Compare commits
10
Commits
| 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 | `/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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
@@ -33,3 +33,98 @@ describe('parseSvgShape', () => {
|
|||||||
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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[][][] = [];
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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)), {
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
// 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
|
<meshStandardMaterial
|
||||||
color={texture ? '#ffffff' : '#52525b'}
|
color={texture ? '#ffffff' : '#52525b'}
|
||||||
map={texture ?? undefined}
|
map={texture ?? undefined}
|
||||||
roughness={0.8}
|
roughness={0.8}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 < 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) {
|
for (let i = 0; i < triangles.length; i += 3) {
|
||||||
const t0 = triangles[i]!;
|
indices.push(triangles[i + 2]!, triangles[i + 1]!, triangles[i]!);
|
||||||
const t1 = triangles[i + 1]!;
|
}
|
||||||
const t2 = triangles[i + 2]!;
|
} else {
|
||||||
indices.push(bottomBase + t2, bottomBase + t1, bottomBase + t0);
|
for (const t of triangles) {
|
||||||
|
indices.push(t);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
Generated
+25
@@ -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: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user