Compare commits

...
10 Commits
Author SHA1 Message Date
hypercross 12418898d0 docs: update mesh, viewers, and selection decisions
Document the separated front/back/walls extrusion, material-level back
flips, CardID sprite selection from the parent deck, index-path tree
selection, and the Bounds-inside-Suspense camera fit.
2026-08-08 18:18:22 +08:00
hypercross ead2ba8c57 fix(web): improve scene camera and bounds behavior
Update Bounds props to enable fitting and clipping, adjust OrbitControls
minDistance, and set makeDefault to ensure proper interaction.
2026-08-08 18:16:03 +08:00
hypercross b6bd4612e0 feat: implement suspense-based alpha tracing for token meshes
Refactor `TokenMesh` to use a custom `useTrace` hook that suspends
during the alpha channel tracing process. This allows the `Bounds`
component in `Scene` to correctly fit the camera to the token's
geometry once it has been fully loaded and traced.
2026-08-08 17:54:43 +08:00
hypercross 55e0351c1d fix(web): select tree nodes by index path instead of GUID
Cards in a deck share the deck's GUID, so GUID-based selection highlighted
every card with that GUID and rendered the first match. Key selection by the
node's unique index path instead.
2026-08-08 17:42:16 +08:00
hypercross 835250abdd 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.
2026-08-08 17:41:59 +08:00
hypercross f7139495fe fix(proxy): load clipper-lib via require in ESM
The compiled server runs Node's native ESM loader, where the CommonJS
namespace object nests the real exports under 'default', so
'import * as ClipperLib' left JoinType undefined and crashed with
offset. Match the existing vtracer pattern and require() the package
instead.
2026-08-08 15:40:32 +08:00
hypercross a2f7c998fc feat(web): inset token silhouette by 2px
Pass a negative offset through traceImage so the traced token shape
sits slightly inside the artwork, hiding the anti-aliased fringe.
2026-08-08 15:37:32 +08:00
hypercross 9094ad58e0 feat(proxy): add offset param to inset or outset traced shapes
Trace results can now be inset (negative) or outset (positive) by a
pixel amount. Offset the outline and holes in opposite directions with
clipper-lib's miter joins, then recombine with a boolean difference so
holes grow on inset and shrink on outset. Collapsed shapes return an
empty outline; a split outline keeps the largest ring. Document the
parameter and the new dependency.
2026-08-08 15:37:27 +08:00
hypercross c7f4bd03fd docs: document mesh package and traced token viewer 2026-08-08 15:12:15 +08:00
hypercross a4759be5ac feat(web): trace token shape and texture the whole mesh
TokenViewer now traces the image's alpha channel via /trace and extrudes the silhouette with @tts/mesh, aligning the texture using the full image as the UV framing. The texture is applied to the whole mesh (top, back, and walls) instead of separate solid-white walls.
2026-08-08 15:12:11 +08:00
30 changed files with 1279 additions and 138 deletions
+1
View File
@@ -38,6 +38,7 @@ To run the frontend alongside the proxy, open a second terminal and run
| GET | `/search?q=&page=`| Search the Workshop (scrapes browse page) | | GET | `/search?q=&page=`| Search the Workshop (scrapes browse page) |
| GET | `/items/:id` | Full parsed `TTSMod` (BSON save) | | GET | `/items/:id` | Full parsed `TTSMod` (BSON save) |
| GET | `/items/:id/file` | Raw save bytes, filename from header | | GET | `/items/:id/file` | Raw save bytes, filename from header |
| GET | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON); `offset` insets/outsets in pixels |
## Commands ## Commands
+2
View File
@@ -18,12 +18,14 @@
"@tts/tts": "workspace:*", "@tts/tts": "workspace:*",
"@visioncortex/vtracer": "1.0.0-alpha.3", "@visioncortex/vtracer": "1.0.0-alpha.3",
"bson": "^6.10.4", "bson": "^6.10.4",
"clipper-lib": "^6.4.2",
"hono": "^4.6.14", "hono": "^4.6.14",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"svgpath": "^2.6.0", "svgpath": "^2.6.0",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@types/clipper-lib": "^6.4.0",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.2" "typescript": "^5.7.2"
+96 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { parseSvgShape } from './svgShape.js'; import { offsetShape, parseSvgShape } from './svgShape.js';
describe('parseSvgShape', () => { describe('parseSvgShape', () => {
it('parses a simple closed outline', () => { it('parses a simple closed outline', () => {
@@ -32,4 +32,99 @@ describe('parseSvgShape', () => {
const shape = parseSvgShape('<svg></svg>'); const shape = parseSvgShape('<svg></svg>');
expect(shape.outline).toEqual([]); expect(shape.outline).toEqual([]);
}); });
});
describe('offsetShape', () => {
const square: number[][] = [
[0, 0],
[10, 0],
[10, 10],
[0, 10],
];
it('outsets a square by a positive delta', () => {
const out = offsetShape({ outline: square }, 1).outline;
expect(Math.min(...out.map((p) => p[0]!))).toBeLessThan(0);
expect(Math.min(...out.map((p) => p[1]!))).toBeLessThan(0);
expect(Math.max(...out.map((p) => p[0]!))).toBeGreaterThan(10);
expect(Math.max(...out.map((p) => p[1]!))).toBeGreaterThan(10);
});
it('insets a square by a negative delta', () => {
const out = offsetShape({ outline: square }, -1).outline;
expect(Math.min(...out.map((p) => p[0]!))).toBeGreaterThan(0);
expect(Math.min(...out.map((p) => p[1]!))).toBeGreaterThan(0);
expect(Math.max(...out.map((p) => p[0]!))).toBeLessThan(10);
expect(Math.max(...out.map((p) => p[1]!))).toBeLessThan(10);
});
it('collapses an inset that is too large', () => {
const out = offsetShape({ outline: square }, -10);
expect(out.outline).toEqual([]);
});
it('offsets concave outlines without self-intersection', () => {
const concave = [
[0, 0],
[10, 0],
[5, 5],
[10, 10],
[0, 10],
];
const out = offsetShape({ outline: concave }, -1).outline;
// The result must stay inside the original bounding box (no flipped
// vertices), and the outline must be strictly smaller in extent.
expect(Math.min(...out.map((p) => p[0]!))).toBeGreaterThanOrEqual(0);
expect(Math.min(...out.map((p) => p[1]!))).toBeGreaterThanOrEqual(0);
expect(Math.max(...out.map((p) => p[0]!))).toBeLessThanOrEqual(10);
expect(Math.max(...out.map((p) => p[1]!))).toBeLessThanOrEqual(10);
});
it('grows holes when insetting and shrinks them when outsetting', () => {
const shape = {
outline: [
[0, 0],
[20, 0],
[20, 20],
[0, 20],
],
holes: [[[8, 8], [12, 8], [12, 12], [8, 12]]],
};
const inset = offsetShape(shape, -2);
expect(inset.holes).toBeDefined();
// Hole grows from 8..12 to 6..14 on inset.
expect(Math.min(...inset.holes![0]!.map((p) => p[0]!))).toBeCloseTo(6);
// A smaller hole outgrows entirely on a moderate outset.
const tiny = {
outline: [
[0, 0],
[20, 0],
[20, 20],
[0, 20],
],
holes: [[[8, 8], [10, 8], [10, 10], [8, 10]]],
};
const outset = offsetShape(tiny, 2);
expect(outset.holes).toBeUndefined();
});
it('removes a hole that is outgrown by an outset', () => {
const shape = {
outline: [
[0, 0],
[0, 20],
[20, 20],
[20, 0],
],
holes: [[[8, 8], [8, 12], [12, 12], [12, 8]]],
};
const out = offsetShape(shape, 6);
expect(out.holes).toBeUndefined();
});
it('returns the same shape for a zero delta', () => {
const out = offsetShape({ outline: square }, 0);
expect(out).toEqual({ outline: square });
});
}); });
+91
View File
@@ -1,6 +1,12 @@
import { createRequire } from 'module';
import svgpath from 'svgpath'; import svgpath from 'svgpath';
import type { TracedShape } from '@tts/shared'; import type { TracedShape } from '@tts/shared';
// clipper-lib is a CommonJS package; load it via require so the enums and
// classes resolve under Node's native ESM loader without a `default` unwrap.
const require = createRequire(import.meta.url);
const ClipperLib = require('clipper-lib') as typeof import('clipper-lib');
/** Number of samples per cubic bezier when flattening curves to polylines. */ /** Number of samples per cubic bezier when flattening curves to polylines. */
const CURVE_STEPS = 12; const CURVE_STEPS = 12;
@@ -58,6 +64,91 @@ export function parseSvgShape(svg: string): TracedShape {
}; };
} }
/**
* Offset a traced shape's outline and holes by `delta` pixels. Positive values
* expand the shape (outset); negative values shrink it (inset). Holes move
* opposite to the outline, so outsetting shrinks holes and insetting grows
* them.
*
* Uses Clipper's offsetting algorithm (Angus Johnson's Clipper ported to JS),
* which handles concave shapes and degenerate cases: an inset that collapses
* the outline yields an empty shape, a hole that is outgrown disappears, and
* a deep inset may split the outline into multiple rings (the largest is
* kept, since `TracedShape` supports a single outline).
*
* Each contour is offset independently, so the outline and holes are offset
* in opposite directions and then recombined with a boolean difference; a
* single `ClipperOffset` call would shrink holes on an inset instead of
* growing them.
*
* `SCALE` converts float coordinates to the fixed-point integers Clipper
* expects; coordinates are rounded to `1/SCALE` pixel.
*/
const SCALE = 100;
export function offsetShape(shape: TracedShape, delta: number): TracedShape {
if (delta === 0) return shape;
const outerOffset = offsetContour(shape.outline, delta);
if (outerOffset.length === 0) return { outline: [] };
// Subtract the (oppositely offset) holes from the offset outline so holes
// grow on inset and shrink on outset, as a whole-shape offset should.
const clipper = new ClipperLib.Clipper();
clipper.AddPaths(outerOffset, ClipperLib.PolyType.ptSubject, true);
for (const hole of shape.holes ?? []) {
clipper.AddPaths(offsetContour(hole, -delta), ClipperLib.PolyType.ptClip, true);
}
const solution: ClipperLib.Path[] = [];
clipper.Execute(
ClipperLib.ClipType.ctDifference,
solution,
ClipperLib.PolyFillType.pftNonZero,
ClipperLib.PolyFillType.pftNonZero,
);
if (solution.length === 0) return { outline: [] };
// Clipper output rings are closed (first point repeated). Drop the
// duplicated point and keep the largest ring as the outline; the rest are
// holes. Clipper orients outer rings CCW and holes CW, which matches the
// `TracedShape` convention for `@tts/mesh`.
const rings = solution
.map(uncloseRing)
.sort((a, b) => Math.abs(signedArea(b)) - Math.abs(signedArea(a)));
const outline = rings[0]!;
const holes = rings.length > 1 ? rings.slice(1) : undefined;
return { outline, holes };
}
/** Offset a single contour by `delta` scaled units. */
function offsetContour(ring: number[][], delta: number): ClipperLib.Paths {
if (ring.length < 3) return [];
const co = new ClipperLib.ClipperOffset(
ClipperLib.JoinType.jtMiter,
2, // miter limit: sharp corners are beveled beyond this ratio
);
co.AddPath(
ring.map((p) => ({ X: p[0]! * SCALE, Y: p[1]! * SCALE })),
ClipperLib.JoinType.jtMiter,
ClipperLib.EndType.etClosedPolygon,
);
const solution: ClipperLib.Path[] = [];
co.Execute(solution, delta * SCALE);
return solution;
}
/** Drop the duplicated closing point of a closed ring. */
function uncloseRing(points: ClipperLib.Path): number[][] {
const out = points.map((p) => [p.X / SCALE, p.Y / SCALE]);
const first = out[0]!;
const last = out[out.length - 1]!;
if (first[0] === last[0] && first[1] === last[1]) out.pop();
return out;
}
/** Split a path's `d` attribute into rings, one per `M` subpath. */ /** Split a path's `d` attribute into rings, one per `M` subpath. */
function parseRings(d: string): number[][][] { function parseRings(d: string): number[][][] {
const rings: number[][][] = []; const rings: number[][][] = [];
+44
View File
@@ -100,4 +100,48 @@ describe('trace route', () => {
expect(result.mode).toBe('bw'); expect(result.mode).toBe('bw');
expect(result.shape.outline.length).toBeGreaterThan(3); expect(result.shape.outline.length).toBeGreaterThan(3);
}); });
it('applies an inset offset to the shape', async () => {
stubFetch(await makePng());
const res = await trace.request(
'/?url=https%3A%2F%2Fexample.com%2Fa.png&offset=-2',
);
expect(res.status).toBe(200);
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
expect(result.offset).toBe(-2);
expect(result.shape.outline.length).toBeGreaterThan(3);
// An inset of 2px on a 20px circle must be visibly smaller.
const extent = (pts: number[][]) => [
Math.min(...pts.map((p) => p[0]!)),
Math.max(...pts.map((p) => p[0]!)),
];
const [minX, maxX] = extent(result.shape.outline);
expect((maxX ?? 0) - (minX ?? 0)).toBeLessThan(20);
});
it('applies an outset offset to the shape', async () => {
stubFetch(await makePng());
const res = await trace.request(
'/?url=https%3A%2F%2Fexample.com%2Fa.png&offset=2',
);
expect(res.status).toBe(200);
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
expect(result.offset).toBe(2);
expect(result.shape.outline.length).toBeGreaterThan(3);
});
it('rejects a non-numeric offset', async () => {
stubFetch(await makePng());
const res = await trace.request(
'/?url=https%3A%2F%2Fexample.com%2Fa.png&offset=abc',
);
expect(res.status).toBe(400);
});
it('omits offset when not requested', async () => {
stubFetch(await makePng());
const res = await trace.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
expect(result.offset).toBeUndefined();
});
}); });
+9 -3
View File
@@ -3,7 +3,7 @@ import { serialize } from 'bson';
import sharp from 'sharp'; import sharp from 'sharp';
import { createRequire } from 'module'; import { createRequire } from 'module';
import { traceRequestSchema, type TraceResult } from '@tts/shared'; import { traceRequestSchema, type TraceResult } from '@tts/shared';
import { parseSvgShape } from './svgShape.js'; import { offsetShape, parseSvgShape } from './svgShape.js';
// vtracer is a CommonJS package; load it via require so the wasm initializes // vtracer is a CommonJS package; load it via require so the wasm initializes
// with the correct `__dirname`. // with the correct `__dirname`.
@@ -36,7 +36,8 @@ app.get('/', async (c) => {
if (!parsed.success) { if (!parsed.success) {
return c.json({ error: parsed.error.issues[0]?.message }, 400); return c.json({ error: parsed.error.issues[0]?.message }, 400);
} }
const { url, mode, threshold, format, simplify, maxColors } = parsed.data; const { url, mode, threshold, format, simplify, maxColors, offset } =
parsed.data;
// Only allow http(s) to avoid SSRF via file://, etc. // Only allow http(s) to avoid SSRF via file://, etc.
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
@@ -95,7 +96,12 @@ app.get('/', async (c) => {
svg, svg,
}; };
if (format === 'shape') { if (format === 'shape') {
result.shape = parseSvgShape(svg); let shape = parseSvgShape(svg);
if (offset !== undefined && offset !== 0) {
shape = offsetShape(shape, offset);
result.offset = offset;
}
result.shape = shape;
} }
return new Response(Buffer.from(serialize(result)), { return new Response(Buffer.from(serialize(result)), {
+1
View File
@@ -21,6 +21,7 @@
"@tts/extract": "workspace:*", "@tts/extract": "workspace:*",
"@tts/mesh": "workspace:*", "@tts/mesh": "workspace:*",
"@tts/shared": "workspace:*", "@tts/shared": "workspace:*",
"bson": "^7.3.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
+23 -1
View File
@@ -1,4 +1,5 @@
import type { SearchResult, TTSMod } from '@tts/shared'; import { deserialize } from 'bson';
import type { SearchResult, TraceResult, TTSMod } from '@tts/shared';
const BASE = ''; const BASE = '';
@@ -17,6 +18,27 @@ export function searchWorkshop(q: string, page = 1): Promise<SearchResult> {
return getJson<SearchResult>(`/search?${params.toString()}`); return getJson<SearchResult>(`/search?${params.toString()}`);
} }
/**
* Trace an image into a vector shape, BSON-deserializing the proxy response.
* `mode` controls how the region is derived (`alpha`, `bw`, `color`); a
* non-zero `offset` insets (negative) or outsets (positive) the shape in
* pixels.
*/
export async function traceImage(
url: string,
mode: 'alpha' | 'bw' | 'color' = 'alpha',
offset?: number,
): Promise<TraceResult> {
const params = new URLSearchParams({ url, mode });
if (offset !== undefined) params.set('offset', String(offset));
const res = await fetch(`${BASE}/trace?${params.toString()}`);
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(body?.error ?? `Trace failed (${res.status})`);
}
return deserialize(new Uint8Array(await res.arrayBuffer())) as TraceResult;
}
/** Fetch a full parsed TTS save. */ /** Fetch a full parsed TTS save. */
export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> { export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> {
const params = new URLSearchParams(); const params = new URLSearchParams();
+14 -11
View File
@@ -5,11 +5,11 @@ import { iconsForObject } from './objectIcons';
interface Props { interface Props {
nodes: ObjectTreeNode[]; nodes: ObjectTreeNode[];
selectedGuid: string | null; selectedPath: string | null;
onSelect: (guid: string) => void; onSelect: (path: string) => void;
} }
export default function ObjectTree({ nodes, selectedGuid, onSelect }: Props) { export default function ObjectTree({ nodes, selectedPath, onSelect }: Props) {
return ( return (
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{nodes.map((node, index) => ( {nodes.map((node, index) => (
@@ -18,7 +18,7 @@ export default function ObjectTree({ nodes, selectedGuid, onSelect }: Props) {
node={node} node={node}
depth={0} depth={0}
path={`${index}`} path={`${index}`}
selectedGuid={selectedGuid} selectedPath={selectedPath}
onSelect={onSelect} onSelect={onSelect}
/> />
))} ))}
@@ -30,17 +30,20 @@ function TreeNode({
node, node,
depth, depth,
path, path,
selectedGuid, selectedPath,
onSelect, onSelect,
}: { }: {
node: ObjectTreeNode; node: ObjectTreeNode;
depth: number; depth: number;
/** Index path from the root, used as a stable unique key. */ /** Index path from the root, used as a stable unique key and selection id. */
path: string; path: string;
selectedGuid: string | null; selectedPath: string | null;
onSelect: (guid: string) => void; onSelect: (path: string) => void;
}) { }) {
const selected = node.object.GUID === selectedGuid; // Selection is keyed by the node's unique index path, not its GUID: cards in
// a deck frequently share a GUID (the deck's), so GUID-based selection would
// highlight and render the wrong card.
const selected = path === selectedPath;
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
return ( return (
@@ -69,7 +72,7 @@ function TreeNode({
<span className="h-6 w-6 shrink-0" /> <span className="h-6 w-6 shrink-0" />
)} )}
<button <button
onClick={() => onSelect(node.object.GUID)} onClick={() => onSelect(path)}
className="block min-w-0 flex-1 truncate py-1 text-left text-sm" className="block min-w-0 flex-1 truncate py-1 text-left text-sm"
> >
<span <span
@@ -91,7 +94,7 @@ function TreeNode({
node={child} node={child}
depth={depth + 1} depth={depth + 1}
path={`${path}-${index}`} path={`${path}-${index}`}
selectedGuid={selectedGuid} selectedPath={selectedPath}
onSelect={onSelect} onSelect={onSelect}
/> />
))} ))}
+3
View File
@@ -7,9 +7,12 @@ import './objectIconsData';
*/ */
const OBJECT_ICONS: Record<string, string[]> = { const OBJECT_ICONS: Record<string, string[]> = {
Card: ['mdi:cards-outline'], Card: ['mdi:cards-outline'],
CardCustom: ['mdi:cards-outline'],
Deck: ['mdi:cards'], Deck: ['mdi:cards'],
DeckCustom: ['mdi:cards'], DeckCustom: ['mdi:cards'],
Custom_Deck: ['mdi:cards'], Custom_Deck: ['mdi:cards'],
Bag: ['material-symbols:folder'], Bag: ['material-symbols:folder'],
Custom_Model_Bag: ['file-icons:3d-model', 'material-symbols:folder'], Custom_Model_Bag: ['file-icons:3d-model', 'material-symbols:folder'],
Custom_Model_Infinite_Bag: ['file-icons:3d-model','boxicons:infinite'], 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 { 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 {
extrudeShapeParts,
roundedRectShape,
type ExtrudedGeometry,
} from '@tts/mesh';
import Scene from './Scene'; import Scene from './Scene';
import { assetUrl } from './assetUrl'; 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 * A playing card: a thin rounded rect with the face texture on the front and
* texture on the rear. Reads `CustomDeck` face/back URLs, falling back to a * the back texture on the rear. Covers `Card`/`Deck`/`DeckCustom`/`Custom_Deck`
* neutral color when absent. * (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 }) { export default function CardViewer({ object }: { object: TTSObject }) {
const deck = object.CustomDeck ? Object.values(object.CustomDeck)[0] : undefined; const { cardId, faceUrl, backUrl, numWidth, numHeight, uniqueBack } =
const faceUrl = deck?.FaceURL; resolveCardConfig(object);
const backUrl = deck?.BackURL;
return ( return (
<Scene> <Scene>
<CardMesh faceUrl={faceUrl} backUrl={backUrl} /> <CardMesh
faceUrl={faceUrl}
backUrl={backUrl}
numWidth={numWidth}
numHeight={numHeight}
uniqueBack={uniqueBack}
cardId={cardId}
/>
</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 CardMesh({ faceUrl, backUrl }: { faceUrl?: string; backUrl?: string }) { function CardMesh({
const face = faceUrl ? useTexture(assetUrl(faceUrl)) : null; faceUrl,
const back = backUrl ? useTexture(assetUrl(backUrl)) : null; 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 ( return (
<mesh> <group>
<boxGeometry args={[1.4, 2, 0.06]} /> <mesh geometry={frontGeo}>
<meshStandardMaterial <meshStandardMaterial
color={face || back ? '#ffffff' : '#52525b'} color={faceMap ? '#ffffff' : '#52525b'}
map={face ?? back ?? undefined} map={faceMap ?? undefined}
roughness={0.6} roughness={0.6}
/> />
</mesh> </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;
} }
+11 -5
View File
@@ -1,6 +1,6 @@
import { Suspense, type ReactNode } from 'react'; import { Suspense, type ReactNode } from 'react';
import { Canvas } from '@react-three/fiber'; import { Canvas } from '@react-three/fiber';
import { ContactShadows, OrbitControls } from '@react-three/drei'; import { Bounds, ContactShadows, OrbitControls } from '@react-three/drei';
import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing'; import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing';
/** /**
@@ -8,6 +8,10 @@ import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing';
* lighting, orbit controls, a soft contact shadow, and subtle post-processing * lighting, orbit controls, a soft contact shadow, and subtle post-processing
* (bloom + vignette). Children are wrapped in a Suspense boundary so loading * (bloom + vignette). Children are wrapped in a Suspense boundary so loading
* assets (textures, models) can suspend without blanking the page. * assets (textures, models) can suspend without blanking the page.
*
* The camera is fitted to the bounds of the content on mount. `Bounds` sits
* inside the Suspense boundary, so it only mounts once the (suspending) content
* has loaded and its geometry is present.
*/ */
export default function Scene({ children }: { children: ReactNode }) { export default function Scene({ children }: { children: ReactNode }) {
return ( return (
@@ -22,7 +26,9 @@ export default function Scene({ children }: { children: ReactNode }) {
<directionalLight position={[-4, 2, -3]} intensity={0.4} color="#b3c7ff" /> <directionalLight position={[-4, 2, -3]} intensity={0.4} color="#b3c7ff" />
<pointLight position={[0, 3, 0]} intensity={0.3} /> <pointLight position={[0, 3, 0]} intensity={0.3} />
<Suspense fallback={null}>{children}</Suspense> <Suspense fallback={null}>
<Bounds fit observe clip>{children}</Bounds>
</Suspense>
<ContactShadows <ContactShadows
position={[0, -0.5, 0]} position={[0, -0.5, 0]}
@@ -34,10 +40,10 @@ export default function Scene({ children }: { children: ReactNode }) {
/> />
<OrbitControls <OrbitControls
enablePan={false} enablePan={false}
minDistance={1} minDistance={0.01}
maxDistance={8} maxDistance={8}
autoRotate autoRotate
autoRotateSpeed={1.2} makeDefault
/> />
<EffectComposer> <EffectComposer>
@@ -47,4 +53,4 @@ export default function Scene({ children }: { children: ReactNode }) {
</Canvas> </Canvas>
</div> </div>
); );
} }
+22 -5
View File
@@ -13,6 +13,7 @@ import {
} from '@tts/mesh'; } from '@tts/mesh';
import Scene from './Scene'; import Scene from './Scene';
import { assetUrl } from './assetUrl'; import { assetUrl } from './assetUrl';
import { flipTexture } from './flipTexture';
/** `CustomTile.Type` enum from Tabletop Simulator. */ /** `CustomTile.Type` enum from Tabletop Simulator. */
const TileType = { const TileType = {
@@ -62,24 +63,40 @@ function TileMesh({
// Build the extruded geometry from the tile shape. When `stretch` is false // 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 // 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. // 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 img = texture?.image as HTMLImageElement;
const aspect = stretch ? img.width / img.height : 1; const aspect = stretch ? img.width / img.height : 1;
const shape = tileShape(type, aspect); const shape = tileShape(type, aspect);
const { caps, walls } = extrudeShapeParts(shape, { height: thickness }); const parts = extrudeShapeParts(shape, { height: thickness });
return { caps: toGeometry(caps), walls: toGeometry(walls) }; return {
front: toGeometry(parts.front),
back: toGeometry(parts.back),
walls: toGeometry(parts.walls),
};
}, [type, thickness, stretch, texture]); }, [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 ( return (
<group> <group>
{/* Top/bottom faces carry the tile texture. */} {/* Front face carries the tile texture. */}
<mesh geometry={caps}> <mesh geometry={front}>
<meshStandardMaterial <meshStandardMaterial
color={texture ? '#ffffff' : '#52525b'} color={texture ? '#ffffff' : '#52525b'}
map={texture ?? undefined} map={texture ?? undefined}
roughness={0.8} roughness={0.8}
/> />
</mesh> </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. */} {/* Sides are a solid white, matching TTS tile tinting. */}
<mesh geometry={walls}> <mesh geometry={walls}>
<meshStandardMaterial color="#ffffff" roughness={0.8} /> <meshStandardMaterial color="#ffffff" roughness={0.8} />
+167 -13
View File
@@ -1,11 +1,27 @@
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 {
extrudeShapeParts,
type ExtrudedGeometry,
type Shape,
type UVBounds,
} from '@tts/mesh';
import { traceImage } from '../../api';
import Scene from './Scene'; import Scene from './Scene';
import { assetUrl } from './assetUrl'; import { assetUrl } from './assetUrl';
const TOKEN_SIZE = 1.8;
/** How far (in trace pixels) the token silhouette is inset from the artwork. */
const TRACE_INSET = 2;
/** /**
* A round token: a short cylinder with the texture on its top face. Uses * A token: a short extruded shape with the texture on its top face. Uses
* `CustomImage.ImageURL`, with a neutral color when absent. * `CustomImage.ImageURL` (falling back to `ImageSecondaryURL`), with a neutral
* color when absent. The footprint is traced from the image's alpha channel via
* the proxy `/trace` endpoint, so the token matches the artwork's silhouette.
*/ */
export default function TokenViewer({ object }: { object: TTSObject }) { export default function TokenViewer({ object }: { object: TTSObject }) {
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL; const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
@@ -20,15 +36,153 @@ export default function TokenViewer({ object }: { object: TTSObject }) {
// Rendered inside the Canvas so `useTexture` can access the R3F store. // Rendered inside the Canvas so `useTexture` can access the R3F store.
function TokenMesh({ url, thickness }: { url?: string; thickness: number }) { function TokenMesh({ url, thickness }: { url?: string; thickness: number }) {
const texture = url ? useTexture(assetUrl(url)) : null; const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
return (
<mesh rotation={[-Math.PI / 2, 0, 0]}> // Trace the image's alpha channel into a shape. Suspends until the trace
<cylinderGeometry args={[0.9, 0.9, thickness, 48]} /> // resolves so the surrounding Suspense boundary (and `Bounds`) only mounts
<meshStandardMaterial // once the token geometry is present. Falls back to a circle when there's no
color={texture ? '#ffffff' : '#52525b'} // image or the trace fails.
map={texture ?? undefined} const trace = useTrace(url);
roughness={0.8}
/> const { front, back, walls } = useMemo(() => {
</mesh> // 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;
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 (
<group>
<mesh geometry={front}>{material}</mesh>
<mesh geometry={back}>{material}</mesh>
<mesh geometry={walls}>{material}</mesh>
</group>
);
}
interface TraceData {
shape: { outline: number[][]; holes?: number[][][] };
width: number;
height: number;
}
// Cache trace promises by URL so Suspense doesn't re-issue the request on every
// render while the boundary is held open.
const traceCache = new Map<string, Promise<TraceData | null>>();
/**
* Suspend on the alpha trace for `url`, resolving to the traced shape (or null
* when there's no URL / the trace fails). Throwing a cached promise here lets
* the surrounding Suspense boundary hold rendering until the trace completes.
*/
function useTrace(url: string | undefined): TraceData | null {
if (!url) return null;
let promise = traceCache.get(url);
if (!promise) {
promise = traceImage(url, 'alpha', -TRACE_INSET).then((result) => {
if (!result.shape) return null;
return {
shape: result.shape,
width: result.width,
height: result.height,
} as TraceData;
});
traceCache.set(url, promise);
}
throw promise;
}
/** 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;
}
/** A circle fallback when there's no image to trace. */
function circleShape(): Shape {
const pts: number[][] = [];
for (let i = 0; i < 48; i++) {
const a = (i / 48) * Math.PI * 2;
pts.push([Math.cos(a) * (TOKEN_SIZE / 2), Math.sin(a) * (TOKEN_SIZE / 2)]);
}
return { outline: pts };
}
/**
* Convert a traced shape (image pixel coords, origin top-left, y-down) to a
* mesh `Shape` (y-up, centered at the origin). Flips the y-axis, scales to
* `TOKEN_SIZE`, centers the result, and normalizes winding so the outline is
* counter-clockwise and holes are clockwise (as `@tts/mesh` expects).
*/
function toMeshShape(trace: {
shape: { outline: number[][]; holes?: number[][][] };
width: number;
height: number;
}): Shape {
const { shape, width, height } = trace;
const scale = TOKEN_SIZE / Math.max(width, height);
const ox = (width * scale) / 2;
const oy = (height * scale) / 2;
const transform = (pts: number[][]) =>
pts.map(([x, y]) => [x! * scale - ox, (height - y!) * scale - oy]);
return {
outline: normalizeWinding(transform(shape.outline), true),
holes: shape.holes?.map((h) => normalizeWinding(transform(h), false)),
};
}
/**
* The full image rectangle, in mesh coordinates, used as the UV framing so the
* texture aligns with the traced silhouette (which may be smaller than the
* image when there is transparent padding).
*/
function toUvBounds(trace: {
width: number;
height: number;
}): UVBounds {
const scale = TOKEN_SIZE / Math.max(trace.width, trace.height);
const ox = (trace.width * scale) / 2;
const oy = (trace.height * scale) / 2;
return { minX: -ox, minY: -oy, maxX: ox, maxY: oy };
}
/**
* Ensure a ring has the requested winding. `ccw` true yields a
* counter-clockwise ring (outline); false yields clockwise (hole).
*/
function normalizeWinding(pts: number[][], ccw: boolean): number[][] {
const isCcw = signedArea(pts) > 0;
return isCcw === ccw ? pts : [...pts].reverse();
}
/** Signed area of a polygon; positive means counter-clockwise. */
function signedArea(points: number[][]): number {
let area = 0;
for (let i = 0; i < points.length; i++) {
const [x1, y1] = points[i]!;
const [x2, y2] = points[(i + 1) % points.length]!;
area += x1! * y2! - x2! * y1!;
}
return area / 2;
}
@@ -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_Tile', TileViewer);
registerViewer('Custom_Token', TokenViewer); registerViewer('Custom_Token', TokenViewer);
registerViewer('Card', CardViewer); registerViewer('Card', CardViewer);
registerViewer('CardCustom', CardViewer);
registerViewer('Deck', CardViewer); registerViewer('Deck', CardViewer);
registerViewer('DeckCustom', CardViewer); registerViewer('DeckCustom', CardViewer);
registerViewer('Custom_Deck', CardViewer); registerViewer('Custom_Deck', CardViewer);
+17 -14
View File
@@ -16,7 +16,10 @@ export default function ModPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { mod, loading, error, load } = useModStore(); const { mod, loading, error, load } = useModStore();
const item = useSearchStore((s) => s.items.find((i) => i.id === id)); const item = useSearchStore((s) => s.items.find((i) => i.id === id));
const [selectedGuid, setSelectedGuid] = useState<string | null>(null); // Selection is keyed by the node's index path, not its GUID: cards in a
// deck share the deck's GUID, so GUID-based selection would resolve to the
// wrong card.
const [selectedPath, setSelectedPath] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (id) load(id, item?.fileUrl); if (id) load(id, item?.fileUrl);
@@ -26,8 +29,8 @@ export default function ModPage() {
const refs = useMemo(() => (mod ? collectRefs(mod) : []), [mod]); const refs = useMemo(() => (mod ? collectRefs(mod) : []), [mod]);
const selected = useMemo( const selected = useMemo(
() => (mod ? findInTree(tree, selectedGuid) : undefined), () => (mod ? findInTree(tree, selectedPath) : undefined),
[tree, selectedGuid], [tree, selectedPath],
); );
if (loading) return <p className="text-sm text-zinc-400">Loading mod</p>; if (loading) return <p className="text-sm text-zinc-400">Loading mod</p>;
@@ -56,8 +59,8 @@ export default function ModPage() {
<aside className="rounded-lg border border-zinc-800 bg-zinc-900 p-2"> <aside className="rounded-lg border border-zinc-800 bg-zinc-900 p-2">
<ObjectTree <ObjectTree
nodes={tree} nodes={tree}
selectedGuid={selectedGuid} selectedPath={selectedPath}
onSelect={setSelectedGuid} onSelect={setSelectedPath}
/> />
</aside> </aside>
@@ -87,7 +90,9 @@ export default function ModPage() {
</div> </div>
} }
> >
<Viewer object={selected.object} /> {/* Key by selection path so the Canvas remounts and the
camera refits to the newly selected object. */}
<Viewer key={selectedPath} object={selected.object} />
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
)} )}
@@ -105,13 +110,11 @@ export default function ModPage() {
function findInTree( function findInTree(
nodes: ReturnType<typeof buildTree>, nodes: ReturnType<typeof buildTree>,
guid: string | null, path: string | null,
): ReturnType<typeof buildTree>[number] | undefined { ): ReturnType<typeof buildTree>[number] | undefined {
if (!guid) return undefined; if (!path) return undefined;
for (const node of nodes) { const [head, ...rest] = path.split('-');
if (node.object.GUID === guid) return node; const node = nodes[Number(head)];
const found = findInTree(node.children, guid); if (!node) return undefined;
if (found) return found; return rest.length === 0 ? node : findInTree(node.children, rest.join('-'));
}
return undefined;
} }
+1
View File
@@ -11,6 +11,7 @@ export default defineConfig({
'/items': 'http://localhost:3000', '/items': 'http://localhost:3000',
'/health': 'http://localhost:3000', '/health': 'http://localhost:3000',
'/asset': 'http://localhost:3000', '/asset': 'http://localhost:3000',
'/trace': 'http://localhost:3000',
}, },
}, },
}); });
+9 -4
View File
@@ -31,6 +31,7 @@ shared types/validation package.
| `apps/proxy` | Hono HTTP server: search + fetch endpoints | Node | | `apps/proxy` | Hono HTTP server: search + fetch endpoints | Node |
| `packages/tts` | Fetch save from Steam, BSON-parse to `TTSMod` | Node | | `packages/tts` | Fetch save from Steam, BSON-parse to `TTSMod` | Node |
| `packages/extract` | Analyze a `TTSMod`: objects, refs, assets | Isomorphic | | `packages/extract` | Analyze a `TTSMod`: objects, refs, assets | Isomorphic |
| `packages/mesh` | 2D shapes + extrusion into 3D mesh geometry | Isomorphic |
| `packages/shared` | Shared types + zod schemas | Isomorphic | | `packages/shared` | Shared types + zod schemas | Isomorphic |
## Dependency graph ## Dependency graph
@@ -40,6 +41,7 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared
│ │ │ │ │ │
│ │ └──► (fetchMod → TTSMod) │ │ └──► (fetchMod → TTSMod)
│ ▼ │ ▼
├──► packages/mesh ──► packages/shared (types)
└──► packages/extract ──► packages/shared (types) └──► packages/extract ──► packages/shared (types)
``` ```
@@ -50,6 +52,8 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared
(image → vector shape) over HTTP. (image → vector shape) over HTTP.
- **`apps/web``packages/extract`** — uses `buildTree` / `collectRefs` - **`apps/web``packages/extract`** — uses `buildTree` / `collectRefs`
to analyze a loaded `TTSMod` in the browser (tree sidebar + asset refs). to analyze a loaded `TTSMod` in the browser (tree sidebar + asset refs).
- **`apps/web``packages/mesh`** — extrudes 2D shapes into 3D geometry
(`{ front, back, walls }`) for the tile, token, and card viewers.
- **`apps/proxy``packages/tts`** — calls `fetchMod` / `getFileName` to serve - **`apps/proxy``packages/tts`** — calls `fetchMod` / `getFileName` to serve
item requests. item requests.
- **`apps/proxy``packages/shared`** — uses shared types and zod schemas for - **`apps/proxy``packages/shared`** — uses shared types and zod schemas for
@@ -82,13 +86,14 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared
| `@hono/cors` | CORS middleware | `apps/proxy` | | `@hono/cors` | CORS middleware | `apps/proxy` |
| `bson` | BSON deserialization of TTS save files | `packages/tts` | | `bson` | BSON deserialization of TTS save files | `packages/tts` |
| `@visioncortex/vtracer` | Raster-to-SVG vectorization (wasm) | `apps/proxy` | | `@visioncortex/vtracer` | Raster-to-SVG vectorization (wasm) | `apps/proxy` |
| `clipper-lib` | Polygon offsetting (inset/outset) for traced shapes | `apps/proxy` |
| `sharp` | Image decoding to RGBA | `apps/proxy` | | `sharp` | Image decoding to RGBA | `apps/proxy` |
| `svgpath` | SVG path parsing for traced shapes | `apps/proxy` | | `svgpath` | SVG path parsing for traced shapes | `apps/proxy` |
| `cheerio` | Workshop browse page scraping | `apps/proxy` | | `cheerio` | Workshop browse page scraping | `apps/proxy` |
| `zod` | Runtime validation | `apps/proxy`, `packages/shared` | | `zod` | Runtime validation | `apps/proxy`, `packages/shared` |
| `three` | 3D rendering | `apps/web` | | `three` | 3D rendering | `apps/web` |
| `@react-three/fiber` | React renderer for three.js | `apps/web` | | `@react-three/fiber` | React renderer for three.js | `apps/web` |
| `@react-three/drei` | three.js helpers (controls, textures) | `apps/web` | | `@react-three/drei` | three.js helpers (controls, textures, bounds) | `apps/web` |
| `@react-three/postprocessing` | Post-processing effects | `apps/web` | | `@react-three/postprocessing` | Post-processing effects | `apps/web` |
### Runtime constraints ### Runtime constraints
@@ -97,9 +102,9 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared
must stay isomorphic. must stay isomorphic.
- **`bson` is Node-only** — used by the fetcher and the trace route, not the - **`bson` is Node-only** — used by the fetcher and the trace route, not the
analysis layer. analysis layer.
- **`@visioncortex/vtracer`, `sharp`, `svgpath` are backend-only** — the trace - **`@visioncortex/vtracer`, `sharp`, `svgpath`, `clipper-lib` are
route lives in `apps/proxy`; they never appear in `packages/extract`, which backend-only** — the trace route lives in `apps/proxy`; they never appear in
must stay isomorphic. `packages/extract`, which must stay isomorphic.
- **`packages/extract` has zero external runtime deps** — it relies only on - **`packages/extract` has zero external runtime deps** — it relies only on
platform `fetch` / `Blob`, keeping it portable to a future frontend. platform `fetch` / `Blob`, keeping it portable to a future frontend.
+85 -4
View File
@@ -164,9 +164,11 @@ CORS failures on common Workshop hosts would break the viewers.
**Decision:** The proxy exposes `GET /trace?url=...`, which fetches an image, **Decision:** The proxy exposes `GET /trace?url=...`, which fetches an image,
traces it into a vector shape, and returns the result BSON-encoded. Tracing is traces it into a vector shape, and returns the result BSON-encoded. Tracing is
configurable: `mode` (`alpha` default, `bw`, `color`) selects how the region is configurable: `mode` (`alpha` default, `bw`, `color`) selects how the region is
derived, and `format` (`shape` default, `svg`) selects the response. The derived, `format` (`shape` default, `svg`) selects the response, and `offset`
`shape` format returns a parsed `{ outline, holes }` polygon matching (optional, in pixels) insets (negative) or outsets (positive) the resulting
`@tts/mesh`'s `Shape` interface. shape — used by the token viewer to shave the anti-aliased fringe off a traced
silhouette. The `shape` format returns a parsed `{ outline, holes }` polygon
matching `@tts/mesh`'s `Shape` interface.
**Context:** The user wants to build a mesh from an image (e.g. a token or tile **Context:** The user wants to build a mesh from an image (e.g. a token or tile
art) using `@tts/mesh`. vtracer only returns SVG, but the mesh package consumes art) using `@tts/mesh`. vtracer only returns SVG, but the mesh package consumes
@@ -178,4 +180,83 @@ feeding vtracer's `convertPixels`.
**Alternatives considered:** Returning only the raw SVG and parsing in the web **Alternatives considered:** Returning only the raw SVG and parsing in the web
app near `@tts/mesh`; tracing by color only (no alpha). Rejected — server-side app near `@tts/mesh`; tracing by color only (no alpha). Rejected — server-side
parsing yields a shape the mesh package can consume directly, and alpha-based parsing yields a shape the mesh package can consume directly, and alpha-based
tracing (the default) is the common case for token/tile art. tracing (the default) is the common case for token/tile art. For shape
inset/outset, `clipper-lib` (Angus Johnson's Clipper ported to JS) was chosen
over the `polygon-offset` package because the latter crashes on degenerate
cases (collapse, hole closure) via a bug in its pinned Martinez dependency.
## D14 — Extrusion exposes separated front/back/walls
**Decision:** `extrudeShapeParts` returns `{ front, back, walls }` as separate
geometries, and `tessellate.ts` exposes `frontFaces` / `backFaces` (with
`capFaces` kept as a merged convenience).
**Context:** Cards and tiles need distinct materials on the front and back
faces, and the back must be flipped so it isn't mirrored when viewed from
behind. Splitting the caps into front/back at the mesh level lets each viewer
apply its own material without post-hoc geometry-group splitting.
**Alternatives considered:** Returning a single merged caps geometry and
splitting it in the viewer (the previous approach for cards). Rejected —
required manual triangle-group bookkeeping in the component.
## D15 — Back faces are flipped on the material, not the geometry
**Decision:** The back face is un-mirrored by flipping the texture on the
material (`flipTexture.ts` negates `repeat.x` and shifts `offset.x`), rather
than by transforming the geometry's UVs.
**Context:** The back cap maps with the same planar UVs as the front, so
without a flip it appears mirrored. Flipping on the material keeps the mesh
geometry simple and shared, and works for both a full texture and a sprite
cell.
**Alternatives considered:** Flipping the UVs in `backFaces`. Rejected —
would bake the flip into the shared mesh package, forcing it on every consumer
rather than letting viewers opt in.
## D16 — Card sprite selection via `CardID` and the parent deck
**Decision:** A card's face/back sprite is selected from the deck sheet by
`CardID` (deck index in the hundreds place, 0-based card number in the last
two digits). The sheet config (grid, face/back URLs, `UniqueBack`) is resolved
from the containing deck's `CustomDeck[deckIndex]`, which is authoritative
over the card's own `CustomDeck` (often keyed differently or absent).
**Context:** Deck images are sheets divided into a `NumWidth` x `NumHeight`
grid. The card's own `CustomDeck` field is unreliable — in the Wingspan dump a
card with `CardID 1605` carries `CustomDeck: {14: ...}` even though its deck
index is 16 — so the parent deck is the source of truth.
**Alternatives considered:** Using the card's own `CustomDeck`. Rejected —
produces the wrong sprite for cards whose own field is mis-keyed or missing.
## D17 — Tree selection by index path, not GUID
**Decision:** The object tree selects nodes by their unique index path (e.g.
`0-3-1`) rather than by `GUID`.
**Context:** Cards in a deck share the deck's GUID — in the Wingspan dump, 292
of 606 objects carry a duplicate GUID. GUID-based selection highlighted every
card with that GUID and rendered the first match, so the wrong sprite could
show.
**Alternatives considered:** Using `GUID` (the previous approach). Rejected —
ambiguous for decked cards.
## D18 — Camera fit via drei `Bounds` inside Suspense
**Decision:** The viewer camera is fitted to the object's bounds using drei's
`Bounds` component, placed inside the scene's Suspense boundary so it mounts
only after the (suspending) content has loaded.
**Context:** Viewers load content asynchronously (textures suspend, tokens
trace via the proxy, models stream in). `Bounds` fits on mount, so it must
mount after the content is present. The token viewer was converted from
`useEffect` + state to a suspending resource so it participates in the same
boundary.
**Alternatives considered:** A polling `CameraFit` that waited for non-empty
bounds each frame; hand-rolled camera math. Rejected — Suspense already
signals content readiness, so `Bounds` inside the boundary fits the loaded
geometry directly.
+69 -14
View File
@@ -30,6 +30,7 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared
│ │ │ │ │ │
│ │ └──► (fetchMod → TTSMod) │ │ └──► (fetchMod → TTSMod)
│ ▼ │ ▼
├──► packages/mesh ──► packages/shared (types)
└──► packages/extract ──► packages/shared (types) └──► packages/extract ──► packages/shared (types)
(isomorphic, used by the frontend) (isomorphic, used by the frontend)
``` ```
@@ -40,6 +41,8 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared
derivation. Returns raw parsed `TTSMod`. derivation. Returns raw parsed `TTSMod`.
- **`packages/extract`** — analysis: flatten/filter objects, extract asset refs, - **`packages/extract`** — analysis: flatten/filter objects, extract asset refs,
download assets. Isomorphic (browser + Node). download assets. Isomorphic (browser + Node).
- **`packages/mesh`** — 2D shapes + extrusion into 3D geometry for the
frontend viewers. Isomorphic (browser + Node).
- **`packages/shared`** — shared types + zod schemas. - **`packages/shared`** — shared types + zod schemas.
## Repository layout ## Repository layout
@@ -107,6 +110,14 @@ tts-workshop/
│ └── src/ │ └── src/
│ ├── index.ts # fetchMod, getFileName │ ├── index.ts # fetchMod, getFileName
│ └── errors.ts │ └── errors.ts
├── mesh/
│ └── src/
│ ├── index.ts # public API barrel
│ ├── types.ts # FaceGeometry, ExtrudedGeometry, UVBounds
│ ├── shapes.ts # Shape + shape generators (rect, circle, ...)
│ ├── tessellate.ts # triangulate + cap faces
│ ├── walls.ts # side walls
│ └── extrude.ts # extrudeShape / extrudeShapeParts
└── extract/ └── extract/
├── package.json ├── package.json
├── tsconfig.json ├── tsconfig.json
@@ -183,6 +194,35 @@ Isomorphic analysis of a parsed `TTSMod`. No Node-specific APIs.
- No Node-only packages (`cheerio` stays in the backend search only). - No Node-only packages (`cheerio` stays in the backend search only).
- Pure, deterministic functions where possible. - Pure, deterministic functions where possible.
### `packages/mesh`
2D shape + extrusion library used by the frontend viewers to build 3D geometry.
- `shapes.ts`
- `Shape` — `{ outline, holes }`, the minimal interface the tessellator and
wall generator need. Shape generators: `rectShape`, `polygonShape`,
`hexShape`, `circleShape`, `roundedRectShape`, `frameShape`, plus
`scaleShape` and `signedArea`.
- `tessellate.ts`
- `triangulate(shape)` — earcut triangulation (same as three.js).
- `frontFaces(shape, height, uvScale, uvBounds)` — top face, normal +Z.
- `backFaces(shape, height, uvScale, uvBounds)` — bottom face, normal -Z.
UVs map the shape's bounding box (or `uvBounds` framing) to the unit
square; the back uses the same planar xy mapping as the front (no mirror).
- `capFaces(...)` — front + back merged into one geometry (front vertices
first, then back).
- `walls.ts`
- `wallFaces(shape, height, uvScale, uvBounds)` — side walls with outward
normals and planar xy UVs (z-independent).
- `extrude.ts`
- `extrudeShape(shape, options)` — merged front + back + walls as one
geometry.
- `extrudeShapeParts(shape, options)` — `{ front, back, walls }` as separate
geometries, so each face can carry its own material.
- `ExtrudeOptions` — `height`, `capUvScale`, `wallUvScale`, `uvBounds`.
- `types.ts`
- `FaceGeometry`, `ExtrudedGeometry`, `UVBounds`.
### `apps/proxy` ### `apps/proxy`
Hono server exposing search + fetch. Hono server exposing search + fetch.
@@ -205,16 +245,23 @@ Hono server exposing search + fetch.
headers, which would block three.js loaders in the browser; routing through headers, which would block three.js loaders in the browser; routing through
the proxy makes those assets loadable. Only `http(s)` URLs are allowed. the proxy makes those assets loadable. Only `http(s)` URLs are allowed.
- `routes/trace.ts` - `routes/trace.ts`
- `GET /trace?url=...&mode=alpha&threshold=128&format=shape` — fetch an - `GET /trace?url=...&mode=alpha&threshold=128&format=shape&offset=...` —
image, trace it into a vector shape, and return the result BSON-encoded. fetch an image, trace it into a vector shape, and return the result
`mode` is `alpha` (default), `bw`, or `color`; `format` is `shape` BSON-encoded. `mode` is `alpha` (default), `bw`, or `color`; `format` is
(default) or `svg`. `shape` returns a parsed `{ outline, holes }` polygon `shape` (default) or `svg`; `offset` (optional) insets (negative) or
matching `@tts/mesh`'s `Shape` interface, ready to extrude. outsets (positive) the shape in pixels. `shape` returns a parsed
`{ outline, holes }` polygon matching `@tts/mesh`'s `Shape` interface,
ready to extrude.
- `routes/svgShape.ts` - `routes/svgShape.ts`
- `parseSvgShape(svg)` — parse a vtracer SVG into a `TracedShape` - `parseSvgShape(svg)` — parse a vtracer SVG into a `TracedShape`
(`{ outline, holes }`): flatten beziers to polylines, split subpaths into (`{ outline, holes }`): flatten beziers to polylines, split subpaths into
rings, classify by winding (CCW outline / CW hole), and assign holes to rings, classify by winding (CCW outline / CW hole), and assign holes to
their containing outline. their containing outline.
- `offsetShape(shape, delta)` — inset/outset a `TracedShape` via
`clipper-lib` (Clipper miter joins); outline and holes offset in opposite
directions and are recombined with a boolean difference, so holes grow on
inset and shrink on outset. Collapsed shapes return an empty outline; a
split outline keeps the largest ring.
- `routes/health.ts` - `routes/health.ts`
- `GET /health` — liveness. - `GET /health` — liveness.
- `env.ts` — zod validation of `STEAM_API_KEY`, `PORT`. - `env.ts` — zod validation of `STEAM_API_KEY`, `PORT`.
@@ -235,20 +282,28 @@ proxy API and `packages/extract` directly for analysis.
- `components/SearchResults.tsx` — result grid + pagination. - `components/SearchResults.tsx` — result grid + pagination.
- `components/ObjectTree.tsx` — recursive tree sidebar; each entry shows a - `components/ObjectTree.tsx` — recursive tree sidebar; each entry shows a
class icon (hover for the class name) + display label, indented by depth. class icon (hover for the class name) + display label, indented by depth.
Clicking selects an object. Clicking selects an object by its unique index path (not GUID — cards in a
deck share the deck's GUID).
- `components/viewers.tsx` — viewer registry (`registerViewer` / - `components/viewers.tsx` — viewer registry (`registerViewer` /
`resolveViewer`) plus a `DefaultViewer` that renders an object's fields; `resolveViewer`) plus a `DefaultViewer` that renders an object's fields;
custom per-class viewers can be registered later. custom per-class viewers can be registered later.
- `components/viewers/` — 3D viewers built on `@react-three/fiber`, - `components/viewers/` — 3D viewers built on `@react-three/fiber`,
`@react-three/drei`, and `@react-three/postprocessing`. `register.ts` `@react-three/drei`, and `@react-three/postprocessing`. `register.ts`
registers lazy-loaded viewers for `Tile`/`Custom_Tile` (flat box), registers lazy-loaded viewers for `Tile`/`Custom_Tile` (flat box),
`Custom_Token` (cylinder), `Card`/`Deck`/`Custom_Deck` (thin box with `Custom_Token` (shape traced from the image's alpha channel via `/trace`,
face/back textures), and `Custom_Model`/`Custom_Model_Bag`/ extruded with `@tts/mesh`), `Card`/`CardCustom`/`Deck`/`DeckCustom`/
`Custom_Model_Infinite_Bag` (GLTF/OBJ/FBX from `CustomMesh.MeshURL`). `Custom_Deck` (thin rounded rect with face/back textures), and
`Scene.tsx` is a shared canvas with lighting, orbit controls, contact `Custom_Model`/`Custom_Model_Bag`/`Custom_Model_Infinite_Bag` (GLTF/OBJ/FBX
shadows, and subtle bloom/vignette. `assetUrl.ts` routes asset URLs through from `CustomMesh.MeshURL`). Viewers build `{ front, back, walls }` geometry
the proxy for CORS-safe loading. The viewers are lazy-loaded so the three.js via `extrudeShapeParts`; back faces are flipped left/right on the material
stack is code-split out of the main bundle. (`flipTexture.ts`) so they aren't mirrored, and card faces slice the deck
sprite sheet via `CardID` (`cardResolution.ts`). `Scene.tsx` is a shared
canvas with lighting, orbit controls, contact shadows, and subtle
bloom/vignette; it fits the camera to the object's bounds via drei's
`Bounds` inside the Suspense boundary, so it frames the loaded content.
`assetUrl.ts` routes asset URLs through the proxy for CORS-safe loading.
The viewers are lazy-loaded so the three.js stack is code-split out of the
main bundle.
- `components/objectIcons.tsx` — maps TTS object classes to one or more - `components/objectIcons.tsx` — maps TTS object classes to one or more
Iconify icons (`iconsForObject`); unknown classes fall back to a help icon. Iconify icons (`iconsForObject`); unknown classes fall back to a help icon.
Icons may come from multiple sets (mdi, material-symbols, file-icons, ...); Icons may come from multiple sets (mdi, material-symbols, file-icons, ...);
@@ -272,7 +327,7 @@ proxy API and `packages/extract` directly for analysis.
| GET | `/items/:id` | Full parsed `TTSMod` (`?fileUrl=` skips key) | key* | | GET | `/items/:id` | Full parsed `TTSMod` (`?fileUrl=` skips key) | key* |
| GET | `/items/:id/file` | Raw save bytes, filename from header | key* | | GET | `/items/:id/file` | Raw save bytes, filename from header | key* |
| GET | `/asset?url=` | CORS-safe proxy for external assets | — | | GET | `/asset?url=` | CORS-safe proxy for external assets | — |
| GET | `/trace?url=&mode=&format=` | Trace an image into a vector shape (BSON) | — | | GET | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON) | — |
\* `STEAM_API_KEY` is optional; `/items/*` works without it when a `fileUrl` \* `STEAM_API_KEY` is optional; `/items/*` works without it when a `fileUrl`
query param is supplied. query param is supplied.
+25 -10
View File
@@ -62,27 +62,42 @@ describe('extrudeShape', () => {
}); });
}); });
describe('extrudeShapeParts', () => { describe('extrudeShapeParts', () => {
it('returns caps and walls as separate geometries', () => { it('returns front, back, and walls as separate geometries', () => {
const { caps, walls } = extrudeShapeParts(rectShape(2, 2), { height: 1 }); const { front, back, walls } = extrudeShapeParts(rectShape(2, 2), { height: 1 });
// Caps: 2 faces * 4 outline points = 8 vertices. // Each face: 4 outline points.
expect(caps.positions.length / 3).toBe(8); expect(front.positions.length / 3).toBe(4);
expect(back.positions.length / 3).toBe(4);
// Walls: 4 outline points * 2 vertices = 8 vertices. // Walls: 4 outline points * 2 vertices = 8 vertices.
expect(walls.positions.length / 3).toBe(8); expect(walls.positions.length / 3).toBe(8);
// Combined, they match `extrudeShape`. // Combined, they match `extrudeShape`.
const combined = extrudeShape(rectShape(2, 2), { height: 1 }); const combined = extrudeShape(rectShape(2, 2), { height: 1 });
expect(caps.positions.length + walls.positions.length).toBe(combined.positions.length); expect(front.positions.length + back.positions.length + walls.positions.length).toBe(
expect(caps.indices.length + walls.indices.length).toBe(combined.indices.length); 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 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, height: 1,
uvBounds, uvBounds,
}); });
// Caps: bottom-left vertex at (-0.5,-0.5) -> u=-0.125. // Front: bottom-left vertex at (-0.5,-0.5) -> u=-0.125.
expect(caps.uvs[0]).toBeCloseTo(-0.125, 5); 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. // 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[0]).toBeCloseTo(-0.125, 5);
expect(walls.uvs[1]).toBeCloseTo(-0.125, 5); expect(walls.uvs[1]).toBeCloseTo(-0.125, 5);
+7 -6
View File
@@ -1,6 +1,6 @@
import type { ExtrudedGeometry, FaceGeometry, UVBounds } from './types.js'; import type { ExtrudedGeometry, FaceGeometry, UVBounds } from './types.js';
import type { Shape } from './shapes.js'; import type { Shape } from './shapes.js';
import { capFaces } from './tessellate.js'; import { backFaces, capFaces, frontFaces } from './tessellate.js';
import { wallFaces } from './walls.js'; import { wallFaces } from './walls.js';
export interface ExtrudeOptions { 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 * Extrude a shape, returning the front face, back face, and walls as separate
* separate geometries. This lets callers apply different materials to the * geometries. This lets callers apply different materials to the textured
* textured faces versus the sides (e.g. white, tintable walls on a tile). * faces versus the sides (e.g. white, tintable walls on a tile).
*/ */
export function extrudeShapeParts( export function extrudeShapeParts(
shape: Shape, shape: Shape,
options: ExtrudeOptions = {}, options: ExtrudeOptions = {},
): { caps: ExtrudedGeometry; walls: ExtrudedGeometry } { ): { front: ExtrudedGeometry; back: ExtrudedGeometry; walls: ExtrudedGeometry } {
const height = options.height ?? 1; const height = options.height ?? 1;
const capUvScale = options.capUvScale ?? 1; const capUvScale = options.capUvScale ?? 1;
const wallUvScale = options.wallUvScale ?? 1; const wallUvScale = options.wallUvScale ?? 1;
const uvBounds = options.uvBounds; const uvBounds = options.uvBounds;
return { 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)), walls: faceToGeometry(wallFaces(shape, height, wallUvScale, uvBounds)),
}; };
} }
+61 -29
View File
@@ -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 * 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 * +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 * the shape's bounding box, so the texture aligns to a larger framing (e.g. a
* traced silhouette inside a transparent image canvas). * traced silhouette inside a transparent image canvas).
*/ */
export function capFaces( function buildCap(
shape: Shape, shape: Shape,
height: number, height: number,
uvScale = 1, uvScale: number,
uvBounds?: UVBounds, uvBounds: UVBounds | undefined,
z: number,
normalZ: number,
reverse: boolean,
): FaceGeometry { ): FaceGeometry {
const triangles = triangulate(shape); const triangles = triangulate(shape);
@@ -67,41 +70,70 @@ export function capFaces(
const normals: number[] = []; const normals: number[] = [];
const indices: number[] = []; const indices: number[] = [];
// Top face.
const topBase = 0;
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, height); positions.push(x, y, z);
uvs.push(((x - minX) / spanX) * uvScale, ((y - minY) / spanY) * uvScale); uvs.push(((x - minX) / spanX) * uvScale, ((y - minY) / spanY) * uvScale);
normals.push(0, 0, 1); normals.push(0, 0, normalZ);
}
for (const t of triangles) {
indices.push(topBase + t);
} }
// Bottom face: same outline, flipped so triangles wind CW when viewed from // The bottom face reverses winding so triangles wind CW when viewed from
// below (normal toward -Z). UVs use the same planar xy mapping as the top // below (normal toward -Z). `triangles` is a flat list of vertex indices in
// face (no mirror), so the texture is consistent across front, back, and // groups of 3, so step by 3.
// walls regardless of z. if (reverse) {
const bottomBase = shape.outline.length; for (let i = 0; i < triangles.length; i += 3) {
for (let i = 0; i < shape.outline.length; i++) { indices.push(triangles[i + 2]!, triangles[i + 1]!, triangles[i]!);
const [x, y] = point(shape.outline, i); }
positions.push(x, y, 0); } else {
uvs.push(((x - minX) / spanX) * uvScale, ((y - minY) / spanY) * uvScale); for (const t of triangles) {
normals.push(0, 0, -1); indices.push(t);
} }
// 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);
} }
return { positions, uvs, normals, indices }; 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. */ /** Compute the bounding box of a shape's outline. */
function shapeBounds(shape: Shape): { function shapeBounds(shape: Shape): {
minX: number; minX: number;
+2
View File
@@ -65,4 +65,6 @@ export const traceRequestSchema = z.object({
format: traceFormatSchema.default('shape'), format: traceFormatSchema.default('shape'),
simplify: z.coerce.number().min(0).optional(), simplify: z.coerce.number().min(0).optional(),
maxColors: z.coerce.number().int().min(1).optional(), maxColors: z.coerce.number().int().min(1).optional(),
/** Inset (negative) or outset (positive) the traced shape, in pixels. */
offset: z.coerce.number().optional(),
}); });
+2
View File
@@ -138,4 +138,6 @@ export interface TraceResult {
svg: string; svg: string;
/** Parsed shape, present when `format` is `shape`. */ /** Parsed shape, present when `format` is `shape`. */
shape?: TracedShape; shape?: TracedShape;
/** Inset (negative) or outset (positive) applied to the shape, in pixels. */
offset?: number;
} }
+25
View File
@@ -32,6 +32,9 @@ importers:
bson: bson:
specifier: ^6.10.4 specifier: ^6.10.4
version: 6.10.4 version: 6.10.4
clipper-lib:
specifier: ^6.4.2
version: 6.4.2
hono: hono:
specifier: ^4.6.14 specifier: ^4.6.14
version: 4.13.1 version: 4.13.1
@@ -45,6 +48,9 @@ importers:
specifier: ^3.24.1 specifier: ^3.24.1
version: 3.25.76 version: 3.25.76
devDependencies: devDependencies:
'@types/clipper-lib':
specifier: ^6.4.0
version: 6.4.0
'@types/node': '@types/node':
specifier: ^22.10.2 specifier: ^22.10.2
version: 22.20.1 version: 22.20.1
@@ -84,6 +90,9 @@ importers:
'@tts/shared': '@tts/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
bson:
specifier: ^7.3.1
version: 7.3.1
react: react:
specifier: ^19.2.8 specifier: ^19.2.8
version: 19.2.8 version: 19.2.8
@@ -799,6 +808,9 @@ packages:
'@types/chai@5.2.3': '@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/clipper-lib@6.4.0':
resolution: {integrity: sha512-y8WVQWLCIJobtcxk8SJj1f7OGVbQEL7RmfGtrWDkAKFxq2U6F0/7/hLYvDWlUnBayvnRYkq+QVZaUzm13mu+Ng==}
'@types/deep-eql@4.0.2': '@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -907,6 +919,10 @@ packages:
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==} resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
engines: {node: '>=16.20.1'} engines: {node: '>=16.20.1'}
bson@7.3.1:
resolution: {integrity: sha512-h/C0qe6857pQhcSJHLfsR1uYGj98Ge3wKAD3Ed9KqH3wcVh+BM4Jq4xISD7vs9OPuT07n+q3QQVjslJ286j6ag==}
engines: {node: '>=20.19.0'}
buffer@6.0.3: buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
@@ -920,6 +936,9 @@ packages:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'} engines: {node: '>=18'}
clipper-lib@6.4.2:
resolution: {integrity: sha512-knglhjQX5ihNj/XCIs6zCHrTemdvHY3LPZP9XB2nq2/3igyYMFueFXtfp84baJvEE+f8pO1ZS4UVeEgmLnAprQ==}
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -1997,6 +2016,8 @@ snapshots:
'@types/deep-eql': 4.0.2 '@types/deep-eql': 4.0.2
assertion-error: 2.0.1 assertion-error: 2.0.1
'@types/clipper-lib@6.4.0': {}
'@types/deep-eql@4.0.2': {} '@types/deep-eql@4.0.2': {}
'@types/draco3d@1.4.10': {} '@types/draco3d@1.4.10': {}
@@ -2101,6 +2122,8 @@ snapshots:
bson@6.10.4: {} bson@6.10.4: {}
bson@7.3.1: {}
buffer@6.0.3: buffer@6.0.3:
dependencies: dependencies:
base64-js: 1.5.1 base64-js: 1.5.1
@@ -2112,6 +2135,8 @@ snapshots:
chai@6.2.2: {} chai@6.2.2: {}
clipper-lib@6.4.2: {}
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
cookie@1.1.1: {} cookie@1.1.1: {}