Compare commits
10
Commits
21fc1b29c1
...
12418898d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12418898d0 | ||
|
|
ead2ba8c57 | ||
|
|
b6bd4612e0 | ||
|
|
55e0351c1d | ||
|
|
835250abdd | ||
|
|
f7139495fe | ||
|
|
a2f7c998fc | ||
|
|
9094ad58e0 | ||
|
|
c7f4bd03fd | ||
|
|
a4759be5ac |
@@ -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 | `/items/:id` | Full parsed `TTSMod` (BSON save) |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -18,12 +18,14 @@
|
||||
"@tts/tts": "workspace:*",
|
||||
"@visioncortex/vtracer": "1.0.0-alpha.3",
|
||||
"bson": "^6.10.4",
|
||||
"clipper-lib": "^6.4.2",
|
||||
"hono": "^4.6.14",
|
||||
"sharp": "^0.35.3",
|
||||
"svgpath": "^2.6.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/clipper-lib": "^6.4.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseSvgShape } from './svgShape.js';
|
||||
import { offsetShape, parseSvgShape } from './svgShape.js';
|
||||
|
||||
describe('parseSvgShape', () => {
|
||||
it('parses a simple closed outline', () => {
|
||||
@@ -32,4 +32,99 @@ describe('parseSvgShape', () => {
|
||||
const shape = parseSvgShape('<svg></svg>');
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
import { createRequire } from 'module';
|
||||
import svgpath from 'svgpath';
|
||||
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. */
|
||||
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. */
|
||||
function parseRings(d: string): number[][][] {
|
||||
const rings: number[][][] = [];
|
||||
|
||||
@@ -100,4 +100,48 @@ describe('trace route', () => {
|
||||
expect(result.mode).toBe('bw');
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { serialize } from 'bson';
|
||||
import sharp from 'sharp';
|
||||
import { createRequire } from 'module';
|
||||
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
|
||||
// with the correct `__dirname`.
|
||||
@@ -36,7 +36,8 @@ app.get('/', async (c) => {
|
||||
if (!parsed.success) {
|
||||
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.
|
||||
const parsedUrl = new URL(url);
|
||||
@@ -95,7 +96,12 @@ app.get('/', async (c) => {
|
||||
svg,
|
||||
};
|
||||
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)), {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@tts/extract": "workspace:*",
|
||||
"@tts/mesh": "workspace:*",
|
||||
"@tts/shared": "workspace:*",
|
||||
"bson": "^7.3.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
|
||||
+23
-1
@@ -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 = '';
|
||||
|
||||
@@ -17,6 +18,27 @@ export function searchWorkshop(q: string, page = 1): Promise<SearchResult> {
|
||||
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. */
|
||||
export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -5,11 +5,11 @@ import { iconsForObject } from './objectIcons';
|
||||
|
||||
interface Props {
|
||||
nodes: ObjectTreeNode[];
|
||||
selectedGuid: string | null;
|
||||
onSelect: (guid: string) => void;
|
||||
selectedPath: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
}
|
||||
|
||||
export default function ObjectTree({ nodes, selectedGuid, onSelect }: Props) {
|
||||
export default function ObjectTree({ nodes, selectedPath, onSelect }: Props) {
|
||||
return (
|
||||
<ul className="space-y-0.5">
|
||||
{nodes.map((node, index) => (
|
||||
@@ -18,7 +18,7 @@ export default function ObjectTree({ nodes, selectedGuid, onSelect }: Props) {
|
||||
node={node}
|
||||
depth={0}
|
||||
path={`${index}`}
|
||||
selectedGuid={selectedGuid}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
@@ -30,17 +30,20 @@ function TreeNode({
|
||||
node,
|
||||
depth,
|
||||
path,
|
||||
selectedGuid,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
}: {
|
||||
node: ObjectTreeNode;
|
||||
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;
|
||||
selectedGuid: string | null;
|
||||
onSelect: (guid: string) => void;
|
||||
selectedPath: string | null;
|
||||
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 [expanded, setExpanded] = useState(false);
|
||||
return (
|
||||
@@ -69,7 +72,7 @@ function TreeNode({
|
||||
<span className="h-6 w-6 shrink-0" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => onSelect(node.object.GUID)}
|
||||
onClick={() => onSelect(path)}
|
||||
className="block min-w-0 flex-1 truncate py-1 text-left text-sm"
|
||||
>
|
||||
<span
|
||||
@@ -91,7 +94,7 @@ function TreeNode({
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
path={`${path}-${index}`}
|
||||
selectedGuid={selectedGuid}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Suspense, type ReactNode } from 'react';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -8,6 +8,10 @@ import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing';
|
||||
* lighting, orbit controls, a soft contact shadow, and subtle post-processing
|
||||
* (bloom + vignette). Children are wrapped in a Suspense boundary so loading
|
||||
* 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 }) {
|
||||
return (
|
||||
@@ -22,7 +26,9 @@ export default function Scene({ children }: { children: ReactNode }) {
|
||||
<directionalLight position={[-4, 2, -3]} intensity={0.4} color="#b3c7ff" />
|
||||
<pointLight position={[0, 3, 0]} intensity={0.3} />
|
||||
|
||||
<Suspense fallback={null}>{children}</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<Bounds fit observe clip>{children}</Bounds>
|
||||
</Suspense>
|
||||
|
||||
<ContactShadows
|
||||
position={[0, -0.5, 0]}
|
||||
@@ -34,10 +40,10 @@ export default function Scene({ children }: { children: ReactNode }) {
|
||||
/>
|
||||
<OrbitControls
|
||||
enablePan={false}
|
||||
minDistance={1}
|
||||
minDistance={0.01}
|
||||
maxDistance={8}
|
||||
autoRotate
|
||||
autoRotateSpeed={1.2}
|
||||
makeDefault
|
||||
/>
|
||||
|
||||
<EffectComposer>
|
||||
@@ -47,4 +53,4 @@ export default function Scene({ children }: { children: ReactNode }) {
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { useTexture } from '@react-three/drei';
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
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 { 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
|
||||
* `CustomImage.ImageURL`, with a neutral color when absent.
|
||||
* A token: a short extruded shape with the texture on its top face. Uses
|
||||
* `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 }) {
|
||||
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.
|
||||
function TokenMesh({ url, thickness }: { url?: string; thickness: number }) {
|
||||
const texture = url ? useTexture(assetUrl(url)) : null;
|
||||
return (
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.9, 0.9, thickness, 48]} />
|
||||
<meshStandardMaterial
|
||||
color={texture ? '#ffffff' : '#52525b'}
|
||||
map={texture ?? undefined}
|
||||
roughness={0.8}
|
||||
/>
|
||||
</mesh>
|
||||
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
||||
|
||||
// Trace the image's alpha channel into a shape. Suspends until the trace
|
||||
// resolves so the surrounding Suspense boundary (and `Bounds`) only mounts
|
||||
// once the token geometry is present. Falls back to a circle when there's no
|
||||
// image or the trace fails.
|
||||
const trace = useTrace(url);
|
||||
|
||||
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;
|
||||
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_Token', TokenViewer);
|
||||
registerViewer('Card', CardViewer);
|
||||
registerViewer('CardCustom', CardViewer);
|
||||
registerViewer('Deck', CardViewer);
|
||||
registerViewer('DeckCustom', CardViewer);
|
||||
registerViewer('Custom_Deck', CardViewer);
|
||||
|
||||
@@ -16,7 +16,10 @@ export default function ModPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { mod, loading, error, load } = useModStore();
|
||||
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(() => {
|
||||
if (id) load(id, item?.fileUrl);
|
||||
@@ -26,8 +29,8 @@ export default function ModPage() {
|
||||
const refs = useMemo(() => (mod ? collectRefs(mod) : []), [mod]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => (mod ? findInTree(tree, selectedGuid) : undefined),
|
||||
[tree, selectedGuid],
|
||||
() => (mod ? findInTree(tree, selectedPath) : undefined),
|
||||
[tree, selectedPath],
|
||||
);
|
||||
|
||||
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">
|
||||
<ObjectTree
|
||||
nodes={tree}
|
||||
selectedGuid={selectedGuid}
|
||||
onSelect={setSelectedGuid}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={setSelectedPath}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
@@ -87,7 +90,9 @@ export default function ModPage() {
|
||||
</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>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
@@ -105,13 +110,11 @@ export default function ModPage() {
|
||||
|
||||
function findInTree(
|
||||
nodes: ReturnType<typeof buildTree>,
|
||||
guid: string | null,
|
||||
path: string | null,
|
||||
): ReturnType<typeof buildTree>[number] | undefined {
|
||||
if (!guid) return undefined;
|
||||
for (const node of nodes) {
|
||||
if (node.object.GUID === guid) return node;
|
||||
const found = findInTree(node.children, guid);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
if (!path) return undefined;
|
||||
const [head, ...rest] = path.split('-');
|
||||
const node = nodes[Number(head)];
|
||||
if (!node) return undefined;
|
||||
return rest.length === 0 ? node : findInTree(node.children, rest.join('-'));
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
||||
'/items': 'http://localhost:3000',
|
||||
'/health': 'http://localhost:3000',
|
||||
'/asset': 'http://localhost:3000',
|
||||
'/trace': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -31,6 +31,7 @@ shared types/validation package.
|
||||
| `apps/proxy` | Hono HTTP server: search + fetch endpoints | Node |
|
||||
| `packages/tts` | Fetch save from Steam, BSON-parse to `TTSMod` | Node |
|
||||
| `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 |
|
||||
|
||||
## Dependency graph
|
||||
@@ -40,6 +41,7 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared
|
||||
│ │ │
|
||||
│ │ └──► (fetchMod → TTSMod)
|
||||
│ ▼
|
||||
├──► packages/mesh ──► 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.
|
||||
- **`apps/web` → `packages/extract`** — uses `buildTree` / `collectRefs`
|
||||
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
|
||||
item requests.
|
||||
- **`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` |
|
||||
| `bson` | BSON deserialization of TTS save files | `packages/tts` |
|
||||
| `@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` |
|
||||
| `svgpath` | SVG path parsing for traced shapes | `apps/proxy` |
|
||||
| `cheerio` | Workshop browse page scraping | `apps/proxy` |
|
||||
| `zod` | Runtime validation | `apps/proxy`, `packages/shared` |
|
||||
| `three` | 3D rendering | `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` |
|
||||
|
||||
### Runtime constraints
|
||||
@@ -97,9 +102,9 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared
|
||||
must stay isomorphic.
|
||||
- **`bson` is Node-only** — used by the fetcher and the trace route, not the
|
||||
analysis layer.
|
||||
- **`@visioncortex/vtracer`, `sharp`, `svgpath` are backend-only** — the trace
|
||||
route lives in `apps/proxy`; they never appear in `packages/extract`, which
|
||||
must stay isomorphic.
|
||||
- **`@visioncortex/vtracer`, `sharp`, `svgpath`, `clipper-lib` are
|
||||
backend-only** — the trace route lives in `apps/proxy`; they never appear in
|
||||
`packages/extract`, which must stay isomorphic.
|
||||
- **`packages/extract` has zero external runtime deps** — it relies only on
|
||||
platform `fetch` / `Blob`, keeping it portable to a future frontend.
|
||||
|
||||
|
||||
+85
-4
@@ -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,
|
||||
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
|
||||
derived, and `format` (`shape` default, `svg`) selects the response. The
|
||||
`shape` format returns a parsed `{ outline, holes }` polygon matching
|
||||
`@tts/mesh`'s `Shape` interface.
|
||||
derived, `format` (`shape` default, `svg`) selects the response, and `offset`
|
||||
(optional, in pixels) insets (negative) or outsets (positive) the resulting
|
||||
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
|
||||
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
|
||||
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
|
||||
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
@@ -30,6 +30,7 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared
|
||||
│ │ │
|
||||
│ │ └──► (fetchMod → TTSMod)
|
||||
│ ▼
|
||||
├──► packages/mesh ──► packages/shared (types)
|
||||
└──► packages/extract ──► packages/shared (types)
|
||||
(isomorphic, used by the frontend)
|
||||
```
|
||||
@@ -40,6 +41,8 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared
|
||||
derivation. Returns raw parsed `TTSMod`.
|
||||
- **`packages/extract`** — analysis: flatten/filter objects, extract asset refs,
|
||||
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.
|
||||
|
||||
## Repository layout
|
||||
@@ -107,6 +110,14 @@ tts-workshop/
|
||||
│ └── src/
|
||||
│ ├── index.ts # fetchMod, getFileName
|
||||
│ └── 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/
|
||||
├── package.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).
|
||||
- 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`
|
||||
|
||||
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
|
||||
the proxy makes those assets loadable. Only `http(s)` URLs are allowed.
|
||||
- `routes/trace.ts`
|
||||
- `GET /trace?url=...&mode=alpha&threshold=128&format=shape` — fetch an
|
||||
image, trace it into a vector shape, and return the result BSON-encoded.
|
||||
`mode` is `alpha` (default), `bw`, or `color`; `format` is `shape`
|
||||
(default) or `svg`. `shape` returns a parsed `{ outline, holes }` polygon
|
||||
matching `@tts/mesh`'s `Shape` interface, ready to extrude.
|
||||
- `GET /trace?url=...&mode=alpha&threshold=128&format=shape&offset=...` —
|
||||
fetch an image, trace it into a vector shape, and return the result
|
||||
BSON-encoded. `mode` is `alpha` (default), `bw`, or `color`; `format` is
|
||||
`shape` (default) or `svg`; `offset` (optional) insets (negative) or
|
||||
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`
|
||||
- `parseSvgShape(svg)` — parse a vtracer SVG into a `TracedShape`
|
||||
(`{ outline, holes }`): flatten beziers to polylines, split subpaths into
|
||||
rings, classify by winding (CCW outline / CW hole), and assign holes to
|
||||
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`
|
||||
- `GET /health` — liveness.
|
||||
- `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/ObjectTree.tsx` — recursive tree sidebar; each entry shows a
|
||||
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` /
|
||||
`resolveViewer`) plus a `DefaultViewer` that renders an object's fields;
|
||||
custom per-class viewers can be registered later.
|
||||
- `components/viewers/` — 3D viewers built on `@react-three/fiber`,
|
||||
`@react-three/drei`, and `@react-three/postprocessing`. `register.ts`
|
||||
registers lazy-loaded viewers for `Tile`/`Custom_Tile` (flat box),
|
||||
`Custom_Token` (cylinder), `Card`/`Deck`/`Custom_Deck` (thin box with
|
||||
face/back textures), and `Custom_Model`/`Custom_Model_Bag`/
|
||||
`Custom_Model_Infinite_Bag` (GLTF/OBJ/FBX from `CustomMesh.MeshURL`).
|
||||
`Scene.tsx` is a shared canvas with lighting, orbit controls, contact
|
||||
shadows, and subtle bloom/vignette. `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.
|
||||
`Custom_Token` (shape traced from the image's alpha channel via `/trace`,
|
||||
extruded with `@tts/mesh`), `Card`/`CardCustom`/`Deck`/`DeckCustom`/
|
||||
`Custom_Deck` (thin rounded rect with face/back textures), and
|
||||
`Custom_Model`/`Custom_Model_Bag`/`Custom_Model_Infinite_Bag` (GLTF/OBJ/FBX
|
||||
from `CustomMesh.MeshURL`). Viewers build `{ front, back, walls }` geometry
|
||||
via `extrudeShapeParts`; back faces are flipped left/right on the material
|
||||
(`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
|
||||
Iconify icons (`iconsForObject`); unknown classes fall back to a help icon.
|
||||
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/file` | Raw save bytes, filename from header | key* |
|
||||
| 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`
|
||||
query param is supplied.
|
||||
|
||||
@@ -62,27 +62,42 @@ describe('extrudeShape', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
describe('extrudeShapeParts', () => {
|
||||
it('returns caps and walls as separate geometries', () => {
|
||||
const { caps, walls } = extrudeShapeParts(rectShape(2, 2), { height: 1 });
|
||||
// Caps: 2 faces * 4 outline points = 8 vertices.
|
||||
expect(caps.positions.length / 3).toBe(8);
|
||||
it('returns front, back, and walls as separate geometries', () => {
|
||||
const { front, back, walls } = extrudeShapeParts(rectShape(2, 2), { height: 1 });
|
||||
// Each face: 4 outline points.
|
||||
expect(front.positions.length / 3).toBe(4);
|
||||
expect(back.positions.length / 3).toBe(4);
|
||||
// Walls: 4 outline points * 2 vertices = 8 vertices.
|
||||
expect(walls.positions.length / 3).toBe(8);
|
||||
// Combined, they match `extrudeShape`.
|
||||
const combined = extrudeShape(rectShape(2, 2), { height: 1 });
|
||||
expect(caps.positions.length + walls.positions.length).toBe(combined.positions.length);
|
||||
expect(caps.indices.length + walls.indices.length).toBe(combined.indices.length);
|
||||
expect(front.positions.length + back.positions.length + walls.positions.length).toBe(
|
||||
combined.positions.length,
|
||||
);
|
||||
expect(front.indices.length + back.indices.length + walls.indices.length).toBe(
|
||||
combined.indices.length,
|
||||
);
|
||||
});
|
||||
|
||||
it('applies uvBounds to caps and walls', () => {
|
||||
it('front faces +Z and back faces -Z', () => {
|
||||
const { front, back } = extrudeShapeParts(rectShape(2, 2), { height: 1 });
|
||||
expect(Array.from(front.normals.slice(0, 3))).toEqual([0, 0, 1]);
|
||||
expect(Array.from(back.normals.slice(0, 3))).toEqual([0, 0, -1]);
|
||||
});
|
||||
|
||||
it('applies uvBounds to front, back, and walls', () => {
|
||||
const uvBounds = { minX: 0, minY: 0, maxX: 4, maxY: 4 };
|
||||
const { caps, walls } = extrudeShapeParts(rectShape(1, 1), {
|
||||
const { front, back, walls } = extrudeShapeParts(rectShape(1, 1), {
|
||||
height: 1,
|
||||
uvBounds,
|
||||
});
|
||||
// Caps: bottom-left vertex at (-0.5,-0.5) -> u=-0.125.
|
||||
expect(caps.uvs[0]).toBeCloseTo(-0.125, 5);
|
||||
// Front: bottom-left vertex at (-0.5,-0.5) -> u=-0.125.
|
||||
expect(front.uvs[0]).toBeCloseTo(-0.125, 5);
|
||||
// Back uses the same planar xy mapping (no mirror).
|
||||
expect(back.uvs[0]).toBeCloseTo(-0.125, 5);
|
||||
// Walls: first outline point (-0.5,-0.5) -> u=-0.125, v=-0.125.
|
||||
expect(walls.uvs[0]).toBeCloseTo(-0.125, 5);
|
||||
expect(walls.uvs[1]).toBeCloseTo(-0.125, 5);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ExtrudedGeometry, FaceGeometry, UVBounds } from './types.js';
|
||||
import type { Shape } from './shapes.js';
|
||||
import { capFaces } from './tessellate.js';
|
||||
import { backFaces, capFaces, frontFaces } from './tessellate.js';
|
||||
import { wallFaces } from './walls.js';
|
||||
|
||||
export interface ExtrudeOptions {
|
||||
@@ -37,21 +37,22 @@ export function extrudeShape(shape: Shape, options: ExtrudeOptions = {}): Extrud
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrude a shape, returning the caps (top + bottom faces) and walls as
|
||||
* separate geometries. This lets callers apply different materials to the
|
||||
* textured faces versus the sides (e.g. white, tintable walls on a tile).
|
||||
* Extrude a shape, returning the front face, back face, and walls as separate
|
||||
* geometries. This lets callers apply different materials to the textured
|
||||
* faces versus the sides (e.g. white, tintable walls on a tile).
|
||||
*/
|
||||
export function extrudeShapeParts(
|
||||
shape: Shape,
|
||||
options: ExtrudeOptions = {},
|
||||
): { caps: ExtrudedGeometry; walls: ExtrudedGeometry } {
|
||||
): { front: ExtrudedGeometry; back: ExtrudedGeometry; walls: ExtrudedGeometry } {
|
||||
const height = options.height ?? 1;
|
||||
const capUvScale = options.capUvScale ?? 1;
|
||||
const wallUvScale = options.wallUvScale ?? 1;
|
||||
const uvBounds = options.uvBounds;
|
||||
|
||||
return {
|
||||
caps: faceToGeometry(capFaces(shape, height, capUvScale, uvBounds)),
|
||||
front: faceToGeometry(frontFaces(shape, height, capUvScale, uvBounds)),
|
||||
back: faceToGeometry(backFaces(shape, height, capUvScale, uvBounds)),
|
||||
walls: faceToGeometry(wallFaces(shape, height, wallUvScale, uvBounds)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function triangulate(shape: Shape): number[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the top and bottom faces of an extruded shape.
|
||||
* Build a single cap (top or bottom face) of an extruded shape.
|
||||
*
|
||||
* The top face lies in the XY plane at `z = height` with its normal toward
|
||||
* +Z; the bottom face lies at `z = 0` with its normal toward -Z. UVs map the
|
||||
@@ -43,11 +43,14 @@ export function triangulate(shape: Shape): number[] {
|
||||
* the shape's bounding box, so the texture aligns to a larger framing (e.g. a
|
||||
* traced silhouette inside a transparent image canvas).
|
||||
*/
|
||||
export function capFaces(
|
||||
function buildCap(
|
||||
shape: Shape,
|
||||
height: number,
|
||||
uvScale = 1,
|
||||
uvBounds?: UVBounds,
|
||||
uvScale: number,
|
||||
uvBounds: UVBounds | undefined,
|
||||
z: number,
|
||||
normalZ: number,
|
||||
reverse: boolean,
|
||||
): FaceGeometry {
|
||||
const triangles = triangulate(shape);
|
||||
|
||||
@@ -67,41 +70,70 @@ export function capFaces(
|
||||
const normals: number[] = [];
|
||||
const indices: number[] = [];
|
||||
|
||||
// Top face.
|
||||
const topBase = 0;
|
||||
for (let i = 0; i < shape.outline.length; i++) {
|
||||
const [x, y] = point(shape.outline, i);
|
||||
positions.push(x, y, height);
|
||||
positions.push(x, y, z);
|
||||
uvs.push(((x - minX) / spanX) * uvScale, ((y - minY) / spanY) * uvScale);
|
||||
normals.push(0, 0, 1);
|
||||
}
|
||||
for (const t of triangles) {
|
||||
indices.push(topBase + t);
|
||||
normals.push(0, 0, normalZ);
|
||||
}
|
||||
|
||||
// Bottom face: same outline, flipped so triangles wind CW when viewed from
|
||||
// below (normal toward -Z). UVs use the same planar xy mapping as the top
|
||||
// face (no mirror), so the texture is consistent across front, back, and
|
||||
// walls regardless of z.
|
||||
const bottomBase = shape.outline.length;
|
||||
for (let i = 0; i < shape.outline.length; i++) {
|
||||
const [x, y] = point(shape.outline, i);
|
||||
positions.push(x, y, 0);
|
||||
uvs.push(((x - minX) / spanX) * uvScale, ((y - minY) / spanY) * uvScale);
|
||||
normals.push(0, 0, -1);
|
||||
}
|
||||
// Reverse winding order for the bottom face. `triangles` is a flat list of
|
||||
// vertex indices in groups of 3, so step by 3.
|
||||
for (let i = 0; i < triangles.length; i += 3) {
|
||||
const t0 = triangles[i]!;
|
||||
const t1 = triangles[i + 1]!;
|
||||
const t2 = triangles[i + 2]!;
|
||||
indices.push(bottomBase + t2, bottomBase + t1, bottomBase + t0);
|
||||
// The bottom face reverses winding so triangles wind CW when viewed from
|
||||
// below (normal toward -Z). `triangles` is a flat list of vertex indices in
|
||||
// groups of 3, so step by 3.
|
||||
if (reverse) {
|
||||
for (let i = 0; i < triangles.length; i += 3) {
|
||||
indices.push(triangles[i + 2]!, triangles[i + 1]!, triangles[i]!);
|
||||
}
|
||||
} else {
|
||||
for (const t of triangles) {
|
||||
indices.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
return { positions, uvs, normals, indices };
|
||||
}
|
||||
|
||||
/** The top face of an extruded shape, normal toward +Z. */
|
||||
export function frontFaces(
|
||||
shape: Shape,
|
||||
height: number,
|
||||
uvScale = 1,
|
||||
uvBounds?: UVBounds,
|
||||
): FaceGeometry {
|
||||
return buildCap(shape, height, uvScale, uvBounds, height, 1, false);
|
||||
}
|
||||
|
||||
/** The bottom face of an extruded shape, normal toward -Z. */
|
||||
export function backFaces(
|
||||
shape: Shape,
|
||||
height: number,
|
||||
uvScale = 1,
|
||||
uvBounds?: UVBounds,
|
||||
): FaceGeometry {
|
||||
return buildCap(shape, height, uvScale, uvBounds, 0, -1, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the top and bottom faces of an extruded shape, merged into one
|
||||
* `FaceGeometry` (front vertices first, then back).
|
||||
*/
|
||||
export function capFaces(
|
||||
shape: Shape,
|
||||
height: number,
|
||||
uvScale = 1,
|
||||
uvBounds?: UVBounds,
|
||||
): FaceGeometry {
|
||||
const front = frontFaces(shape, height, uvScale, uvBounds);
|
||||
const back = backFaces(shape, height, uvScale, uvBounds);
|
||||
const frontCount = front.positions.length / 3;
|
||||
return {
|
||||
positions: [...front.positions, ...back.positions],
|
||||
uvs: [...front.uvs, ...back.uvs],
|
||||
normals: [...front.normals, ...back.normals],
|
||||
indices: [...front.indices, ...back.indices.map((i) => i + frontCount)],
|
||||
};
|
||||
}
|
||||
|
||||
/** Compute the bounding box of a shape's outline. */
|
||||
function shapeBounds(shape: Shape): {
|
||||
minX: number;
|
||||
|
||||
@@ -65,4 +65,6 @@ export const traceRequestSchema = z.object({
|
||||
format: traceFormatSchema.default('shape'),
|
||||
simplify: z.coerce.number().min(0).optional(),
|
||||
maxColors: z.coerce.number().int().min(1).optional(),
|
||||
/** Inset (negative) or outset (positive) the traced shape, in pixels. */
|
||||
offset: z.coerce.number().optional(),
|
||||
});
|
||||
@@ -138,4 +138,6 @@ export interface TraceResult {
|
||||
svg: string;
|
||||
/** Parsed shape, present when `format` is `shape`. */
|
||||
shape?: TracedShape;
|
||||
/** Inset (negative) or outset (positive) applied to the shape, in pixels. */
|
||||
offset?: number;
|
||||
}
|
||||
Generated
+25
@@ -32,6 +32,9 @@ importers:
|
||||
bson:
|
||||
specifier: ^6.10.4
|
||||
version: 6.10.4
|
||||
clipper-lib:
|
||||
specifier: ^6.4.2
|
||||
version: 6.4.2
|
||||
hono:
|
||||
specifier: ^4.6.14
|
||||
version: 4.13.1
|
||||
@@ -45,6 +48,9 @@ importers:
|
||||
specifier: ^3.24.1
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/clipper-lib':
|
||||
specifier: ^6.4.0
|
||||
version: 6.4.0
|
||||
'@types/node':
|
||||
specifier: ^22.10.2
|
||||
version: 22.20.1
|
||||
@@ -84,6 +90,9 @@ importers:
|
||||
'@tts/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
bson:
|
||||
specifier: ^7.3.1
|
||||
version: 7.3.1
|
||||
react:
|
||||
specifier: ^19.2.8
|
||||
version: 19.2.8
|
||||
@@ -799,6 +808,9 @@ packages:
|
||||
'@types/chai@5.2.3':
|
||||
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':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
@@ -907,6 +919,10 @@ packages:
|
||||
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
|
||||
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:
|
||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||
|
||||
@@ -920,6 +936,9 @@ packages:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
clipper-lib@6.4.2:
|
||||
resolution: {integrity: sha512-knglhjQX5ihNj/XCIs6zCHrTemdvHY3LPZP9XB2nq2/3igyYMFueFXtfp84baJvEE+f8pO1ZS4UVeEgmLnAprQ==}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
@@ -1997,6 +2016,8 @@ snapshots:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/clipper-lib@6.4.0': {}
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/draco3d@1.4.10': {}
|
||||
@@ -2101,6 +2122,8 @@ snapshots:
|
||||
|
||||
bson@6.10.4: {}
|
||||
|
||||
bson@7.3.1: {}
|
||||
|
||||
buffer@6.0.3:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
@@ -2112,6 +2135,8 @@ snapshots:
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
clipper-lib@6.4.2: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
Reference in New Issue
Block a user