Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef0695ed04 | ||
|
|
15c45e7106 | ||
|
|
b4cdcdeb42 | ||
|
|
87e6eee9fe | ||
|
|
521519ce3d | ||
|
|
eefce5487d | ||
|
|
4d14120d54 | ||
|
|
91d4b6c999 | ||
|
|
0503ac84a7 |
@@ -0,0 +1,7 @@
|
|||||||
|
# Game assets are large binaries; store them in Git LFS.
|
||||||
|
games/**/*.png filter=lfs diff=lfs merge=lfs -text
|
||||||
|
games/**/*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||||
|
games/**/*.jpeg filter=lfs diff=lfs merge=lfs -text
|
||||||
|
games/**/*.webp filter=lfs diff=lfs merge=lfs -text
|
||||||
|
games/**/*.glb filter=lfs diff=lfs merge=lfs -text
|
||||||
|
games/**/*.gltf filter=lfs diff=lfs merge=lfs -text
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Runtime config for the proxy. `GAMES_ROOT` is set at startup (from env or a
|
||||||
|
* default) and read by the asset/trace routes to serve local game assets. It's
|
||||||
|
* a module-level value because the node-server adapter injects its own
|
||||||
|
* `HttpBindings` as the Hono env, not custom bindings.
|
||||||
|
*/
|
||||||
|
export let GAMES_ROOT: string | undefined;
|
||||||
|
|
||||||
|
/** Set the games root at startup. */
|
||||||
|
export function setGamesRoot(root: string | undefined): void {
|
||||||
|
GAMES_ROOT = root;
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@ import { loadEnv } from './env.js';
|
|||||||
|
|
||||||
describe('loadEnv', () => {
|
describe('loadEnv', () => {
|
||||||
it('parses a valid environment', () => {
|
it('parses a valid environment', () => {
|
||||||
expect(loadEnv({ STEAM_API_KEY: 'key', PORT: '4000' })).toEqual({
|
expect(loadEnv({ STEAM_API_KEY: 'key', PORT: '4000', GAMES_ROOT: '/games' })).toEqual({
|
||||||
STEAM_API_KEY: 'key',
|
STEAM_API_KEY: 'key',
|
||||||
PORT: 4000,
|
PORT: 4000,
|
||||||
|
GAMES_ROOT: '/games',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { z } from 'zod';
|
|||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
STEAM_API_KEY: z.string().min(1).optional(),
|
STEAM_API_KEY: z.string().min(1).optional(),
|
||||||
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
||||||
|
/** Absolute path to the games root, for serving local game assets. */
|
||||||
|
GAMES_ROOT: z.string().min(1).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
@@ -11,6 +13,7 @@ export type Env = z.infer<typeof envSchema>;
|
|||||||
export interface Bindings {
|
export interface Bindings {
|
||||||
STEAM_API_KEY?: string;
|
STEAM_API_KEY?: string;
|
||||||
PORT: number;
|
PORT: number;
|
||||||
|
GAMES_ROOT?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
|
export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { serve } from '@hono/node-server';
|
import { serve } from '@hono/node-server';
|
||||||
import { cors } from 'hono/cors';
|
import { cors } from 'hono/cors';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import * as path from 'node:path';
|
||||||
import { loadEnv, type Bindings } from './env.js';
|
import { loadEnv, type Bindings } from './env.js';
|
||||||
|
import { setGamesRoot } from './config.js';
|
||||||
import asset from './routes/asset.js';
|
import asset from './routes/asset.js';
|
||||||
import health from './routes/health.js';
|
import health from './routes/health.js';
|
||||||
import items from './routes/items.js';
|
import items from './routes/items.js';
|
||||||
@@ -10,6 +13,13 @@ import trace from './routes/trace.js';
|
|||||||
|
|
||||||
const env = loadEnv();
|
const env = loadEnv();
|
||||||
|
|
||||||
|
// Default GAMES_ROOT to the repo's `games` folder so local game assets work
|
||||||
|
// without configuration; override via env.
|
||||||
|
setGamesRoot(
|
||||||
|
env.GAMES_ROOT ??
|
||||||
|
path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'games'),
|
||||||
|
);
|
||||||
|
|
||||||
const app = new Hono<{ Bindings: Bindings }>();
|
const app = new Hono<{ Bindings: Bindings }>();
|
||||||
|
|
||||||
app.use('*', cors());
|
app.use('*', cors());
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
import asset from './asset.js';
|
import asset from './asset.js';
|
||||||
|
import { setGamesRoot } from '../config.js';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(path.join(tmpdir(), 'games-'));
|
||||||
|
setGamesRoot(dir);
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
|
setGamesRoot(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('asset route', () => {
|
describe('asset route', () => {
|
||||||
@@ -40,4 +52,22 @@ describe('asset route', () => {
|
|||||||
const res = await asset.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
|
const res = await asset.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
|
||||||
expect(res.status).toBe(502);
|
expect(res.status).toBe(502);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serves a local game asset from GAMES_ROOT', async () => {
|
||||||
|
writeFileSync(path.join(dir, 'cards.png'), new Uint8Array([1, 2, 3]));
|
||||||
|
const res = await asset.request('/?url=cards.png');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toBe('image/png');
|
||||||
|
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for a missing local asset', async () => {
|
||||||
|
const res = await asset.request('/?url=nope.png');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects path traversal outside GAMES_ROOT', async () => {
|
||||||
|
const res = await asset.request('/?url=..%2F..%2Fetc%2Fpasswd');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { GAMES_ROOT } from '../config.js';
|
||||||
|
import { resolveAsset } from './resolveAsset.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -7,6 +9,10 @@ const app = new Hono();
|
|||||||
* client. Workshop asset hosts (steamusercontent.com, etc.) often omit CORS
|
* client. Workshop asset hosts (steamusercontent.com, etc.) often omit CORS
|
||||||
* headers, which would block `TextureLoader` / `GLTFLoader` in the browser.
|
* headers, which would block `TextureLoader` / `GLTFLoader` in the browser.
|
||||||
* Routing through the proxy makes those assets loadable.
|
* Routing through the proxy makes those assets loadable.
|
||||||
|
*
|
||||||
|
* A relative URL (no scheme) is treated as a game asset path relative to the
|
||||||
|
* `GAMES_ROOT` directory and served from disk, so bgm parts can reference
|
||||||
|
* local files (e.g. `poker/parts/assets/cards.png`).
|
||||||
*/
|
*/
|
||||||
app.get('/', async (c) => {
|
app.get('/', async (c) => {
|
||||||
const raw = c.req.query('url');
|
const raw = c.req.query('url');
|
||||||
@@ -14,20 +20,28 @@ app.get('/', async (c) => {
|
|||||||
return c.json({ error: 'Missing url query param' }, 400);
|
return c.json({ error: 'Missing url query param' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
let url: URL;
|
const result = await resolveAsset(raw, GAMES_ROOT);
|
||||||
try {
|
if (!result.ok) {
|
||||||
url = new URL(raw);
|
// A missing local file vs an invalid reference.
|
||||||
} catch {
|
return c.json(
|
||||||
return c.json({ error: 'Invalid url query param' }, 400);
|
{ error: result.reason === 'not-found' ? 'Asset not found' : 'Invalid url query param' },
|
||||||
|
result.reason === 'not-found' ? 404 : 400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Only allow http(s) to avoid SSRF via file://, etc.
|
const asset = result.asset;
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
||||||
return c.json({ error: 'Only http(s) urls are allowed' }, 400);
|
if (asset.stream) {
|
||||||
|
return new Response(asset.stream as unknown as BodyInit, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': asset.contentType,
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(url);
|
res = await fetch(asset.url!);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return c.json({ error: `Failed to fetch asset: ${String(err)}` }, 502);
|
return c.json({ error: `Failed to fetch asset: ${String(err)}` }, 502);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { stat } from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
/** Content-type by extension for local game assets. */
|
||||||
|
const CONTENT_TYPES: Record<string, string> = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.gltf': 'model/gltf+json',
|
||||||
|
'.glb': 'model/gltf-binary',
|
||||||
|
'.obj': 'text/plain',
|
||||||
|
'.fbx': 'application/octet-stream',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ResolvedAsset {
|
||||||
|
/** The http(s) URL to fetch, when the asset is remote. */
|
||||||
|
url?: string;
|
||||||
|
/** A readable stream of a local file, when the asset is on disk. */
|
||||||
|
stream?: NodeJS.ReadableStream;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolveResult =
|
||||||
|
| { ok: true; asset: ResolvedAsset }
|
||||||
|
| { ok: false; reason: 'invalid' | 'not-found' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an asset reference to either a remote http(s) URL or a local file
|
||||||
|
* under `gamesRoot`. A reference with a scheme is remote; otherwise it's a
|
||||||
|
* game asset path relative to `gamesRoot`. Returns `{ ok: false }` with a
|
||||||
|
* reason when the reference is invalid (non-http scheme, path traversal) or
|
||||||
|
* the file is missing.
|
||||||
|
*/
|
||||||
|
export async function resolveAsset(
|
||||||
|
raw: string,
|
||||||
|
gamesRoot: string | undefined,
|
||||||
|
): Promise<ResolveResult> {
|
||||||
|
// A relative path (no scheme) is a local game asset.
|
||||||
|
if (!/^[a-z][a-z0-9+.-]*:/i.test(raw)) {
|
||||||
|
if (!gamesRoot) return { ok: false, reason: 'invalid' };
|
||||||
|
const rel = raw.replace(/^\/+/, '');
|
||||||
|
const abs = path.resolve(gamesRoot, rel);
|
||||||
|
if (!abs.startsWith(path.resolve(gamesRoot) + path.sep)) {
|
||||||
|
return { ok: false, reason: 'invalid' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await stat(abs);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: 'not-found' };
|
||||||
|
}
|
||||||
|
const ext = path.extname(abs).toLowerCase();
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
asset: {
|
||||||
|
stream: createReadStream(abs),
|
||||||
|
contentType: CONTENT_TYPES[ext] ?? 'application/octet-stream',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: 'invalid' };
|
||||||
|
}
|
||||||
|
// Only allow http(s) to avoid SSRF via file://, etc.
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
|
return { ok: false, reason: 'invalid' };
|
||||||
|
}
|
||||||
|
return { ok: true, asset: { url: raw, contentType: 'application/octet-stream' } };
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ 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 { offsetShape, parseSvgShape } from './svgShape.js';
|
import { offsetShape, parseSvgShape } from './svgShape.js';
|
||||||
|
import { resolveAsset } from './resolveAsset.js';
|
||||||
|
import { GAMES_ROOT } from '../config.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`.
|
||||||
@@ -39,19 +41,29 @@ app.get('/', async (c) => {
|
|||||||
const { url, mode, threshold, format, simplify, maxColors, offset } =
|
const { url, mode, threshold, format, simplify, maxColors, offset } =
|
||||||
parsed.data;
|
parsed.data;
|
||||||
|
|
||||||
// Only allow http(s) to avoid SSRF via file://, etc.
|
// Resolve the image: a relative path is a local game asset under GAMES_ROOT;
|
||||||
const parsedUrl = new URL(url);
|
// otherwise it must be an http(s) URL.
|
||||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
const resolved = await resolveAsset(url, GAMES_ROOT);
|
||||||
return c.json({ error: 'Only http(s) urls are allowed' }, 400);
|
if (!resolved.ok) {
|
||||||
|
return c.json({ error: 'Invalid or missing image url' }, 400);
|
||||||
}
|
}
|
||||||
|
const asset = resolved.asset;
|
||||||
|
|
||||||
let image: Buffer;
|
let image: Buffer;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
if (asset.stream) {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of asset.stream) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
image = Buffer.concat(chunks);
|
||||||
|
} else {
|
||||||
|
const res = await fetch(asset.url!);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return c.json({ error: `Image responded ${res.status}` }, 502);
|
return c.json({ error: `Image responded ${res.status}` }, 502);
|
||||||
}
|
}
|
||||||
image = Buffer.from(await res.arrayBuffer());
|
image = Buffer.from(await res.arrayBuffer());
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return c.json({ error: `Failed to fetch image: ${String(err)}` }, 502);
|
return c.json({ error: `Failed to fetch image: ${String(err)}` }, 502);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
"@tts/mesh": "workspace:*",
|
"@tts/mesh": "workspace:*",
|
||||||
"@tts/shared": "workspace:*",
|
"@tts/shared": "workspace:*",
|
||||||
"@tts/bgm": "workspace:*",
|
"@tts/bgm": "workspace:*",
|
||||||
|
"@tts/http": "workspace:*",
|
||||||
|
"@tts/tabletop": "workspace:*",
|
||||||
"bson": "^7.3.1",
|
"bson": "^7.3.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
|||||||
+5
-22
@@ -1,5 +1,7 @@
|
|||||||
import { deserialize } from 'bson';
|
import type { SearchResult, TTSMod } from '@tts/shared';
|
||||||
import type { SearchResult, TraceResult, TTSMod } from '@tts/shared';
|
import { traceImage } from '@tts/http';
|
||||||
|
|
||||||
|
export { traceImage };
|
||||||
|
|
||||||
const BASE = '';
|
const BASE = '';
|
||||||
|
|
||||||
@@ -19,27 +21,8 @@ export function searchWorkshop(q: string, page = 1): Promise<SearchResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trace an image into a vector shape, BSON-deserializing the proxy response.
|
* Fetch a full parsed TTS save.
|
||||||
* `mode` controls how the region is derived (`alpha`, `bw`, `color`); a
|
|
||||||
* non-zero `offset` insets (negative) or outsets (positive) the shape in
|
|
||||||
* pixels.
|
|
||||||
*/
|
*/
|
||||||
export async function traceImage(
|
|
||||||
url: string,
|
|
||||||
mode: 'alpha' | 'bw' | 'color' = 'alpha',
|
|
||||||
offset?: number,
|
|
||||||
): Promise<TraceResult> {
|
|
||||||
const params = new URLSearchParams({ url, mode });
|
|
||||||
if (offset !== undefined) params.set('offset', String(offset));
|
|
||||||
const res = await fetch(`${BASE}/trace?${params.toString()}`);
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
|
||||||
throw new Error(body?.error ?? `Trace failed (${res.status})`);
|
|
||||||
}
|
|
||||||
return deserialize(new Uint8Array(await res.arrayBuffer())) as TraceResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch a full parsed TTS save. */
|
|
||||||
export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> {
|
export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (fileUrl) params.set('fileUrl', fileUrl);
|
if (fileUrl) params.set('fileUrl', fileUrl);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
type ExtrudedGeometry,
|
type ExtrudedGeometry,
|
||||||
} from '@tts/mesh';
|
} from '@tts/mesh';
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from './assetUrl';
|
import { assetUrl } from '@tts/http';
|
||||||
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
|
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
|
||||||
import { flipTexture } from './flipTexture';
|
import { flipTexture } from './flipTexture';
|
||||||
import { getSharedGeometry, objectTint, tintedColor } from './sharedResources';
|
import { getSharedGeometry, objectTint, tintedColor } from './sharedResources';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { TTSObject } from '@tts/shared';
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { Object3D } from 'three';
|
import type { Object3D } from 'three';
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from './assetUrl';
|
import { assetUrl } from '@tts/http';
|
||||||
import { FlexibleModelLoader } from './flexibleModelLoader';
|
import { FlexibleModelLoader } from './flexibleModelLoader';
|
||||||
import { objectTint, tintedColor } from './sharedResources';
|
import { objectTint, tintedColor } from './sharedResources';
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type ExtrudedGeometry,
|
type ExtrudedGeometry,
|
||||||
} from '@tts/mesh';
|
} from '@tts/mesh';
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from './assetUrl';
|
import { assetUrl } from '@tts/http';
|
||||||
import { flipTexture } from './flipTexture';
|
import { flipTexture } from './flipTexture';
|
||||||
import {
|
import {
|
||||||
getSharedGeometry,
|
getSharedGeometry,
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ import { useMemo } from 'react';
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import {
|
import {
|
||||||
|
circleShape,
|
||||||
extrudeShapeParts,
|
extrudeShapeParts,
|
||||||
|
traceToShape,
|
||||||
|
traceToUvBounds,
|
||||||
type ExtrudedGeometry,
|
type ExtrudedGeometry,
|
||||||
type Shape,
|
|
||||||
type UVBounds,
|
|
||||||
} from '@tts/mesh';
|
} from '@tts/mesh';
|
||||||
import { traceImage } from '../../api';
|
import { traceImage } from '@tts/http';
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from './assetUrl';
|
import { assetUrl } from '@tts/http';
|
||||||
import {
|
import {
|
||||||
getSharedGeometry,
|
getSharedGeometry,
|
||||||
getSharedMaterial,
|
getSharedMaterial,
|
||||||
@@ -71,8 +72,9 @@ export function TokenMesh({
|
|||||||
const { front, back, walls } = useMemo(() => {
|
const { front, back, walls } = useMemo(() => {
|
||||||
// The traced shape and its UV framing share the same transform, so the
|
// The traced shape and its UV framing share the same transform, so the
|
||||||
// full image rectangle maps to the same bounds in mesh coordinates.
|
// full image rectangle maps to the same bounds in mesh coordinates.
|
||||||
const shape = trace ? toMeshShape(trace) : circleShape();
|
const scale = TOKEN_SIZE / Math.max(trace?.width ?? 0, trace?.height ?? 0);
|
||||||
const uvBounds = trace ? toUvBounds(trace) : undefined;
|
const shape = trace ? traceToShape(trace, scale) : circleShape(TOKEN_SIZE / 2);
|
||||||
|
const uvBounds = trace ? traceToUvBounds(trace, scale) : undefined;
|
||||||
const parts = extrudeShapeParts(shape, { height: thickness, uvBounds });
|
const parts = extrudeShapeParts(shape, { height: thickness, uvBounds });
|
||||||
// Shared across tokens with the same source image (the trace is cached per
|
// Shared across tokens with the same source image (the trace is cached per
|
||||||
// URL, so the silhouette is deterministic) so the full-setup view reuses
|
// URL, so the silhouette is deterministic) so the full-setup view reuses
|
||||||
@@ -154,71 +156,3 @@ function toGeometry(extruded: ExtrudedGeometry) {
|
|||||||
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||||
return geo;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* Route an external asset URL through the proxy so it can be loaded by
|
|
||||||
* three.js loaders (TextureLoader, GLTFLoader, etc.) despite the upstream host
|
|
||||||
* omitting CORS headers.
|
|
||||||
*/
|
|
||||||
export function assetUrl(url: string): string {
|
|
||||||
return `/asset?url=${encodeURIComponent(url)}`;
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import { useModStore } from '../stores/modStore';
|
|||||||
import { useSearchStore } from '../stores/searchStore';
|
import { useSearchStore } from '../stores/searchStore';
|
||||||
import { modFileUrl } from '../api';
|
import { modFileUrl } from '../api';
|
||||||
import ObjectTree from '../components/ObjectTree';
|
import ObjectTree from '../components/ObjectTree';
|
||||||
import ErrorBoundary from '../components/ErrorBoundary';
|
import { ErrorBoundary } from '@tts/tabletop';
|
||||||
import { resolveViewer } from '../components/viewers';
|
import { resolveViewer } from '../components/viewers';
|
||||||
import { iconsForObject } from '../components/objectIcons';
|
import { iconsForObject } from '../components/objectIcons';
|
||||||
// Register the 3D viewers (side effect) so `resolveViewer` can find them.
|
// Register the 3D viewers (side effect) so `resolveViewer` can find them.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { PartView, ErrorBoundary } from '@tts/tabletop';
|
||||||
import Breadcrumbs from '../components/Breadcrumbs';
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
import PackageMissing from '../components/PackageMissing';
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import Scene from '../components/viewers/Scene';
|
||||||
import { findPackage } from './bgm';
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
/** Detail view for a single part within a package. */
|
/** Detail view for a single part within a package. */
|
||||||
@@ -35,6 +37,14 @@ export default function PartPage() {
|
|||||||
{found.fillet ? ` · fillet ${found.fillet}mm` : ''}
|
{found.fillet ? ` · fillet ${found.fillet}mm` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{/* The boundary wraps the Canvas (Scene), not PartView, so its HTML
|
||||||
|
fallback renders outside the r3f namespace. The library's proxy
|
||||||
|
calls default to the host's `/asset` and `/trace` paths. */}
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Scene>
|
||||||
|
<PartView part={found} baseUrl={found.baseUrl} />
|
||||||
|
</Scene>
|
||||||
|
</ErrorBoundary>
|
||||||
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
|
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
|
||||||
{JSON.stringify(found, null, 2)}
|
{JSON.stringify(found, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
| `src/markdown.ts` | Virtual def files from markdown code blocks, via **`marked`**. `file=` naming + content-hash auto-naming (`./<hash>.yaml`); multiple blocks may share a `file=` name |
|
| `src/markdown.ts` | Virtual def files from markdown code blocks, via **`marked`**. `file=` naming + content-hash auto-naming (`./<hash>.yaml`); multiple blocks may share a `file=` name |
|
||||||
| `src/parse.ts` | yaml/json/toml → def objects (`yaml`, `smol-toml`); real-file walker (incl. `.csv`) |
|
| `src/parse.ts` | yaml/json/toml → def objects (`yaml`, `smol-toml`); real-file walker (incl. `.csv`) |
|
||||||
| `src/variants.ts` | `$variants` expansion via **`typed-csv`** (`typed-csv/csv-loader`). Inline (newline) vs path; paths resolve against the virtual def map |
|
| `src/variants.ts` | `$variants` expansion via **`typed-csv`** (`typed-csv/csv-loader`). Inline (newline) vs path; paths resolve against the virtual def map |
|
||||||
| `src/collect.ts` | `loadDefs` (real + virtual, virtual wins), `collectPackages` (package decl → `include` globs via **picomatch** → parts/surfaces/setups, `type#id` uniqueness, `$variants` on defs and route candidates) |
|
| `src/collect.ts` | `loadDefs` (real + virtual, virtual wins), `collectPackages` (package decl → `include` globs via **picomatch** → parts/surfaces/setups, `type#id` uniqueness, `$variants` on defs and route candidates). Sets each part's `baseUrl` to its source file's directory for resolving relative asset paths |
|
||||||
| `src/vite.ts` | The **vite plugin** (`bgm()`): resolves `virtual:bgm/packages` (all packages) and `virtual:bgm/package/<id>` (one package) imports to `export default <json>`, watches source files for reload. Serializes the package's `Map`s to objects (`SerializedPackage`); throws on unknown packages; re-collects per `load` (no stale cache). |
|
| `src/vite.ts` | The **vite plugin** (`bgm()`): resolves `virtual:bgm/packages` (all packages) and `virtual:bgm/package/<id>` (one package) imports to `export default <json>`, watches source files for reload. Serializes the package's `Map`s to objects (`SerializedPackage`); throws on unknown packages; re-collects per `load` (no stale cache). |
|
||||||
| `src/*.test.ts` | **23 tests, all passing** — markdown extractor, typed-csv parsing (incl. the spec's empty-array + crop tuple cases), full harbor collection, plugin unit tests (resolve/load/shape/watch), and a real `vite build` integration test covering two packages |
|
| `src/*.test.ts` | **23 tests, all passing** — markdown extractor, typed-csv parsing (incl. the spec's empty-array + crop tuple cases), full harbor collection, plugin unit tests (resolve/load/shape/watch), and a real `vite build` integration test covering two packages |
|
||||||
|
|
||||||
@@ -24,6 +24,22 @@ Deps: `marked`, `typed-csv`, `yaml`, `smol-toml`, `picomatch`, `zod`, `vite`, `@
|
|||||||
|
|
||||||
Exercises the format end-to-end: package decl, two `file=parts/tokens.yaml` blocks, a table surface with `mount`/`children` and `candidates: $variants` against a virtual csv block, a child player surface, and a setup declaring its enabled `surfaces`. Same content duplicated as the vitest fixture under `packages/bgm/src/__fixtures__/harbor/`.
|
Exercises the format end-to-end: package decl, two `file=parts/tokens.yaml` blocks, a table surface with `mount`/`children` and `candidates: $variants` against a virtual csv block, a child player surface, and a setup declaring its enabled `surfaces`. Same content duplicated as the vitest fixture under `packages/bgm/src/__fixtures__/harbor/`.
|
||||||
|
|
||||||
|
### `games/poker/poker.md` — example game (new)
|
||||||
|
|
||||||
|
A real 52-card deck: a single `card` part expanded by `$variants` into 52 cards, each picking a cell from a `13×4` face sheet (`cards-13x4.jpg`) and a shared `4×1` back sheet (`back-4x1.png`). Assets are stored in Git LFS (`.gitattributes`).
|
||||||
|
|
||||||
|
### `packages/tabletop` — rendering library (new)
|
||||||
|
|
||||||
|
A standalone r3f library that renders bgm parts. `PartView`/`PartMesh` build a mesh from a `Part` definition via `@tts/mesh` (size/fillet, face/back sprite UVs, traced `shape` or rect fallback). Proxy calls (`/asset`, `/trace`) default to `@tts/http` handlers and are overridable via `TabletopProvider`. Plan: [`bgm-tabletop-plan.md`](./bgm-tabletop-plan.md).
|
||||||
|
|
||||||
|
### `packages/http` — shared proxy HTTP (new)
|
||||||
|
|
||||||
|
CORS-safe proxy HTTP helpers shared by the web app and `@tts/tabletop`: `assetUrl`/`resolveAssetUrl` (route external assets through `/asset`) and `traceImage` (image → vector shape via `/trace`, BSON-deserialized). The trace-to-shape geometry conversion lives in `@tts/mesh` (`traceToShape`/`traceToUvBounds`), and `ErrorBoundary` is shared via `@tts/tabletop`.
|
||||||
|
|
||||||
|
### `apps/proxy` — local game assets (new)
|
||||||
|
|
||||||
|
The proxy serves local game assets from the `games` root: `GAMES_ROOT` (env or default) is set at startup (`config.ts`) and read by the `/asset` and `/trace` routes to serve relative paths (e.g. `poker/parts/assets/cards.png`) from disk, alongside the existing http(s) proxy path.
|
||||||
|
|
||||||
### `apps/web` — consumer (new)
|
### `apps/web` — consumer (new)
|
||||||
|
|
||||||
- `vite.config.ts` — wired with `bgm({ root: <repo>/games })`, importing the plugin from `@tts/bgm`.
|
- `vite.config.ts` — wired with `bgm({ root: <repo>/games })`, importing the plugin from `@tts/bgm`.
|
||||||
@@ -36,9 +52,10 @@ The vite plugin itself lives in `packages/bgm/src/vite.ts` (exported from `@tts/
|
|||||||
## Works
|
## Works
|
||||||
|
|
||||||
- `pnpm --filter @tts/bgm build` and `typecheck` pass.
|
- `pnpm --filter @tts/bgm build` and `typecheck` pass.
|
||||||
- Root `pnpm test`: **177 pass**.
|
- Root `pnpm test`: **191 pass**.
|
||||||
- `pnpm --filter @tts/web build` succeeds; config warnings fixed.
|
- `pnpm --filter @tts/web build` succeeds; config warnings fixed.
|
||||||
- `src/vite.test.ts` runs a **real `vite build`** against a self-contained fixture (`src/__fixtures__/vite-build/`) and asserts the bundled output contains the package data — the plugin is proven end-to-end without touching the web app.
|
- `src/vite.test.ts` runs a **real `vite build`** against a self-contained fixture (`src/__fixtures__/vite-build/`) and asserts the bundled output contains the package data — the plugin is proven end-to-end without touching the web app.
|
||||||
|
- `pnpm --filter @tts/tabletop build` / `test` pass; `pnpm --filter @tts/proxy typecheck` passes.
|
||||||
|
|
||||||
## Known issues
|
## Known issues
|
||||||
|
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+62
-61
@@ -16,74 +16,75 @@ language: en
|
|||||||
## Parts
|
## Parts
|
||||||
|
|
||||||
A single `card` part expanded into 52 cards by the `$variants` CSV. Each row
|
A single `card` part expanded into 52 cards by the `$variants` CSV. Each row
|
||||||
picks a cell from a `13×5` sprite sheet — 13 ranks across, 4 suits down, and
|
picks a cell from the `13×4` face sheet (`cards-13x4.jpg`) — 13 ranks across,
|
||||||
a shared card-back row at index 4.
|
4 suits down. All cards share the same back from the `4×1` back sheet
|
||||||
|
(`back-4x1.png`).
|
||||||
|
|
||||||
```yaml file=parts/cards.yaml
|
```yaml file=cards.yaml
|
||||||
role: part
|
role: part
|
||||||
type: card
|
type: card
|
||||||
face: ./assets/cards.png
|
face: ./cards-13x4.jpg
|
||||||
back: ./assets/cards.png
|
back: ./back-4x1.png
|
||||||
size: [63, 88, 3]
|
size: [63, 88, 3]
|
||||||
fillet: 2
|
fillet: 2
|
||||||
$variants: ./cards.csv
|
$variants: ./cards.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/cards.csv
|
```csv file=cards.csv
|
||||||
id,rank,suit,faceCrop,backCrop
|
id,rank,suit,faceCrop,backCrop
|
||||||
string,string,string,[number;number;number;number],[number;number;number;number]
|
string,string,string,[number;number;number;number],[number;number;number;number]
|
||||||
2s,2,spades,[0;0;13;5],[0;4;13;5]
|
2s,2,spades,[0;0;13;4],[0;0;4;1]
|
||||||
3s,3,spades,[1;0;13;5],[0;4;13;5]
|
3s,3,spades,[1;0;13;4],[0;0;4;1]
|
||||||
4s,4,spades,[2;0;13;5],[0;4;13;5]
|
4s,4,spades,[2;0;13;4],[0;0;4;1]
|
||||||
5s,5,spades,[3;0;13;5],[0;4;13;5]
|
5s,5,spades,[3;0;13;4],[0;0;4;1]
|
||||||
6s,6,spades,[4;0;13;5],[0;4;13;5]
|
6s,6,spades,[4;0;13;4],[0;0;4;1]
|
||||||
7s,7,spades,[5;0;13;5],[0;4;13;5]
|
7s,7,spades,[5;0;13;4],[0;0;4;1]
|
||||||
8s,8,spades,[6;0;13;5],[0;4;13;5]
|
8s,8,spades,[6;0;13;4],[0;0;4;1]
|
||||||
9s,9,spades,[7;0;13;5],[0;4;13;5]
|
9s,9,spades,[7;0;13;4],[0;0;4;1]
|
||||||
10s,10,spades,[8;0;13;5],[0;4;13;5]
|
10s,10,spades,[8;0;13;4],[0;0;4;1]
|
||||||
js,J,spades,[9;0;13;5],[0;4;13;5]
|
js,J,spades,[9;0;13;4],[0;0;4;1]
|
||||||
qs,Q,spades,[10;0;13;5],[0;4;13;5]
|
qs,Q,spades,[10;0;13;4],[0;0;4;1]
|
||||||
ks,K,spades,[11;0;13;5],[0;4;13;5]
|
ks,K,spades,[11;0;13;4],[0;0;4;1]
|
||||||
as,A,spades,[12;0;13;5],[0;4;13;5]
|
as,A,spades,[12;0;13;4],[0;0;4;1]
|
||||||
2h,2,hearts,[0;1;13;5],[0;4;13;5]
|
2h,2,hearts,[0;1;13;4],[0;0;4;1]
|
||||||
3h,3,hearts,[1;1;13;5],[0;4;13;5]
|
3h,3,hearts,[1;1;13;4],[0;0;4;1]
|
||||||
4h,4,hearts,[2;1;13;5],[0;4;13;5]
|
4h,4,hearts,[2;1;13;4],[0;0;4;1]
|
||||||
5h,5,hearts,[3;1;13;5],[0;4;13;5]
|
5h,5,hearts,[3;1;13;4],[0;0;4;1]
|
||||||
6h,6,hearts,[4;1;13;5],[0;4;13;5]
|
6h,6,hearts,[4;1;13;4],[0;0;4;1]
|
||||||
7h,7,hearts,[5;1;13;5],[0;4;13;5]
|
7h,7,hearts,[5;1;13;4],[0;0;4;1]
|
||||||
8h,8,hearts,[6;1;13;5],[0;4;13;5]
|
8h,8,hearts,[6;1;13;4],[0;0;4;1]
|
||||||
9h,9,hearts,[7;1;13;5],[0;4;13;5]
|
9h,9,hearts,[7;1;13;4],[0;0;4;1]
|
||||||
10h,10,hearts,[8;1;13;5],[0;4;13;5]
|
10h,10,hearts,[8;1;13;4],[0;0;4;1]
|
||||||
jh,J,hearts,[9;1;13;5],[0;4;13;5]
|
jh,J,hearts,[9;1;13;4],[0;0;4;1]
|
||||||
qh,Q,hearts,[10;1;13;5],[0;4;13;5]
|
qh,Q,hearts,[10;1;13;4],[0;0;4;1]
|
||||||
kh,K,hearts,[11;1;13;5],[0;4;13;5]
|
kh,K,hearts,[11;1;13;4],[0;0;4;1]
|
||||||
ah,A,hearts,[12;1;13;5],[0;4;13;5]
|
ah,A,hearts,[12;1;13;4],[0;0;4;1]
|
||||||
2d,2,diamonds,[0;2;13;5],[0;4;13;5]
|
2d,2,diamonds,[0;2;13;4],[0;0;4;1]
|
||||||
3d,3,diamonds,[1;2;13;5],[0;4;13;5]
|
3d,3,diamonds,[1;2;13;4],[0;0;4;1]
|
||||||
4d,4,diamonds,[2;2;13;5],[0;4;13;5]
|
4d,4,diamonds,[2;2;13;4],[0;0;4;1]
|
||||||
5d,5,diamonds,[3;2;13;5],[0;4;13;5]
|
5d,5,diamonds,[3;2;13;4],[0;0;4;1]
|
||||||
6d,6,diamonds,[4;2;13;5],[0;4;13;5]
|
6d,6,diamonds,[4;2;13;4],[0;0;4;1]
|
||||||
7d,7,diamonds,[5;2;13;5],[0;4;13;5]
|
7d,7,diamonds,[5;2;13;4],[0;0;4;1]
|
||||||
8d,8,diamonds,[6;2;13;5],[0;4;13;5]
|
8d,8,diamonds,[6;2;13;4],[0;0;4;1]
|
||||||
9d,9,diamonds,[7;2;13;5],[0;4;13;5]
|
9d,9,diamonds,[7;2;13;4],[0;0;4;1]
|
||||||
10d,10,diamonds,[8;2;13;5],[0;4;13;5]
|
10d,10,diamonds,[8;2;13;4],[0;0;4;1]
|
||||||
jd,J,diamonds,[9;2;13;5],[0;4;13;5]
|
jd,J,diamonds,[9;2;13;4],[0;0;4;1]
|
||||||
qd,Q,diamonds,[10;2;13;5],[0;4;13;5]
|
qd,Q,diamonds,[10;2;13;4],[0;0;4;1]
|
||||||
kd,K,diamonds,[11;2;13;5],[0;4;13;5]
|
kd,K,diamonds,[11;2;13;4],[0;0;4;1]
|
||||||
ad,A,diamonds,[12;2;13;5],[0;4;13;5]
|
ad,A,diamonds,[12;2;13;4],[0;0;4;1]
|
||||||
2c,2,clubs,[0;3;13;5],[0;4;13;5]
|
2c,2,clubs,[0;3;13;4],[0;0;4;1]
|
||||||
3c,3,clubs,[1;3;13;5],[0;4;13;5]
|
3c,3,clubs,[1;3;13;4],[0;0;4;1]
|
||||||
4c,4,clubs,[2;3;13;5],[0;4;13;5]
|
4c,4,clubs,[2;3;13;4],[0;0;4;1]
|
||||||
5c,5,clubs,[3;3;13;5],[0;4;13;5]
|
5c,5,clubs,[3;3;13;4],[0;0;4;1]
|
||||||
6c,6,clubs,[4;3;13;5],[0;4;13;5]
|
6c,6,clubs,[4;3;13;4],[0;0;4;1]
|
||||||
7c,7,clubs,[5;3;13;5],[0;4;13;5]
|
7c,7,clubs,[5;3;13;4],[0;0;4;1]
|
||||||
8c,8,clubs,[6;3;13;5],[0;4;13;5]
|
8c,8,clubs,[6;3;13;4],[0;0;4;1]
|
||||||
9c,9,clubs,[7;3;13;5],[0;4;13;5]
|
9c,9,clubs,[7;3;13;4],[0;0;4;1]
|
||||||
10c,10,clubs,[8;3;13;5],[0;4;13;5]
|
10c,10,clubs,[8;3;13;4],[0;0;4;1]
|
||||||
jc,J,clubs,[9;3;13;5],[0;4;13;5]
|
jc,J,clubs,[9;3;13;4],[0;0;4;1]
|
||||||
qc,Q,clubs,[10;3;13;5],[0;4;13;5]
|
qc,Q,clubs,[10;3;13;4],[0;0;4;1]
|
||||||
kc,K,clubs,[11;3;13;5],[0;4;13;5]
|
kc,K,clubs,[11;3;13;4],[0;0;4;1]
|
||||||
ac,A,clubs,[12;3;13;5],[0;4;13;5]
|
ac,A,clubs,[12;3;13;4],[0;0;4;1]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Board
|
## Board
|
||||||
@@ -91,7 +92,7 @@ ac,A,clubs,[12;3;13;5],[0;4;13;5]
|
|||||||
A table with a draw pile on the left and five community-card slots across the
|
A table with a draw pile on the left and five community-card slots across the
|
||||||
middle. The deck pile fans its stacked cards along a curve.
|
middle. The deck pile fans its stacked cards along a curve.
|
||||||
|
|
||||||
```yaml file=parts/board.yaml
|
```yaml file=board.yaml
|
||||||
type: board
|
type: board
|
||||||
id: poker
|
id: poker
|
||||||
role: surface
|
role: surface
|
||||||
@@ -110,7 +111,7 @@ layout:
|
|||||||
$variants: ./community.csv
|
$variants: ./community.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/community.csv
|
```csv file=community.csv
|
||||||
slot,x,y,rotation
|
slot,x,y,rotation
|
||||||
string,number,number,number
|
string,number,number,number
|
||||||
0,-100,0,0
|
0,-100,0,0
|
||||||
@@ -125,7 +126,7 @@ string,number,number,number
|
|||||||
Deal the whole deck onto the draw pile (`poker:card` expands to every card of
|
Deal the whole deck onto the draw pile (`poker:card` expands to every card of
|
||||||
that type), then flip a flop onto the community slots.
|
that type), then flip a flop onto the community slots.
|
||||||
|
|
||||||
```yaml file=setup/main.yaml
|
```yaml file=main.yaml
|
||||||
role: setup
|
role: setup
|
||||||
type: game
|
type: game
|
||||||
id: main
|
id: main
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ambient types for the virtual `bgm` modules served by the bgm vite plugin,
|
||||||
|
* so the fixture's entry typechecks in the editor (the plugin provides them at
|
||||||
|
* build time). Mirrors `apps/web/src/vite-env.d.ts`.
|
||||||
|
*/
|
||||||
|
declare module 'virtual:bgm/packages' {
|
||||||
|
import type { SerializedPackage } from '@tts/bgm';
|
||||||
|
const packages: SerializedPackage[];
|
||||||
|
export default packages;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'virtual:bgm/package/*' {
|
||||||
|
import type { SerializedPackage } from '@tts/bgm';
|
||||||
|
const pkg: SerializedPackage;
|
||||||
|
export default pkg;
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ describe('collectPackages', () => {
|
|||||||
});
|
});
|
||||||
expect(wood.face).toBe('./assets/tokens.png');
|
expect(wood.face).toBe('./assets/tokens.png');
|
||||||
expect(wood.faceCrop).toEqual([1, 0, 5, 2]);
|
expect(wood.faceCrop).toEqual([1, 0, 5, 2]);
|
||||||
|
// Relative assets resolve against the source file's directory. The fixture
|
||||||
|
// markdown sits at the games root, so the virtual file is `parts/tokens.yaml`.
|
||||||
|
expect(wood.baseUrl).toBe('parts/');
|
||||||
|
|
||||||
// Two surfaces: the table board and its child player board.
|
// Two surfaces: the table board and its child player board.
|
||||||
expect([...harbor.surfaces.keys()].sort()).toEqual(['board#harbor', 'board#player']);
|
expect([...harbor.surfaces.keys()].sort()).toEqual(['board#harbor', 'board#player']);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
*
|
*
|
||||||
* See docs/bgm-format.md for the format's concrete behavior.
|
* See docs/bgm-format.md for the format's concrete behavior.
|
||||||
*/
|
*/
|
||||||
|
import * as path from 'node:path';
|
||||||
import picomatch from 'picomatch';
|
import picomatch from 'picomatch';
|
||||||
import { collectVirtualFiles } from './markdown.js';
|
import { collectVirtualFiles } from './markdown.js';
|
||||||
import { parseDefText, readDefFiles } from './parse.js';
|
import { parseDefText, readDefFiles } from './parse.js';
|
||||||
@@ -218,7 +219,13 @@ function asPackage(def: ParsedDef, source: string): PackageDef {
|
|||||||
|
|
||||||
function asPart(obj: Record<string, unknown>, source: string): Part {
|
function asPart(obj: Record<string, unknown>, source: string): Part {
|
||||||
try {
|
try {
|
||||||
return validatePart(obj) as unknown as Part;
|
const part = validatePart(obj) as unknown as Part;
|
||||||
|
// Resolve relative asset paths against the directory of the source file
|
||||||
|
// (path-style name relative to the games root, e.g. `harbor/parts/`).
|
||||||
|
// Real files may carry a leading slash from an empty root; strip it.
|
||||||
|
const dir = path.posix.dirname(source).replace(/^\/+/, '');
|
||||||
|
part.baseUrl = dir ? `${dir}/` : '';
|
||||||
|
return part;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw wrapZod(err, source);
|
throw wrapZod(err, source);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ export interface Part {
|
|||||||
size?: Size;
|
size?: Size;
|
||||||
/** Fillet radius in mm; defaults to `0`. */
|
/** Fillet radius in mm; defaults to `0`. */
|
||||||
fillet?: number;
|
fillet?: number;
|
||||||
|
/**
|
||||||
|
* Directory of the part's source file (json/yaml/markdown), for resolving
|
||||||
|
* relative asset paths. Set by the loader; not authored.
|
||||||
|
*/
|
||||||
|
baseUrl?: string;
|
||||||
/** Extra fields from the source definition, kept for forwards compatibility. */
|
/** Extra fields from the source definition, kept for forwards compatibility. */
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ describe('bgm vite plugin', () => {
|
|||||||
const plugin = bgm({ root: gamesRoot });
|
const plugin = bgm({ root: gamesRoot });
|
||||||
const watched: string[] = [];
|
const watched: string[] = [];
|
||||||
const context = { addWatchFile: (file: string) => watched.push(file) };
|
const context = { addWatchFile: (file: string) => watched.push(file) };
|
||||||
(plugin.buildStart as Callable<typeof plugin.buildStart>).call(context);
|
// Vite 8 (rolldown) `buildStart` takes `(this, options)`; the plugin ignores
|
||||||
|
// the options, so pass a placeholder.
|
||||||
|
(plugin.buildStart as Callable<typeof plugin.buildStart>).call(context, {} as never);
|
||||||
|
|
||||||
// Every def file (real + virtual code blocks) is watched so edits
|
// Every def file (real + virtual code blocks) is watched so edits
|
||||||
// trigger a re-collect.
|
// trigger a re-collect.
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@tts/http",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "echo \"no lint configured\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tts/shared": "workspace:*",
|
||||||
|
"bson": "^7.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveAssetUrl } from './asset.js';
|
||||||
|
|
||||||
|
describe('resolveAssetUrl', () => {
|
||||||
|
it('passes absolute urls through unchanged', () => {
|
||||||
|
expect(resolveAssetUrl('https://example.com/a.png', 'harbor/parts/')).toBe(
|
||||||
|
'https://example.com/a.png',
|
||||||
|
);
|
||||||
|
expect(resolveAssetUrl('data:image/png;base64,AAAA', 'harbor/parts/')).toBe(
|
||||||
|
'data:image/png;base64,AAAA',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a relative path against baseUrl', () => {
|
||||||
|
expect(resolveAssetUrl('./assets/tokens.png', 'harbor/parts/')).toBe(
|
||||||
|
'harbor/parts/assets/tokens.png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the relative path as-is without a baseUrl', () => {
|
||||||
|
expect(resolveAssetUrl('./assets/tokens.png')).toBe('./assets/tokens.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Asset URL helpers for the host app's CORS-safe asset proxy (`/asset`).
|
||||||
|
* Shared by the web viewers and `@tts/tabletop`, which both route external
|
||||||
|
* assets (textures, models) through the proxy so three.js can load them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a part's asset URL. Absolute URLs (http/https, data:, blob:) pass
|
||||||
|
* through unchanged; relative paths are resolved against `baseUrl` — the
|
||||||
|
* directory of the part's source file (the json/yaml/markdown that defined
|
||||||
|
* it). Without a `baseUrl`, a relative path is returned as-is.
|
||||||
|
*/
|
||||||
|
export function resolveAssetUrl(url: string, baseUrl?: string): string {
|
||||||
|
if (/^(https?:|data:|blob:)/i.test(url)) return url;
|
||||||
|
if (!baseUrl) return url;
|
||||||
|
// `baseUrl` is a path relative to the games root (no scheme), so join rather
|
||||||
|
// than resolve as a URL. A fake origin lets `new URL` normalize `./` and
|
||||||
|
// `../`; the origin is stripped from the result.
|
||||||
|
const resolved = new URL(url, `http://localhost/${baseUrl}`).pathname;
|
||||||
|
return resolved.replace(/^\/+/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route an external asset URL through the proxy so three.js can load it. */
|
||||||
|
export function assetUrl(url: string): string {
|
||||||
|
return `/asset?url=${encodeURIComponent(url)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { resolveAssetUrl, assetUrl } from './asset.js';
|
||||||
|
export { traceImage } from './trace.js';
|
||||||
|
export type { TraceResult } from '@tts/shared';
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Trace an image into a vector shape via the host app's proxy (`/trace`).
|
||||||
|
* Shared by the web token viewer and `@tts/tabletop`, which both trace an
|
||||||
|
* image's alpha channel into a silhouette for extrusion.
|
||||||
|
*/
|
||||||
|
import { deserialize } from 'bson';
|
||||||
|
import type { TraceResult } from '@tts/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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(`/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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,3 +3,4 @@ export * from './shapes.js';
|
|||||||
export * from './tessellate.js';
|
export * from './tessellate.js';
|
||||||
export * from './walls.js';
|
export * from './walls.js';
|
||||||
export * from './extrude.js';
|
export * from './extrude.js';
|
||||||
|
export * from './trace.js';
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { traceToShape, traceToUvBounds } from './trace.js';
|
||||||
|
|
||||||
|
describe('traceToShape', () => {
|
||||||
|
it('flips y and centers the traced shape', () => {
|
||||||
|
const trace = {
|
||||||
|
shape: { outline: [[0, 0], [10, 0], [10, 10], [0, 10]] },
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
};
|
||||||
|
const shape = traceToShape(trace, 0.2);
|
||||||
|
// Centered at origin, scaled to fit the 2x2 box. The y-flip reverses
|
||||||
|
// winding, so the outline is normalized to CCW (order may differ).
|
||||||
|
expect(shape.outline).toHaveLength(4);
|
||||||
|
expect(shape.outline).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
[-1, 1],
|
||||||
|
[1, 1],
|
||||||
|
[1, -1],
|
||||||
|
[-1, -1],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps holes to clockwise winding', () => {
|
||||||
|
const trace = {
|
||||||
|
shape: {
|
||||||
|
outline: [[0, 0], [10, 0], [10, 10], [0, 10]],
|
||||||
|
holes: [[[2, 2], [2, 8], [8, 8], [8, 2]]],
|
||||||
|
},
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
};
|
||||||
|
const shape = traceToShape(trace, 0.2);
|
||||||
|
expect(shape.holes).toHaveLength(1);
|
||||||
|
expect(shape.holes![0]).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('traceToUvBounds', () => {
|
||||||
|
it('maps the full image rectangle to the part box', () => {
|
||||||
|
const bounds = traceToUvBounds({ width: 10, height: 20 }, 0.2);
|
||||||
|
expect(bounds).toEqual({ minX: -1, minY: -2, maxX: 1, maxY: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Trace-to-shape helpers: convert a proxy `/trace` result (image pixel coords,
|
||||||
|
* origin top-left, y-down) into a mesh `Shape` (y-up, centered at the origin).
|
||||||
|
*
|
||||||
|
* Shared by the web token viewer and `@tts/tabletop`, which both trace an
|
||||||
|
* image's alpha channel into a silhouette and extrude it. The only difference
|
||||||
|
* between consumers is the scale factor, so the transform is parameterized by
|
||||||
|
* `scale` and the callers compute it (fit-to-box vs fit-to-max-dimension).
|
||||||
|
*/
|
||||||
|
import type { Shape } from './shapes.js';
|
||||||
|
import type { UVBounds } from './types.js';
|
||||||
|
|
||||||
|
/** A traced image region, in image pixel coords (origin top-left, y-down). */
|
||||||
|
export interface TraceInput {
|
||||||
|
shape: { outline: number[][]; holes?: number[][][] };
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a traced shape to a mesh `Shape` scaled by `scale`. Flips the y-axis
|
||||||
|
* (image y-down → mesh y-up), centers the result at the origin, and normalizes
|
||||||
|
* winding so the outline is counter-clockwise and holes are clockwise (as
|
||||||
|
* `@tts/mesh` expects).
|
||||||
|
*/
|
||||||
|
export function traceToShape(trace: TraceInput, scale: number): Shape {
|
||||||
|
const { shape, width, height } = trace;
|
||||||
|
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 a traced silhouette (which may be smaller than the image
|
||||||
|
* when there is transparent padding).
|
||||||
|
*/
|
||||||
|
export function traceToUvBounds(
|
||||||
|
trace: { width: number; height: number },
|
||||||
|
scale: number,
|
||||||
|
): UVBounds {
|
||||||
|
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 CCW (outline). */
|
||||||
|
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,37 @@
|
|||||||
|
{
|
||||||
|
"name": "@tts/tabletop",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "echo \"no lint configured\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-three/drei": "^10.7.8",
|
||||||
|
"@react-three/fiber": "^9.7.0",
|
||||||
|
"@react-three/postprocessing": "^3.0.4",
|
||||||
|
"@tts/bgm": "workspace:*",
|
||||||
|
"@tts/http": "workspace:*",
|
||||||
|
"@tts/mesh": "workspace:*",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"three": "^0.185.1",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/three": "^0.185.4",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,8 @@ interface State {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Catches render errors in its subtree and renders a fallback instead of
|
* Catches render errors in its subtree and renders a fallback instead of
|
||||||
* letting them crash the whole page. Used to isolate failures in object
|
* letting them crash the whole page. Used to isolate failures in part viewers
|
||||||
* viewers (e.g. WebGL context loss, model/texture load errors).
|
* (e.g. WebGL context loss, texture/trace load errors).
|
||||||
*/
|
*/
|
||||||
export default class ErrorBoundary extends Component<Props, State> {
|
export default class ErrorBoundary extends Component<Props, State> {
|
||||||
override state: State = { error: null };
|
override state: State = { error: null };
|
||||||
@@ -24,7 +24,7 @@ export default class ErrorBoundary extends Component<Props, State> {
|
|||||||
|
|
||||||
override componentDidCatch(error: Error, info: ErrorInfo) {
|
override componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
// Surface the error for debugging without breaking the UI.
|
// Surface the error for debugging without breaking the UI.
|
||||||
console.error('Viewer error:', error, info.componentStack);
|
console.error('Part viewer error:', error, info.componentStack);
|
||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
@@ -40,7 +40,7 @@ export default class ErrorBoundary extends Component<Props, State> {
|
|||||||
function DefaultFallback({ error }: { error: Error }) {
|
function DefaultFallback({ error }: { error: Error }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-80 flex-col items-center justify-center gap-2 overflow-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4 text-center">
|
<div className="flex h-80 flex-col items-center justify-center gap-2 overflow-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4 text-center">
|
||||||
<p className="text-sm font-medium text-zinc-200">Couldn't render this object</p>
|
<p className="text-sm font-medium text-zinc-200">Couldn't render this part</p>
|
||||||
<p className="max-w-sm wrap-break-word font-mono text-xs text-zinc-400">
|
<p className="max-w-sm wrap-break-word font-mono text-xs text-zinc-400">
|
||||||
{error.message}
|
{error.message}
|
||||||
</p>
|
</p>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* HTTP helpers for loading part assets. Re-exported from `@tts/http`, the
|
||||||
|
* shared package for the host app's proxy (`/asset` and `/trace`) handlers.
|
||||||
|
* Kept as a thin re-export so the tabletop's public surface is unchanged.
|
||||||
|
*/
|
||||||
|
export { resolveAssetUrl, assetUrl, traceImage } from '@tts/http';
|
||||||
|
export type { TraceResult } from '@tts/http';
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export { PartView, PartMesh } from './partView.js';
|
||||||
|
export { default as ErrorBoundary } from './ErrorBoundary.js';
|
||||||
|
export { TabletopProvider, useTabletopHttp } from './provider.js';
|
||||||
|
export type { TabletopHttp, AssetUrlFn, TraceImageFn } from './provider.js';
|
||||||
|
export {
|
||||||
|
partDimensions,
|
||||||
|
spriteUvFromCrop,
|
||||||
|
fallbackShape,
|
||||||
|
traceToShape,
|
||||||
|
traceToUvBounds,
|
||||||
|
} from './part.js';
|
||||||
|
export { resolveAssetUrl, assetUrl, traceImage } from './http.js';
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { Part } from '@tts/bgm';
|
||||||
|
import {
|
||||||
|
partDimensions,
|
||||||
|
spriteUvFromCrop,
|
||||||
|
fallbackShape,
|
||||||
|
traceToShape,
|
||||||
|
traceToUvBounds,
|
||||||
|
} from './part.js';
|
||||||
|
|
||||||
|
describe('partDimensions', () => {
|
||||||
|
it('converts mm size to world units', () => {
|
||||||
|
const part: Part = { type: 'card', id: 'as', size: [63, 88, 3] };
|
||||||
|
expect(partDimensions(part)).toEqual({ width: 63 / 30, height: 88 / 30, depth: 3 / 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults when no size', () => {
|
||||||
|
const part: Part = { type: 'token', id: 'wood' };
|
||||||
|
expect(partDimensions(part)).toEqual({ width: 2, height: 2, depth: 0.1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('spriteUvFromCrop', () => {
|
||||||
|
it('returns full-image UVs without a crop', () => {
|
||||||
|
expect(spriteUvFromCrop(undefined)).toEqual({ repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects a sprite cell from a crop', () => {
|
||||||
|
// [col, row, cols, rows] — cell at col 1, row 2 of a 13x5 sheet.
|
||||||
|
expect(spriteUvFromCrop([1, 2, 13, 5])).toEqual({
|
||||||
|
repeatX: 1 / 13,
|
||||||
|
repeatY: 1 / 5,
|
||||||
|
offsetX: 1 / 13,
|
||||||
|
offsetY: 1 - 3 / 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fallbackShape', () => {
|
||||||
|
it('returns a rect without a fillet', () => {
|
||||||
|
const part: Part = { type: 'tile', id: 'x', size: [40, 20, 3] };
|
||||||
|
const shape = fallbackShape(part, 40 / 30, 20 / 30);
|
||||||
|
expect(shape.outline).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a rounded rect with a fillet', () => {
|
||||||
|
const part: Part = { type: 'card', id: 'as', size: [63, 88, 3], fillet: 2 };
|
||||||
|
const shape = fallbackShape(part, 63 / 30, 88 / 30);
|
||||||
|
// 4 corners x 4 segments.
|
||||||
|
expect(shape.outline.length).toBeGreaterThan(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('traceToShape', () => {
|
||||||
|
it('flips y and centers the traced shape', () => {
|
||||||
|
const trace = {
|
||||||
|
shape: { outline: [[0, 0], [10, 0], [10, 10], [0, 10]] },
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
};
|
||||||
|
const shape = traceToShape(trace, 2, 2);
|
||||||
|
// Centered at origin, scaled to fit the 2x2 box. The y-flip reverses
|
||||||
|
// winding, so the outline is normalized to CCW (order may differ).
|
||||||
|
expect(shape.outline).toHaveLength(4);
|
||||||
|
expect(shape.outline).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
[-1, 1],
|
||||||
|
[1, 1],
|
||||||
|
[1, -1],
|
||||||
|
[-1, -1],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('traceToUvBounds', () => {
|
||||||
|
it('maps the full image rectangle to the part box', () => {
|
||||||
|
const bounds = traceToUvBounds({ width: 10, height: 20 }, 2, 4);
|
||||||
|
expect(bounds).toEqual({ minX: -1, minY: -2, maxX: 1, maxY: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Pure helpers for rendering a bgm `Part` as a mesh. Kept free of react-three
|
||||||
|
* so they can be unit-tested in a plain node environment (mirroring the web
|
||||||
|
* app's `cardResolution.ts`).
|
||||||
|
*/
|
||||||
|
import type { Part, Crop } from '@tts/bgm';
|
||||||
|
import {
|
||||||
|
rectShape,
|
||||||
|
roundedRectShape,
|
||||||
|
traceToShape as meshTraceToShape,
|
||||||
|
traceToUvBounds as meshTraceToUvBounds,
|
||||||
|
type Shape,
|
||||||
|
} from '@tts/mesh';
|
||||||
|
|
||||||
|
/** Scale from mm (the format's `size` unit) to world units. */
|
||||||
|
export const MM_TO_WORLD = 1 / 30;
|
||||||
|
|
||||||
|
/** Default part size `[w, h, d]` in mm when a part has no `size`. */
|
||||||
|
const DEFAULT_SIZE: [number, number, number] = [60, 60, 3];
|
||||||
|
|
||||||
|
/** A part's dimensions in world units, derived from its `size` (mm). */
|
||||||
|
export function partDimensions(part: Part): { width: number; height: number; depth: number } {
|
||||||
|
const [w, h, d] = part.size ?? DEFAULT_SIZE;
|
||||||
|
return {
|
||||||
|
width: w * MM_TO_WORLD,
|
||||||
|
height: h * MM_TO_WORLD,
|
||||||
|
depth: d * MM_TO_WORLD,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UV repeat/offset that selects a single sprite from a sheet, given a crop
|
||||||
|
* `[col, row, cols, rows]` that divides the image into a `cols` x `rows` grid
|
||||||
|
* and picks the cell at `[col, row]`. Row 0 is the top of the image (v=1), so
|
||||||
|
* the offset counts down from 1. Without a crop, the whole image is shown.
|
||||||
|
*/
|
||||||
|
export function spriteUvFromCrop(
|
||||||
|
crop: Crop | undefined,
|
||||||
|
): { repeatX: number; repeatY: number; offsetX: number; offsetY: number } {
|
||||||
|
if (!crop) return { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 };
|
||||||
|
const [col, row, cols, rows] = crop;
|
||||||
|
return {
|
||||||
|
repeatX: 1 / cols,
|
||||||
|
repeatY: 1 / rows,
|
||||||
|
offsetX: col / cols,
|
||||||
|
offsetY: 1 - (row + 1) / rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fallback footprint for a part without a `shape` sprite: a rounded rect
|
||||||
|
* when `fillet` is set, otherwise a plain rect, sized to the part's world
|
||||||
|
* dimensions. The fillet (mm) is converted to world units and clamped to half
|
||||||
|
* the smaller dimension.
|
||||||
|
*/
|
||||||
|
export function fallbackShape(part: Part, width: number, height: number): Shape {
|
||||||
|
const fillet = (part.fillet ?? 0) * MM_TO_WORLD;
|
||||||
|
if (fillet > 0) {
|
||||||
|
return roundedRectShape(width, height, Math.min(fillet, Math.min(width, height) / 2));
|
||||||
|
}
|
||||||
|
return rectShape(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a traced shape (image pixel coords, origin top-left, y-down) to a
|
||||||
|
* mesh `Shape` (y-up, centered at the origin), scaled to fit the part's world
|
||||||
|
* `width` x `height` box. Flips the y-axis, centers the result, and normalizes
|
||||||
|
* winding so the outline is counter-clockwise and holes are clockwise (as
|
||||||
|
* `@tts/mesh` expects).
|
||||||
|
*/
|
||||||
|
export function traceToShape(
|
||||||
|
trace: { shape: { outline: number[][]; holes?: number[][][] }; width: number; height: number },
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): Shape {
|
||||||
|
const { width: tw, height: th } = trace;
|
||||||
|
return meshTraceToShape(trace, Math.min(width / tw, height / th));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full image rectangle, in mesh coordinates, used as the UV framing so the
|
||||||
|
* texture aligns with a traced silhouette (which may be smaller than the image
|
||||||
|
* when there is transparent padding).
|
||||||
|
*/
|
||||||
|
export function traceToUvBounds(
|
||||||
|
trace: { width: number; height: number },
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): { minX: number; minY: number; maxX: number; maxY: number } {
|
||||||
|
return meshTraceToUvBounds(trace, Math.min(width / trace.width, height / trace.height));
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* `PartView` — render a bgm `Part` definition as a 3D mesh.
|
||||||
|
*
|
||||||
|
* Builds the mesh from the part's definition using `@tts/mesh` geometry:
|
||||||
|
* - `size` → world dimensions; `fillet` → corner radius.
|
||||||
|
* - `face`/`faceCrop`/`back`/`backCrop` → textures with sprite UVs.
|
||||||
|
* - `shape` → traced silhouette (via the proxy `/trace`); otherwise a
|
||||||
|
* rect/rounded-rect fallback.
|
||||||
|
*
|
||||||
|
* A standalone component library: it reuses `@tts/mesh` geometry, not the web
|
||||||
|
* app's viewers. The trace endpoint and asset proxy are the only coupling to
|
||||||
|
* the host app's HTTP surface.
|
||||||
|
*/
|
||||||
|
import { useTexture } from '@react-three/drei';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Part } from '@tts/bgm';
|
||||||
|
import { extrudeShapeParts, type ExtrudedGeometry, type UVBounds } from '@tts/mesh';
|
||||||
|
import {
|
||||||
|
fallbackShape,
|
||||||
|
partDimensions,
|
||||||
|
spriteUvFromCrop,
|
||||||
|
traceToShape,
|
||||||
|
traceToUvBounds,
|
||||||
|
} from './part.js';
|
||||||
|
import { resolveAssetUrl } from './http.js';
|
||||||
|
import { useTabletopHttp, type TraceImageFn } from './provider.js';
|
||||||
|
|
||||||
|
interface TraceData {
|
||||||
|
shape: { outline: number[][]; holes?: number[][][] };
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache traces by URL so Suspense doesn't re-issue the request on every render.
|
||||||
|
// A URL maps to either a pending promise (while loading) or the resolved value.
|
||||||
|
const traceCache = new Map<string, TraceData | null | 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). Throws the cached promise only while
|
||||||
|
* it's pending; once resolved, the value is returned directly.
|
||||||
|
*/
|
||||||
|
export function useTrace(url: string | undefined, traceImage: TraceImageFn): TraceData | null {
|
||||||
|
if (!url) return null;
|
||||||
|
const cached = traceCache.get(url);
|
||||||
|
if (cached === undefined) {
|
||||||
|
const promise = traceImage(url, 'alpha', -2).then((result) => {
|
||||||
|
const value: TraceData | null = result.shape
|
||||||
|
? { shape: result.shape, width: result.width, height: result.height }
|
||||||
|
: null;
|
||||||
|
traceCache.set(url, value);
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
traceCache.set(url, promise);
|
||||||
|
throw promise;
|
||||||
|
}
|
||||||
|
if (cached instanceof Promise) throw cached;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A 1x1 transparent placeholder so `useTexture` always receives a URL. */
|
||||||
|
const FALLBACK_URL =
|
||||||
|
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a single part as a mesh. `PartView` is a canvas-internal mesh
|
||||||
|
* component — wrap it (or its containing `<Canvas>`) in `ErrorBoundary` to
|
||||||
|
* isolate render failures, since an HTML fallback can't render inside a canvas.
|
||||||
|
* `baseUrl` resolves relative asset paths against the part's source file
|
||||||
|
* directory (see `resolveAssetUrl`).
|
||||||
|
*/
|
||||||
|
export function PartView({ part, baseUrl }: { part: Part; baseUrl?: string }) {
|
||||||
|
return <PartMesh part={part} baseUrl={baseUrl} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The part mesh, exported for composition into a shared scene. */
|
||||||
|
export function PartMesh({ part, baseUrl }: { part: Part; baseUrl?: string }) {
|
||||||
|
const { assetUrl, traceImage } = useTabletopHttp();
|
||||||
|
const { width, height, depth } = partDimensions(part);
|
||||||
|
const faceUrl = part.face ? resolveAssetUrl(part.face, baseUrl) : undefined;
|
||||||
|
const backUrl = part.back ? resolveAssetUrl(part.back, baseUrl) : undefined;
|
||||||
|
const shapeUrl = part.shape ? resolveAssetUrl(part.shape, baseUrl) : undefined;
|
||||||
|
|
||||||
|
// Always call both hooks so the hook count is stable across renders.
|
||||||
|
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
|
||||||
|
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
|
||||||
|
|
||||||
|
const trace = useTrace(shapeUrl, traceImage);
|
||||||
|
|
||||||
|
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
|
||||||
|
const shape = trace ? traceToShape(trace, width, height) : fallbackShape(part, width, height);
|
||||||
|
const uvBounds: UVBounds | undefined = trace ? traceToUvBounds(trace, width, height) : undefined;
|
||||||
|
const parts = extrudeShapeParts(shape, { height: depth, uvBounds });
|
||||||
|
const key = `part:${part.type}#${part.id}:${width}:${height}:${depth}`;
|
||||||
|
return {
|
||||||
|
frontGeo: toGeometry(parts.front, key + ':front'),
|
||||||
|
backGeo: toGeometry(parts.back, key + ':back'),
|
||||||
|
wallsGeo: toGeometry(parts.walls, key + ':walls'),
|
||||||
|
};
|
||||||
|
}, [part, trace, width, height, depth]);
|
||||||
|
|
||||||
|
// Face texture: the sprite cell from the sheet (or the full image when there
|
||||||
|
// is no crop). Cloned so the sprite offset/repeat don't leak into other parts
|
||||||
|
// that share the same sheet URL.
|
||||||
|
const faceMap = useMemo(() => {
|
||||||
|
if (!faceUrl) return null;
|
||||||
|
const tex = face.clone();
|
||||||
|
const { repeatX, repeatY, offsetX, offsetY } = spriteUvFromCrop(part.faceCrop);
|
||||||
|
tex.repeat.set(repeatX, repeatY);
|
||||||
|
tex.offset.set(offsetX, offsetY);
|
||||||
|
return tex;
|
||||||
|
}, [faceUrl, face, part.faceCrop]);
|
||||||
|
|
||||||
|
// Back texture: the sprite cell (or full image), flipped 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 } = spriteUvFromCrop(part.backCrop);
|
||||||
|
tex.repeat.set(repeatX, repeatY);
|
||||||
|
tex.offset.set(offsetX, offsetY);
|
||||||
|
tex.repeat.x = -tex.repeat.x;
|
||||||
|
tex.offset.x -= tex.repeat.x;
|
||||||
|
return tex;
|
||||||
|
}, [backUrl, back, part.backCrop]);
|
||||||
|
|
||||||
|
const wallColor = new THREE.Color('#ffffff');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh geometry={frontGeo}>
|
||||||
|
<meshStandardMaterial color={faceMap ? '#ffffff' : '#52525b'} map={faceMap ?? undefined} roughness={0.6} />
|
||||||
|
</mesh>
|
||||||
|
<mesh geometry={backGeo}>
|
||||||
|
<meshStandardMaterial color={backMap ? '#ffffff' : '#52525b'} map={backMap ?? undefined} roughness={0.6} />
|
||||||
|
</mesh>
|
||||||
|
<mesh geometry={wallsGeo}>
|
||||||
|
<meshStandardMaterial color={wallColor} roughness={0.6} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared geometry cache so repeated parts reuse buffers.
|
||||||
|
const geometryCache = new Map<string, THREE.BufferGeometry>();
|
||||||
|
|
||||||
|
/** Convert raw extruded arrays into a cached three.js `BufferGeometry`. */
|
||||||
|
function toGeometry(extruded: ExtrudedGeometry, key: string): THREE.BufferGeometry {
|
||||||
|
const cached = geometryCache.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
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));
|
||||||
|
geometryCache.set(key, geo);
|
||||||
|
return geo;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
|
import { assetUrl as defaultAssetUrl, traceImage as defaultTraceImage, type TraceResult } from './http.js';
|
||||||
|
|
||||||
|
/** Build a proxy URL for an external asset (texture, model, etc.). */
|
||||||
|
export type AssetUrlFn = (url: string) => string;
|
||||||
|
|
||||||
|
/** Trace an image into a vector shape via the proxy. */
|
||||||
|
export type TraceImageFn = (
|
||||||
|
url: string,
|
||||||
|
mode?: 'alpha' | 'bw' | 'color',
|
||||||
|
offset?: number,
|
||||||
|
) => Promise<TraceResult>;
|
||||||
|
|
||||||
|
export interface TabletopHttp {
|
||||||
|
/** Route an asset URL through the host's proxy. */
|
||||||
|
assetUrl: AssetUrlFn;
|
||||||
|
/** Trace an image into a shape via the host's proxy. */
|
||||||
|
traceImage: TraceImageFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultHttp: TabletopHttp = {
|
||||||
|
assetUrl: defaultAssetUrl,
|
||||||
|
traceImage: defaultTraceImage,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TabletopHttpContext = createContext<TabletopHttp>(defaultHttp);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide the HTTP handlers the tabletop library uses to reach the host's
|
||||||
|
* proxy (`/asset` and `/trace`). The library defaults to its own handlers that
|
||||||
|
* hit the conventional proxy paths; a host app can override them (e.g. to point
|
||||||
|
* at a different base URL or inject auth).
|
||||||
|
*/
|
||||||
|
export function TabletopProvider({
|
||||||
|
http = defaultHttp,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
http?: TabletopHttp;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return <TabletopHttpContext.Provider value={http}>{children}</TabletopHttpContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The active HTTP handlers for the current subtree. */
|
||||||
|
export function useTabletopHttp(): TabletopHttp {
|
||||||
|
return useContext(TabletopHttpContext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+65
@@ -87,12 +87,18 @@ importers:
|
|||||||
'@tts/extract':
|
'@tts/extract':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/extract
|
version: link:../../packages/extract
|
||||||
|
'@tts/http':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/http
|
||||||
'@tts/mesh':
|
'@tts/mesh':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/mesh
|
version: link:../../packages/mesh
|
||||||
'@tts/shared':
|
'@tts/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
|
'@tts/tabletop':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/tabletop
|
||||||
bson:
|
bson:
|
||||||
specifier: ^7.3.1
|
specifier: ^7.3.1
|
||||||
version: 7.3.1
|
version: 7.3.1
|
||||||
@@ -187,6 +193,22 @@ importers:
|
|||||||
specifier: ^5.7.2
|
specifier: ^5.7.2
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages/http:
|
||||||
|
dependencies:
|
||||||
|
'@tts/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../shared
|
||||||
|
bson:
|
||||||
|
specifier: ^7.3.1
|
||||||
|
version: 7.3.1
|
||||||
|
devDependencies:
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.2
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^4.1.10
|
||||||
|
version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
|
||||||
|
|
||||||
packages/mesh:
|
packages/mesh:
|
||||||
dependencies:
|
dependencies:
|
||||||
earcut:
|
earcut:
|
||||||
@@ -216,6 +238,49 @@ importers:
|
|||||||
specifier: ^5.7.2
|
specifier: ^5.7.2
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages/tabletop:
|
||||||
|
dependencies:
|
||||||
|
'@react-three/drei':
|
||||||
|
specifier: ^10.7.8
|
||||||
|
version: 10.7.8(@react-three/fiber@9.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.185.1))(@types/react@19.2.18)(@types/three@0.185.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.185.1)
|
||||||
|
'@react-three/fiber':
|
||||||
|
specifier: ^9.7.0
|
||||||
|
version: 9.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.185.1)
|
||||||
|
'@react-three/postprocessing':
|
||||||
|
specifier: ^3.0.4
|
||||||
|
version: 3.0.4(@react-three/fiber@9.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.185.1))(@types/three@0.185.4)(react@19.2.8)(three@0.185.1)
|
||||||
|
'@tts/bgm':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../bgm
|
||||||
|
'@tts/http':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../http
|
||||||
|
'@tts/mesh':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../mesh
|
||||||
|
react:
|
||||||
|
specifier: ^19.2.8
|
||||||
|
version: 19.2.8
|
||||||
|
three:
|
||||||
|
specifier: ^0.185.1
|
||||||
|
version: 0.185.1
|
||||||
|
zustand:
|
||||||
|
specifier: ^5.0.14
|
||||||
|
version: 5.0.14(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))
|
||||||
|
devDependencies:
|
||||||
|
'@types/react':
|
||||||
|
specifier: ^19.2.18
|
||||||
|
version: 19.2.18
|
||||||
|
'@types/three':
|
||||||
|
specifier: ^0.185.4
|
||||||
|
version: 0.185.4
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.2
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^4.1.10
|
||||||
|
version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
|
||||||
|
|
||||||
packages/tts:
|
packages/tts:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tts/shared':
|
'@tts/shared':
|
||||||
|
|||||||
Reference in New Issue
Block a user