Compare commits
108
Commits
21fc1b29c1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddf6785f9e | ||
|
|
d0e4269ad6 | ||
|
|
999b7a0771 | ||
|
|
8eff44712f | ||
|
|
3813287489 | ||
|
|
71ae91960f | ||
|
|
addfc03ab6 | ||
|
|
211c151971 | ||
|
|
345832e389 | ||
|
|
cf8ca07850 | ||
|
|
498cf1b633 | ||
|
|
a9297d8b2b | ||
|
|
7d9b5e49ad | ||
|
|
3dd3db5643 | ||
|
|
fc9f756d52 | ||
|
|
7ff8e3c51d | ||
|
|
82a4f9b645 | ||
|
|
82d5a36497 | ||
|
|
699b89e916 | ||
|
|
0d35ad56f2 | ||
|
|
f01bb5f99b | ||
|
|
8ebb9211c5 | ||
|
|
a3095ead38 | ||
|
|
30ef76632f | ||
|
|
7163451d1f | ||
|
|
2430be9661 | ||
|
|
002bcb324b | ||
|
|
c665212209 | ||
|
|
2403abc07b | ||
|
|
ee7e73c798 | ||
|
|
dd08f901ae | ||
|
|
8bd186df54 | ||
|
|
81c115cb4d | ||
|
|
3167d26bd6 | ||
|
|
91cd3a16d7 | ||
|
|
13b3eaed78 | ||
|
|
23332ab786 | ||
|
|
aab0b66ee8 | ||
|
|
c41c266ac1 | ||
|
|
634a99dd25 | ||
|
|
9b5223686e | ||
|
|
3aa48058f7 | ||
|
|
812b4640e2 | ||
|
|
9d776771b1 | ||
|
|
d663f4afea | ||
|
|
aa23e93b3f | ||
|
|
77e0751554 | ||
|
|
dc721d5c2d | ||
|
|
96c299e498 | ||
|
|
c2693277ac | ||
|
|
bc418b2c73 | ||
|
|
6502fdae4f | ||
|
|
45bf362fbc | ||
|
|
82caa9bb9c | ||
|
|
dfe49bad0c | ||
|
|
2001616d9e | ||
|
|
77ffd8ff00 | ||
|
|
5a3a9c1fcc | ||
|
|
50c6df48b5 | ||
|
|
f12b40e82b | ||
|
|
8d0e393100 | ||
|
|
43c6334413 | ||
|
|
b312bf4f1f | ||
|
|
90baa35c7c | ||
|
|
c5d6dff12d | ||
|
|
2da04940c2 | ||
|
|
4727a00e26 | ||
|
|
5808e15c45 | ||
|
|
abf901418b | ||
|
|
d9d38c2bee | ||
|
|
f494a6f9be | ||
|
|
cd08e6af04 | ||
|
|
ef0695ed04 | ||
|
|
15c45e7106 | ||
|
|
b4cdcdeb42 | ||
|
|
87e6eee9fe | ||
|
|
521519ce3d | ||
|
|
eefce5487d | ||
|
|
4d14120d54 | ||
|
|
91d4b6c999 | ||
|
|
0503ac84a7 | ||
|
|
43b69133cc | ||
|
|
599fb6a76d | ||
|
|
4e18ab5bc8 | ||
|
|
64c8bb60b3 | ||
|
|
c86d7444b8 | ||
|
|
7f3f758c52 | ||
|
|
845d510948 | ||
|
|
ba16f17d8a | ||
|
|
c0967ab71f | ||
|
|
1cfec40c99 | ||
|
|
664079528c | ||
|
|
82a81c9c1a | ||
|
|
a6e9d2dd07 | ||
|
|
dcba0ac4bd | ||
|
|
c3c7fe2445 | ||
|
|
c04d99fe38 | ||
|
|
e88dd03fac | ||
|
|
12418898d0 | ||
|
|
ead2ba8c57 | ||
|
|
b6bd4612e0 | ||
|
|
55e0351c1d | ||
|
|
835250abdd | ||
|
|
f7139495fe | ||
|
|
a2f7c998fc | ||
|
|
9094ad58e0 | ||
|
|
c7f4bd03fd | ||
|
|
a4759be5ac |
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: commit-conventions
|
||||||
|
description: How to write git commits for this repository. Use when committing changes, writing commit messages, or splitting work into commits. Covers conventional commit format and separating commits by concern.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Commit Conventions
|
||||||
|
|
||||||
|
Follow these rules whenever you create a commit in this repository.
|
||||||
|
|
||||||
|
## Conventional Commits
|
||||||
|
|
||||||
|
Write every commit message using the [Conventional Commits](https://www.conventionalcommits.org/) format:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <subject>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **type**: `feat`, `fix`, `chore`, `docs`, `refactor`, `perf`, `test`, `build`, `ci`, `style`.
|
||||||
|
- **scope** (optional): the area of the codebase the change touches. Use the workspace package or directory name when it's clear (e.g. `proxy`, `web`, `packages`). Omit when the change spans multiple areas.
|
||||||
|
- **subject**: imperative mood, capitalized, no trailing period, ≤ 50 characters.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(proxy): add TTS streaming endpoint
|
||||||
|
fix(web): handle empty transcript in player
|
||||||
|
docs: document workspace layout
|
||||||
|
```
|
||||||
|
|
||||||
|
## Separate Commits by Concern
|
||||||
|
|
||||||
|
Do not bundle unrelated changes into a single commit. Split work so each commit is a focused, self-contained unit:
|
||||||
|
|
||||||
|
- One logical change per commit (a feature, a fix, a refactor, a doc update).
|
||||||
|
- Keep each commit buildable and independently reviewable.
|
||||||
|
- Separate concerns that have different types, scopes, or reasons to be reverted independently.
|
||||||
|
- If a change is large, split it into a series of smaller commits that each stand on their own.
|
||||||
|
|
||||||
|
## Message Body
|
||||||
|
|
||||||
|
Include a body only when it adds useful context beyond the subject. If the subject fully captures the change, omit it.
|
||||||
|
|
||||||
|
- Separate the subject from the body with a blank line.
|
||||||
|
- Wrap the body at 72 characters.
|
||||||
|
- Explain the *why* and *what* rather than restating the code.
|
||||||
|
- Do not repeat information already in the subject line.
|
||||||
|
- Use `BREAKING CHANGE:` in the body (or `!` after the type/scope) for breaking changes.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Activate this skill when:
|
||||||
|
|
||||||
|
- Creating a new commit.
|
||||||
|
- Writing or revising a commit message.
|
||||||
|
- Deciding how to split staged or unstaged changes into commits.
|
||||||
@@ -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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
coverage/
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
@@ -7,14 +7,16 @@ analyze their contents. A lightweight, client-only pnpm monorepo.
|
|||||||
|
|
||||||
| Package | Role | Runtime |
|
| Package | Role | Runtime |
|
||||||
| ------------------- | ------------------------------------------------------ | ---------- |
|
| ------------------- | ------------------------------------------------------ | ---------- |
|
||||||
| `apps/web` | React frontend: search + mod pages | Browser |
|
| `apps/web` | React frontend: search + mod pages + 3D object viewers | Browser |
|
||||||
| `apps/proxy` | Hono HTTP server: Workshop search + save fetch | Node |
|
| `apps/proxy` | Hono HTTP server: Workshop search, save fetch, tracing | 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, asset refs, downloads | Isomorphic |
|
| `packages/extract` | Analyze a `TTSMod`: objects, asset refs, downloads | 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 |
|
||||||
|
|
||||||
See [`docs/architecture.md`](docs/architecture.md) for the architecture and
|
See [`docs/overview.md`](docs/overview.md) for the docs index,
|
||||||
[`docs/implementation-plan.md`](docs/implementation-plan.md) for the plan.
|
[`docs/architecture.md`](docs/architecture.md) for the architecture, and
|
||||||
|
[`docs/status/implementation-plan.md`](docs/status/implementation-plan.md) for the plan.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ Get a Steam Web API key at https://steamcommunity.com/dev/apikey (free).
|
|||||||
|
|
||||||
To run the frontend alongside the proxy, open a second terminal and run
|
To run the frontend alongside the proxy, open a second terminal and run
|
||||||
`pnpm --filter @tts/web dev` (serves at http://localhost:5173 and proxies
|
`pnpm --filter @tts/web dev` (serves at http://localhost:5173 and proxies
|
||||||
`/search`, `/items`, and `/health` to the backend).
|
`/search`, `/items`, `/health`, `/asset`, and `/trace` to the backend).
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
@@ -37,7 +39,9 @@ To run the frontend alongside the proxy, open a second terminal and run
|
|||||||
| GET | `/health` | Liveness |
|
| GET | `/health` | Liveness |
|
||||||
| 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 the URL path |
|
||||||
|
| GET | `/asset?url=` | CORS-safe proxy for external assets (textures, models) |
|
||||||
|
| GET | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON); `offset` insets/outsets in pixels |
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -50,6 +54,24 @@ pnpm test # run the unit tests (vitest)
|
|||||||
pnpm lint # lint all packages
|
pnpm lint # lint all packages
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Object viewers
|
||||||
|
|
||||||
|
The mod page renders each selected object in 3D. Tiles, tokens, cards, and
|
||||||
|
custom models each have a viewer built on `@react-three/fiber`, `@react-three/drei`,
|
||||||
|
and `@react-three/postprocessing`, registered per object class and lazy-loaded
|
||||||
|
so the three.js stack is code-split out of the main bundle.
|
||||||
|
|
||||||
|
- **Tiles / tokens** — extruded from a 2D shape; tokens trace the image's alpha
|
||||||
|
channel via `/trace` to match the artwork's silhouette.
|
||||||
|
- **Cards** — a thin rounded rect. Deck images are sheets divided into a
|
||||||
|
`NumWidth` x `NumHeight` grid; the face/back sprite is selected by `CardID`
|
||||||
|
from the containing deck's config.
|
||||||
|
- **Custom models** — GLTF/OBJ/FBX loaded from `CustomMesh.MeshURL`.
|
||||||
|
|
||||||
|
The camera fits the object's bounds on load, and back faces are flipped so
|
||||||
|
they aren't mirrored. See [`docs/decisions.md`](docs/decisions.md) for the
|
||||||
|
rationale behind these choices.
|
||||||
|
|
||||||
## How search works
|
## How search works
|
||||||
|
|
||||||
Steam has no official search API. The proxy fetches the Workshop browse page
|
Steam has no official search API. The proxy fetches the Workshop browse page
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,15 +1,26 @@
|
|||||||
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';
|
||||||
|
import pdf from './routes/pdf.js';
|
||||||
import search from './routes/search.js';
|
import search from './routes/search.js';
|
||||||
import trace from './routes/trace.js';
|
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());
|
||||||
@@ -17,6 +28,7 @@ app.route('/health', health);
|
|||||||
app.route('/search', search);
|
app.route('/search', search);
|
||||||
app.route('/items', items);
|
app.route('/items', items);
|
||||||
app.route('/asset', asset);
|
app.route('/asset', asset);
|
||||||
|
app.route('/pdf', pdf);
|
||||||
app.route('/trace', trace);
|
app.route('/trace', trace);
|
||||||
|
|
||||||
serve(
|
serve(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { ItemNotFoundError, SteamApiError, TtsError } from '@tts/tts';
|
import { ItemNotFoundError, SteamApiError, TtsError } from '@tts/tts';
|
||||||
import type { TTSMod } from '@tts/shared';
|
import type { ModDetails } from '@tts/shared';
|
||||||
import items from './items.js';
|
import items from './items.js';
|
||||||
|
|
||||||
const env = { STEAM_API_KEY: 'test-key', PORT: 3000 };
|
const env = { STEAM_API_KEY: 'test-key', PORT: 3000 };
|
||||||
|
|
||||||
|
const mod = {
|
||||||
|
GameMode: 'Tabletop',
|
||||||
|
Date: '2024-01-01',
|
||||||
|
ObjectStates: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const details: ModDetails = { mod };
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
@@ -17,7 +25,7 @@ describe('items route', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns 500 when the API key is missing and no fileUrl is given', async () => {
|
it('returns 500 when the API key is missing and no fileUrl is given', async () => {
|
||||||
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
|
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
|
||||||
new TtsError('STEAM_API_KEY is not configured', 500),
|
new TtsError('STEAM_API_KEY is not configured', 500),
|
||||||
);
|
);
|
||||||
const res = await items.request('/123', {}, { STEAM_API_KEY: '', PORT: 3000 });
|
const res = await items.request('/123', {}, { STEAM_API_KEY: '', PORT: 3000 });
|
||||||
@@ -26,11 +34,6 @@ describe('items route', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('loads from a fileUrl without an API key', async () => {
|
it('loads from a fileUrl without an API key', async () => {
|
||||||
const mod: TTSMod = {
|
|
||||||
GameMode: 'Tabletop',
|
|
||||||
Date: '2024-01-01',
|
|
||||||
ObjectStates: [],
|
|
||||||
};
|
|
||||||
const fetchModFromUrl = vi
|
const fetchModFromUrl = vi
|
||||||
.spyOn(await import('@tts/tts'), 'fetchModFromUrl')
|
.spyOn(await import('@tts/tts'), 'fetchModFromUrl')
|
||||||
.mockResolvedValue(mod);
|
.mockResolvedValue(mod);
|
||||||
@@ -40,26 +43,26 @@ describe('items route', () => {
|
|||||||
{ STEAM_API_KEY: '', PORT: 3000 },
|
{ STEAM_API_KEY: '', PORT: 3000 },
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.json()).toEqual(mod);
|
expect(await res.json()).toEqual(details);
|
||||||
expect(fetchModFromUrl).toHaveBeenCalledWith('https://example.com/save.json');
|
expect(fetchModFromUrl).toHaveBeenCalledWith('https://example.com/save.json');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the parsed mod on success', async () => {
|
it('returns the parsed mod with metadata on success', async () => {
|
||||||
const mod: TTSMod = {
|
const withMeta: ModDetails = {
|
||||||
GameMode: 'Tabletop',
|
mod,
|
||||||
Date: '2024-01-01',
|
title: 'My Mod',
|
||||||
ObjectStates: [],
|
previewImageUrl: 'https://example.com/preview.png',
|
||||||
};
|
};
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}')));
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}')));
|
||||||
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockResolvedValue(mod);
|
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockResolvedValue(withMeta);
|
||||||
|
|
||||||
const res = await items.request('/123', {}, env);
|
const res = await items.request('/123', {}, env);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.json()).toEqual(mod);
|
expect(await res.json()).toEqual(withMeta);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps TtsError subclasses to their status', async () => {
|
it('maps TtsError subclasses to their status', async () => {
|
||||||
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
|
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
|
||||||
new ItemNotFoundError('123'),
|
new ItemNotFoundError('123'),
|
||||||
);
|
);
|
||||||
const res = await items.request('/123', {}, env);
|
const res = await items.request('/123', {}, env);
|
||||||
@@ -70,7 +73,7 @@ describe('items route', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns 500 for unexpected errors', async () => {
|
it('returns 500 for unexpected errors', async () => {
|
||||||
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
|
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
|
||||||
new Error('boom'),
|
new Error('boom'),
|
||||||
);
|
);
|
||||||
const res = await items.request('/123', {}, env);
|
const res = await items.request('/123', {}, env);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { itemIdSchema, type TTSMod } from '@tts/shared';
|
import { itemIdSchema, type ModDetails } from '@tts/shared';
|
||||||
import {
|
import {
|
||||||
fetchMod,
|
fetchModDetails,
|
||||||
fetchModFile,
|
fetchModFile,
|
||||||
fetchModFileFromUrl,
|
fetchModFileFromUrl,
|
||||||
fetchModFromUrl,
|
fetchModFromUrl,
|
||||||
@@ -20,11 +20,12 @@ app.get('/:id', async (c) => {
|
|||||||
try {
|
try {
|
||||||
const fileUrl = c.req.query('fileUrl');
|
const fileUrl = c.req.query('fileUrl');
|
||||||
// With no Steam API key, the caller must supply the save URL (e.g. from a
|
// With no Steam API key, the caller must supply the save URL (e.g. from a
|
||||||
// search result). Otherwise resolve it via the Steam API.
|
// search result). Otherwise resolve it via the Steam API, which also
|
||||||
const mod: TTSMod = fileUrl
|
// yields the Workshop title and preview image.
|
||||||
? await fetchModFromUrl(fileUrl)
|
const details: ModDetails = fileUrl
|
||||||
: await fetchMod(id, c.env.STEAM_API_KEY ?? '');
|
? { mod: await fetchModFromUrl(fileUrl) }
|
||||||
return c.json<TTSMod>(mod);
|
: await fetchModDetails(id, c.env.STEAM_API_KEY ?? '');
|
||||||
|
return c.json<ModDetails>(details);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TtsError) {
|
if (err instanceof TtsError) {
|
||||||
return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502);
|
return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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 pdf from './pdf.js';
|
||||||
|
import { setGamesRoot } from '../config.js';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(path.join(tmpdir(), 'games-'));
|
||||||
|
setGamesRoot(dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
setGamesRoot(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pdf route', () => {
|
||||||
|
it('rejects a missing url', async () => {
|
||||||
|
const res = await pdf.request('/');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(await res.json()).toEqual({ error: 'Missing url query param' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-http url', async () => {
|
||||||
|
const res = await pdf.request('/?url=file%3A%2F%2F%2Fetc%2Fpasswd');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streams the fetched pdf inline with a pdf content-type', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
new Response(new Uint8Array([1, 2, 3]), {
|
||||||
|
headers: { 'content-type': 'application/pdf' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const res = await pdf.request('/?url=https%3A%2F%2Fexample.com%2Fa.pdf');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toBe('application/pdf');
|
||||||
|
expect(res.headers.get('content-disposition')).toBe('inline');
|
||||||
|
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 502 when the upstream fetch fails', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(new Response('error', { status: 500 })),
|
||||||
|
);
|
||||||
|
const res = await pdf.request('/?url=https%3A%2F%2Fexample.com%2Fa.pdf');
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves a local game asset from GAMES_ROOT', async () => {
|
||||||
|
writeFileSync(path.join(dir, 'rules.pdf'), new Uint8Array([1, 2, 3]));
|
||||||
|
const res = await pdf.request('/?url=rules.pdf');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toBe('application/pdf');
|
||||||
|
expect(res.headers.get('content-disposition')).toBe('inline');
|
||||||
|
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { GAMES_ROOT } from '../config.js';
|
||||||
|
import { resolveAsset } from './resolveAsset.js';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a PDF and stream it back inline so the browser renders it instead of
|
||||||
|
* downloading. PDF hosts often send `Content-Disposition: attachment`, which
|
||||||
|
* forces a download even when the URL is opened in a tab or iframe. This route
|
||||||
|
* strips that header and forces `inline` so the PDF can be embedded in the web
|
||||||
|
* app's viewer. Like `/asset`, a relative URL (no scheme) is treated as a game
|
||||||
|
* asset path relative to `GAMES_ROOT`.
|
||||||
|
*/
|
||||||
|
app.get('/', async (c) => {
|
||||||
|
const raw = c.req.query('url');
|
||||||
|
if (!raw) {
|
||||||
|
return c.json({ error: 'Missing url query param' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await resolveAsset(raw, GAMES_ROOT);
|
||||||
|
if (!result.ok) {
|
||||||
|
return c.json(
|
||||||
|
{ error: result.reason === 'not-found' ? 'Asset not found' : 'Invalid url query param' },
|
||||||
|
result.reason === 'not-found' ? 404 : 400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const asset = result.asset;
|
||||||
|
|
||||||
|
// A local game asset is streamed from disk; a remote one is fetched.
|
||||||
|
let body: BodyInit;
|
||||||
|
if (asset.stream) {
|
||||||
|
body = asset.stream as unknown as BodyInit;
|
||||||
|
} else {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(asset.url!);
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ error: `Failed to fetch pdf: ${String(err)}` }, 502);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
return c.json({ error: `Pdf responded ${res.status}` }, 502);
|
||||||
|
}
|
||||||
|
body = res.body!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': 'inline',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -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' } };
|
||||||
|
}
|
||||||
@@ -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,9 @@ 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';
|
||||||
|
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`.
|
||||||
@@ -36,21 +38,32 @@ 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.
|
// 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);
|
||||||
}
|
}
|
||||||
@@ -95,7 +108,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,11 @@
|
|||||||
"@tts/extract": "workspace:*",
|
"@tts/extract": "workspace:*",
|
||||||
"@tts/mesh": "workspace:*",
|
"@tts/mesh": "workspace:*",
|
||||||
"@tts/shared": "workspace:*",
|
"@tts/shared": "workspace:*",
|
||||||
|
"@tts/bgm": "workspace:*",
|
||||||
|
"@tts/http": "workspace:*",
|
||||||
|
"@tts/tabletop": "workspace:*",
|
||||||
|
"bson": "^7.3.1",
|
||||||
|
"postprocessing": "^6.36.6",
|
||||||
"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",
|
||||||
|
|||||||
+51
-3
@@ -1,11 +1,38 @@
|
|||||||
import { Link, Route, Routes } from 'react-router-dom';
|
import { lazy, Suspense } from 'react';
|
||||||
|
import { Link, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import SearchPage from './pages/SearchPage';
|
import SearchPage from './pages/SearchPage';
|
||||||
import ModPage from './pages/ModPage';
|
import ModPage from './pages/ModPage';
|
||||||
|
import ModHeader from './components/ModHeader';
|
||||||
|
|
||||||
|
// The full-setup view and the bgm/tabletop pages pull in the whole three.js
|
||||||
|
// stack (Scene + all mesh viewers + @tts/tabletop). Lazy-load them so that
|
||||||
|
// stack stays code-split out of the main bundle and is only fetched when one
|
||||||
|
// of those routes is actually visited.
|
||||||
|
const FullSetupPage = lazy(() => import('./pages/FullSetupPage'));
|
||||||
|
const BgmPage = lazy(() => import('./pages/BgmPage'));
|
||||||
|
const BgmPackagePage = lazy(() => import('./pages/BgmPackagePage'));
|
||||||
|
const PartsPage = lazy(() => import('./pages/PartsPage'));
|
||||||
|
const PartPage = lazy(() => import('./pages/PartPage'));
|
||||||
|
const SurfacesPage = lazy(() => import('./pages/SurfacesPage'));
|
||||||
|
const SurfacePage = lazy(() => import('./pages/SurfacePage'));
|
||||||
|
const SetupsPage = lazy(() => import('./pages/SetupsPage'));
|
||||||
|
const SetupPage = lazy(() => import('./pages/SetupPage'));
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const pathname = useLocation().pathname;
|
||||||
|
const isModRoute = pathname.startsWith('/mod/');
|
||||||
|
// The inspector view is full-bleed/viewport-height; the setup sub-route keeps the standard centered layout.
|
||||||
|
const isModView = /^\/mod\/[^/]+$/.test(pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-zinc-100">
|
<div className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||||
<header className="border-b border-zinc-800">
|
{/* For the mod route the layout is full-bleed and fills the viewport so
|
||||||
|
the sidebar can scroll independently and the viewer gets all the space. */}
|
||||||
|
<div className={"flex min-h-screen flex-col " + (isModView ? "h-screen" : "")}>
|
||||||
|
<header className={"border-b border-zinc-800 " + (isModRoute ? "shrink-0" : "")}>
|
||||||
|
{isModRoute ? (
|
||||||
|
<ModHeader />
|
||||||
|
) : (
|
||||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
|
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
|
||||||
<Link to="/" className="text-lg font-semibold tracking-tight">
|
<Link to="/" className="text-lg font-semibold tracking-tight">
|
||||||
TTS Workshop
|
TTS Workshop
|
||||||
@@ -14,15 +41,36 @@ export default function App() {
|
|||||||
<Link to="/" className="hover:text-zinc-100">
|
<Link to="/" className="hover:text-zinc-100">
|
||||||
Search
|
Search
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/bgm" className="hover:text-zinc-100">
|
||||||
|
BGM
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto max-w-5xl px-4 py-8">
|
<main className={(isModView ? "min-h-0 flex-1" : "mx-auto max-w-5xl px-4 py-8")}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<SearchPage />} />
|
<Route path="/" element={<SearchPage />} />
|
||||||
<Route path="/mod/:id" element={<ModPage />} />
|
<Route path="/mod/:id" element={<ModPage />} />
|
||||||
|
<Route
|
||||||
|
path="/mod/:id/setup"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<p className="text-sm text-zinc-400">Loading full setup…</p>}>
|
||||||
|
<FullSetupPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/bgm" element={<Suspense fallback={null}><BgmPage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id" element={<Suspense fallback={null}><BgmPackagePage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/parts" element={<Suspense fallback={null}><PartsPage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/parts/:type/:part" element={<Suspense fallback={null}><PartPage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/surfaces" element={<Suspense fallback={null}><SurfacesPage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/surfaces/:type/:surface" element={<Suspense fallback={null}><SurfacePage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/setups" element={<Suspense fallback={null}><SetupsPage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/setups/:type/:setup" element={<Suspense fallback={null}><SetupPage /></Suspense>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+9
-4
@@ -1,4 +1,7 @@
|
|||||||
import type { SearchResult, TTSMod } from '@tts/shared';
|
import type { ModDetails, SearchResult } from '@tts/shared';
|
||||||
|
import { traceImage } from '@tts/http';
|
||||||
|
|
||||||
|
export { traceImage };
|
||||||
|
|
||||||
const BASE = '';
|
const BASE = '';
|
||||||
|
|
||||||
@@ -17,12 +20,14 @@ export function searchWorkshop(q: string, page = 1): Promise<SearchResult> {
|
|||||||
return getJson<SearchResult>(`/search?${params.toString()}`);
|
return getJson<SearchResult>(`/search?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch a full parsed TTS save. */
|
/**
|
||||||
export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> {
|
* Fetch a full parsed TTS save plus its Workshop metadata.
|
||||||
|
*/
|
||||||
|
export function fetchMod(id: string, fileUrl?: string): Promise<ModDetails> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (fileUrl) params.set('fileUrl', fileUrl);
|
if (fileUrl) params.set('fileUrl', fileUrl);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return getJson<TTSMod>(`/items/${id}${qs ? `?${qs}` : ''}`);
|
return getJson<ModDetails>(`/items/${id}${qs ? `?${qs}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a URL for the raw save file download. */
|
/** Build a URL for the raw save file download. */
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export interface Crumb {
|
||||||
|
label: string;
|
||||||
|
/** Where the crumb links; omit for the current (last) crumb. */
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A breadcrumb navigation trail. The last crumb is the current page and is
|
||||||
|
* rendered as plain text; earlier crumbs link to their routes.
|
||||||
|
*/
|
||||||
|
export default function Breadcrumbs({ crumbs }: { crumbs: Crumb[] }) {
|
||||||
|
return (
|
||||||
|
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-sm text-zinc-400">
|
||||||
|
{crumbs.map((crumb, i) => {
|
||||||
|
const last = i === crumbs.length - 1;
|
||||||
|
return (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
{i > 0 && <span className="text-zinc-600">/</span>}
|
||||||
|
{crumb.to && !last ? (
|
||||||
|
<Link to={crumb.to} className="hover:text-zinc-100">
|
||||||
|
{crumb.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className={last ? 'text-zinc-100' : ''}>{crumb.label}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { buildTree, collectRefs } from '@tts/extract';
|
||||||
|
import { useModStore } from '../stores/modStore';
|
||||||
|
import { useSearchStore } from '../stores/searchStore';
|
||||||
|
import { modFileUrl } from '../api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the global site header on the mod page: thumbnail, game name, and
|
||||||
|
* a subheader with the mod id and stats, plus download/setup links styled like
|
||||||
|
* the Search/BGM nav.
|
||||||
|
*/
|
||||||
|
export default function ModHeader() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { mod } = useModStore();
|
||||||
|
const item = useSearchStore((s) => s.items.find((i) => i.id === id));
|
||||||
|
const tree = useMemo(() => (mod ? buildTree(mod.mod) : []), [mod]);
|
||||||
|
const refs = useMemo(() => (mod ? collectRefs(mod.mod) : []), [mod]);
|
||||||
|
// Prefer the metadata fetched with the save (survives a refresh); fall back
|
||||||
|
// to the search result for the brief moment before the save loads.
|
||||||
|
const title = mod?.title ?? item?.title;
|
||||||
|
const previewImageUrl = mod?.previewImageUrl ?? item?.previewImageUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
{previewImageUrl && (
|
||||||
|
<img
|
||||||
|
src={previewImageUrl}
|
||||||
|
alt={title}
|
||||||
|
className="h-12 w-12 shrink-0 rounded-lg border border-zinc-800 object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-lg font-semibold tracking-tight">
|
||||||
|
{title ?? `Mod ${id}`}
|
||||||
|
</h1>
|
||||||
|
{mod && (
|
||||||
|
<p className="truncate text-sm text-zinc-400">
|
||||||
|
<span className="font-mono">{id}</span> · {mod.mod.GameMode} ·{' '}
|
||||||
|
{mod.mod.Date} · {tree.length} objects · {refs.length} asset refs
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="flex shrink-0 gap-4 text-sm text-zinc-400">
|
||||||
|
<Link to="/" className="hover:text-zinc-100">
|
||||||
|
Search
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={modFileUrl(id!, item?.fileUrl)}
|
||||||
|
className="hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<Link to={`/mod/${id}/setup`} className="hover:text-zinc-100">
|
||||||
|
Setup
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +1,94 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import type { ObjectTreeNode } from '@tts/extract';
|
import type { ObjectTreeNode } from '@tts/extract';
|
||||||
import { iconsForObject } from './objectIcons';
|
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) {
|
||||||
|
const [highlighted, setHighlighted] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
|
const types = useMemo(() => collectTypes(nodes), [nodes]);
|
||||||
|
|
||||||
|
// When nothing is highlighted, every type is shown. Otherwise only the
|
||||||
|
// highlighted types are visible.
|
||||||
|
const visibleNodes = useMemo(
|
||||||
|
() => filterTree(nodes, highlighted),
|
||||||
|
[nodes, highlighted],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleType = (name: string) =>
|
||||||
|
setHighlighted((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(name)) next.delete(name);
|
||||||
|
else next.add(name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearAll = () => setHighlighted(new Set());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-0.5">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
{nodes.map((node, index) => (
|
{types.length > 0 && (
|
||||||
|
<div className="shrink-0 border-b border-zinc-800 p-3 pb-2">
|
||||||
|
<div className="grid grid-cols-6 gap-1">
|
||||||
|
{types.map(({ name, count }) => {
|
||||||
|
const active = highlighted.has(name);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
onClick={() => toggleType(name)}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={`${name} (${count}) — ${active ? 'unhighlight' : 'highlight'}`}
|
||||||
|
className={`relative flex h-8 items-center justify-center rounded-md border transition-colors ${
|
||||||
|
active
|
||||||
|
? 'border-zinc-300 bg-zinc-700 text-zinc-100'
|
||||||
|
: 'border-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{iconsForObject(name).map((icon) => (
|
||||||
|
<Icon key={icon} icon={icon} className="h-4 w-4" />
|
||||||
|
))}
|
||||||
|
<span
|
||||||
|
className={`absolute -bottom-1 -right-1 rounded bg-zinc-950 px-0.5 font-mono text-[9px] leading-tight ${
|
||||||
|
active ? 'text-zinc-200' : 'text-zinc-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{highlighted.size > 0 && (
|
||||||
|
<div className="mt-1.5 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className="inline-flex items-center rounded px-1.5 py-0.5 text-xs text-zinc-500 hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto p-3 pt-2">
|
||||||
|
{visibleNodes.map(({ node, path }) => (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
key={index}
|
key={path}
|
||||||
node={node}
|
node={node}
|
||||||
depth={0}
|
depth={0}
|
||||||
path={`${index}`}
|
path={path}
|
||||||
selectedGuid={selectedGuid}
|
selectedPath={selectedPath}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,17 +96,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 +138,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
|
||||||
@@ -81,6 +150,16 @@ function TreeNode({
|
|||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{node.label}</span>
|
<span className="truncate">{node.label}</span>
|
||||||
|
{hasChildren && (
|
||||||
|
<span
|
||||||
|
title={`${node.children.length} ${
|
||||||
|
node.children.length === 1 ? 'child' : 'children'
|
||||||
|
}`}
|
||||||
|
className="ml-1.5 shrink-0 rounded bg-zinc-800 px-1 font-mono text-[10px] leading-4 text-zinc-500"
|
||||||
|
>
|
||||||
|
{node.children.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{hasChildren && expanded && (
|
{hasChildren && expanded && (
|
||||||
@@ -91,7 +170,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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -100,3 +179,50 @@ function TreeNode({
|
|||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Distinct object types present in the tree, with a count of each. */
|
||||||
|
function collectTypes(nodes: ObjectTreeNode[]): { name: string; count: number }[] {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
const visit = (node: ObjectTreeNode) => {
|
||||||
|
counts.set(node.object.Name, (counts.get(node.object.Name) ?? 0) + 1);
|
||||||
|
node.children.forEach(visit);
|
||||||
|
};
|
||||||
|
nodes.forEach(visit);
|
||||||
|
return [...counts.entries()]
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep a node when its type is highlighted or any descendant survives, so the
|
||||||
|
* containment hierarchy is preserved and highlighted parents still lead to
|
||||||
|
* highlighted children. With nothing highlighted, every node is kept.
|
||||||
|
*
|
||||||
|
* Each result carries the node's original index path into the full (unfiltered)
|
||||||
|
* tree, so selection stays stable regardless of filtering.
|
||||||
|
*/
|
||||||
|
function filterTree(
|
||||||
|
nodes: ObjectTreeNode[],
|
||||||
|
highlighted: Set<string>,
|
||||||
|
prefix = '',
|
||||||
|
): { node: ObjectTreeNode; path: string }[] {
|
||||||
|
const result: { node: ObjectTreeNode; path: string }[] = [];
|
||||||
|
nodes.forEach((node, index) => {
|
||||||
|
const path = prefix ? `${prefix}-${index}` : `${index}`;
|
||||||
|
const children = filterTree(node.children, highlighted, path);
|
||||||
|
const visible =
|
||||||
|
highlighted.size === 0 ||
|
||||||
|
highlighted.has(node.object.Name) ||
|
||||||
|
children.length > 0;
|
||||||
|
if (visible) {
|
||||||
|
result.push({
|
||||||
|
node:
|
||||||
|
children.length > 0
|
||||||
|
? { ...node, children: children.map((c) => c.node) }
|
||||||
|
: node,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from './Breadcrumbs';
|
||||||
|
|
||||||
|
/** Shown when a bgm package id doesn't resolve to a known package. */
|
||||||
|
export default function PackageMissing({ id }: { id: string }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Breadcrumbs crumbs={[{ label: 'Board games', to: '/bgm' }, { label: id }]} />
|
||||||
|
<p className="text-sm text-zinc-400">No package “{id}”.</p>
|
||||||
|
<Link to="/bgm" className="text-sm text-zinc-300 underline">
|
||||||
|
Back to all packages
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Suspense, useMemo, useState } from 'react';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import type { SerializedPackage, Setup } from '@tts/bgm';
|
||||||
|
import {
|
||||||
|
SetupLoader,
|
||||||
|
WorldSurfaceView,
|
||||||
|
HudSurfaceView,
|
||||||
|
resolveMountTree,
|
||||||
|
serializedToPackage,
|
||||||
|
useTabletopStore,
|
||||||
|
MM_TO_WORLD,
|
||||||
|
} from '@tts/tabletop';
|
||||||
|
import Scene from '../viewers/Scene';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a bgm setup as an interactive 3D table.
|
||||||
|
*
|
||||||
|
* Seeds the tabletop store from the setup, resolves the surface mount tree,
|
||||||
|
* and renders each enabled surface — world surfaces in world space, HUD
|
||||||
|
* surfaces as an overlay — with their parts placed on routes. The store is
|
||||||
|
* seeded on every render so a setup change re-seeds it.
|
||||||
|
*/
|
||||||
|
export default function TabletopScene({
|
||||||
|
pkg,
|
||||||
|
setup,
|
||||||
|
}: {
|
||||||
|
pkg: SerializedPackage;
|
||||||
|
setup: Setup;
|
||||||
|
}) {
|
||||||
|
const packageData = useMemo(() => serializedToPackage(pkg), [pkg]);
|
||||||
|
const surfaces = useTabletopStore((s) => s.surfaces);
|
||||||
|
const [showSurface, setShowSurface] = useState(false);
|
||||||
|
|
||||||
|
const tree = useMemo(
|
||||||
|
() => resolveMountTree(packageData.surfaces, new Set(Object.keys(surfaces))),
|
||||||
|
[packageData, surfaces],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Size the contact shadow to cover 2x the largest enabled surface, so the
|
||||||
|
// shadow plane always extends past the table. Surface sizes are in mm;
|
||||||
|
// `MM_TO_WORLD` converts to world units.
|
||||||
|
const shadowScale = useMemo(() => {
|
||||||
|
let maxMm = 0;
|
||||||
|
for (const [id, enabled] of Object.entries(surfaces)) {
|
||||||
|
if (!enabled) continue;
|
||||||
|
const surface = packageData.surfaces.get(id);
|
||||||
|
if (!surface?.size) continue;
|
||||||
|
maxMm = Math.max(maxMm, ...surface.size);
|
||||||
|
}
|
||||||
|
return maxMm > 0 ? maxMm * 2 * MM_TO_WORLD : 22;
|
||||||
|
}, [packageData, surfaces]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Scene
|
||||||
|
autoRotate={false}
|
||||||
|
enablePan
|
||||||
|
fullscreen
|
||||||
|
shadowScale={shadowScale}
|
||||||
|
// Keep the camera above the table so face-down cards can't be peeked
|
||||||
|
// from below. π/2 clamps the polar angle at the horizon.
|
||||||
|
maxPolarAngle={Math.PI / 2}
|
||||||
|
overlay={
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSurface((v) => !v)}
|
||||||
|
title={showSurface ? 'Hide surfaces' : 'Show surfaces'}
|
||||||
|
aria-label={showSurface ? 'Hide surfaces' : 'Show surfaces'}
|
||||||
|
className="absolute right-2 top-10 z-10 flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={showSurface ? 'mdi:view-grid' : 'mdi:view-grid-outline'}
|
||||||
|
className="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SetupLoader pkg={packageData} setup={setup} />
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
{tree.world.map((node) => (
|
||||||
|
<WorldSurfaceView key={node.id} pkg={packageData} node={node} showSurface={showSurface} />
|
||||||
|
))}
|
||||||
|
{tree.hud.map((node) => (
|
||||||
|
<HudSurfaceView key={node.id} pkg={packageData} node={node} showSurface={showSurface} />
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
</Scene>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,14 @@ import type { TTSObject } from '@tts/shared';
|
|||||||
export interface ObjectViewer {
|
export interface ObjectViewer {
|
||||||
/** The object class this viewer handles, e.g. `Card`, `Bag`. */
|
/** The object class this viewer handles, e.g. `Card`, `Bag`. */
|
||||||
name: string;
|
name: string;
|
||||||
component: (props: { object: TTSObject }) => ReactNode;
|
component: (props: ViewerProps) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props passed to every object viewer. */
|
||||||
|
export interface ViewerProps {
|
||||||
|
object: TTSObject;
|
||||||
|
/** Expand the 3D scene to fill its container instead of the default frame. */
|
||||||
|
fill?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const registry = new Map<string, ObjectViewer['component']>();
|
const registry = new Map<string, ObjectViewer['component']>();
|
||||||
@@ -24,7 +31,7 @@ export function resolveViewer(object: TTSObject): ObjectViewer['component'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The default viewer: a plain inspection of the object's fields. */
|
/** The default viewer: a plain inspection of the object's fields. */
|
||||||
export function DefaultViewer({ object }: { object: TTSObject }) {
|
export function DefaultViewer({ object }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
||||||
{Object.entries(object).map(([key, value]) => {
|
{Object.entries(object).map(([key, value]) => {
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useTexture } from '@react-three/drei';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { TTSObject } from '@tts/shared';
|
||||||
|
import {
|
||||||
|
extrudeShapeParts,
|
||||||
|
roundedRectShape,
|
||||||
|
type ExtrudedGeometry,
|
||||||
|
} from '@tts/mesh';
|
||||||
|
import { assetUrl } from '@tts/http';
|
||||||
|
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
|
||||||
|
import { applyMapTransform } from './cardMaterial';
|
||||||
|
import {
|
||||||
|
getSharedGeometry,
|
||||||
|
getSharedMaterial,
|
||||||
|
objectTint,
|
||||||
|
tintKey,
|
||||||
|
tintedColor,
|
||||||
|
} from './sharedResources';
|
||||||
|
|
||||||
|
/** 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, as a fraction of its length (real cards are ~0.3%). */
|
||||||
|
const CARD_THICKNESS = 0.01;
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card mesh for an object, exported so the full-setup view can compose it
|
||||||
|
* into a shared scene.
|
||||||
|
*/
|
||||||
|
export function CardObjectMesh({ object }: { object: TTSObject }) {
|
||||||
|
const { cardId, faceUrl, backUrl, numWidth, numHeight, uniqueBack } =
|
||||||
|
resolveCardConfig(object);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardMesh
|
||||||
|
faceUrl={faceUrl}
|
||||||
|
backUrl={backUrl}
|
||||||
|
numWidth={numWidth}
|
||||||
|
numHeight={numHeight}
|
||||||
|
uniqueBack={uniqueBack}
|
||||||
|
cardId={cardId}
|
||||||
|
tint={objectTint(object)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
||||||
|
// Exported so the full-setup view can compose it into a shared scene.
|
||||||
|
export function CardMesh({
|
||||||
|
faceUrl,
|
||||||
|
backUrl,
|
||||||
|
numWidth,
|
||||||
|
numHeight,
|
||||||
|
uniqueBack,
|
||||||
|
cardId,
|
||||||
|
tint,
|
||||||
|
}: {
|
||||||
|
faceUrl?: string;
|
||||||
|
backUrl?: string;
|
||||||
|
numWidth?: number;
|
||||||
|
numHeight?: number;
|
||||||
|
uniqueBack: boolean;
|
||||||
|
cardId?: number;
|
||||||
|
tint: THREE.Color;
|
||||||
|
}) {
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Card art is sRGB-encoded. `TextureLoader` leaves `colorSpace` as
|
||||||
|
// `NoColorSpace`, which uploads the texture as linear and then double-decodes
|
||||||
|
// it in the shader, washing out contrast. Mark it sRGB so the GPU decodes it
|
||||||
|
// once, correctly. Clones (e.g. `flipTexture`) inherit this from the source.
|
||||||
|
face.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
back.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
|
// The face/back textures are shared (drei caches them by URL); each card's
|
||||||
|
// sprite cell is selected via a per-material UV transform injected into the
|
||||||
|
// shader, so no per-card texture clone (and no re-upload) is needed. The
|
||||||
|
// transform is baked into the material's shader, so it must be keyed into the
|
||||||
|
// shared-material cache to avoid mutating a material used by another card.
|
||||||
|
const faceMap = faceUrl ? face : null;
|
||||||
|
const backMap = backUrl ? back : null;
|
||||||
|
|
||||||
|
const tintK = tintKey(tint);
|
||||||
|
const faceUv = faceUrl ? spriteUv(cardId, numWidth, numHeight) : null;
|
||||||
|
const backUv = backUrl
|
||||||
|
? uniqueBack
|
||||||
|
? spriteUv(cardId, numWidth, numHeight)
|
||||||
|
: { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Key by URL + card id + tint: the URL disambiguates different sheets (and
|
||||||
|
// `CardCustom` objects, which have no `CardID`), the card id selects the
|
||||||
|
// sprite cell, and the tint bakes the per-object color in.
|
||||||
|
const faceMat = getSharedMaterial(`card-face:${faceUrl ?? 'none'}:${cardId ?? 'none'}:${tintK}`, {
|
||||||
|
color: tintedColor(faceMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
||||||
|
map: faceMap ?? undefined,
|
||||||
|
roughness: 0.6,
|
||||||
|
});
|
||||||
|
if (faceUv) {
|
||||||
|
applyMapTransform(faceMat, new THREE.Vector2(faceUv.repeatX, faceUv.repeatY), new THREE.Vector2(faceUv.offsetX, faceUv.offsetY));
|
||||||
|
}
|
||||||
|
|
||||||
|
const backMat = getSharedMaterial(`card-back:${backUrl ?? 'none'}:${cardId ?? 'none'}:${tintK}`, {
|
||||||
|
color: tintedColor(backMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
||||||
|
map: backMap ?? undefined,
|
||||||
|
roughness: 0.6,
|
||||||
|
});
|
||||||
|
if (backUv) {
|
||||||
|
// The back cap maps with the same planar UVs as the front, so mirror the
|
||||||
|
// sprite cell left/right to read correctly instead of appearing mirrored.
|
||||||
|
// Negating repeat.x and shifting offset.x by one repeat keeps the visible
|
||||||
|
// region in place while mirrored (see `flipTexture`).
|
||||||
|
applyMapTransform(
|
||||||
|
backMat,
|
||||||
|
new THREE.Vector2(-backUv.repeatX, backUv.repeatY),
|
||||||
|
new THREE.Vector2(backUv.offsetX + backUv.repeatX, backUv.offsetY),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallMat = getSharedMaterial(`card-wall:${tintK}`, {
|
||||||
|
color: tintedColor(new THREE.Color('#ffffff'), tint),
|
||||||
|
roughness: 0.6,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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. Geometry is shared across cards of the
|
||||||
|
// same size so the full-setup view reuses it; the face/back materials are
|
||||||
|
// shared per card (keyed by card id + tint) and carry the sprite UV transform.
|
||||||
|
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 parts = extrudeShapeParts(shape, { height: CARD_THICKNESS });
|
||||||
|
const key = `card:${width}:${height}:${CARD_THICKNESS}`;
|
||||||
|
return {
|
||||||
|
frontGeo: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
||||||
|
backGeo: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
||||||
|
wallsGeo: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
||||||
|
};
|
||||||
|
}, [faceUrl, face, backUrl, back, numWidth, numHeight]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh geometry={frontGeo} material={faceMat} />
|
||||||
|
<mesh geometry={backGeo} material={backMat} />
|
||||||
|
<mesh geometry={wallsGeo} material={wallMat} />
|
||||||
|
</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,37 +1,41 @@
|
|||||||
import { useTexture } from '@react-three/drei';
|
|
||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from './assetUrl';
|
import { CardObjectMesh } from './CardMesh';
|
||||||
|
import type { ViewerProps } from '../viewers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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, fill }: ViewerProps) {
|
||||||
const deck = object.CustomDeck ? Object.values(object.CustomDeck)[0] : undefined;
|
|
||||||
const faceUrl = deck?.FaceURL;
|
|
||||||
const backUrl = deck?.BackURL;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Scene>
|
<Scene fill={fill}>
|
||||||
<CardMesh faceUrl={faceUrl} backUrl={backUrl} />
|
{/* Cards are authored standing (face in XY, thickness along Z); lay them
|
||||||
|
flat on the ground so the face points up like a card on a table, which
|
||||||
|
is also what the contact shadow needs to project. */}
|
||||||
|
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<CardObjectMesh object={object} />
|
||||||
|
</group>
|
||||||
</Scene>
|
</Scene>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
export { CardObjectMesh, CardMesh } from './CardMesh';
|
||||||
function CardMesh({ faceUrl, backUrl }: { faceUrl?: string; backUrl?: string }) {
|
|
||||||
const face = faceUrl ? useTexture(assetUrl(faceUrl)) : null;
|
|
||||||
const back = backUrl ? useTexture(assetUrl(backUrl)) : null;
|
|
||||||
return (
|
|
||||||
<mesh>
|
|
||||||
<boxGeometry args={[1.4, 2, 0.06]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
color={face || back ? '#ffffff' : '#52525b'}
|
|
||||||
map={face ?? back ?? undefined}
|
|
||||||
roughness={0.6}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Suspense, useLayoutEffect } from 'react';
|
||||||
|
import { useLoader } from '@react-three/fiber';
|
||||||
|
import { useTexture } from '@react-three/drei';
|
||||||
|
import type { TTSObject } from '@tts/shared';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Object3D } from 'three';
|
||||||
|
import { assetUrl } from '@tts/http';
|
||||||
|
import { FlexibleModelLoader } from './flexibleModelLoader';
|
||||||
|
import { objectTint, tintedColor } from './sharedResources';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mesh content for a custom model, exported so the full-setup view can
|
||||||
|
* compose it into a shared scene. Renders the model from `CustomMesh.MeshURL`
|
||||||
|
* (or a neutral box placeholder when absent).
|
||||||
|
*/
|
||||||
|
export function CustomModelMesh({ object }: { object: TTSObject }) {
|
||||||
|
const meshUrl = object.CustomMesh?.MeshURL;
|
||||||
|
const tint = objectTint(object);
|
||||||
|
if (!meshUrl) {
|
||||||
|
return (
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[1, 1, 1]} />
|
||||||
|
<meshStandardMaterial color={tintedColor(new THREE.Color('#52525b'), tint)} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Model
|
||||||
|
meshUrl={meshUrl}
|
||||||
|
diffuseUrl={object.CustomMesh?.DiffuseURL}
|
||||||
|
tint={tint}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Model({
|
||||||
|
meshUrl,
|
||||||
|
diffuseUrl,
|
||||||
|
tint,
|
||||||
|
}: {
|
||||||
|
meshUrl: string;
|
||||||
|
diffuseUrl?: string;
|
||||||
|
tint: THREE.Color;
|
||||||
|
}) {
|
||||||
|
const root = useLoader(FlexibleModelLoader, assetUrl(meshUrl));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<primitive object={root} scale={0.5} />
|
||||||
|
{diffuseUrl && <DiffuseTexture root={root} url={diffuseUrl} />}
|
||||||
|
<Tint root={root} tint={tint} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the object's tint to every material on the loaded model, multiplying
|
||||||
|
// the existing color. Rendered after the model so it runs once the materials
|
||||||
|
// exist.
|
||||||
|
function Tint({ root, tint }: { root: Object3D; tint: THREE.Color }) {
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
root.traverse((child) => {
|
||||||
|
const mesh = child as THREE.Mesh;
|
||||||
|
if (mesh.isMesh) {
|
||||||
|
const material = Array.isArray(mesh.material)
|
||||||
|
? mesh.material[0]
|
||||||
|
: mesh.material;
|
||||||
|
if (material && 'color' in material) {
|
||||||
|
(material as THREE.MeshStandardMaterial).color.multiply(tint);
|
||||||
|
material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [root, tint]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendered inside the Canvas so `useTexture` can access the R3F store. Only
|
||||||
|
// mounted when a diffuse URL exists, so the hook count stays consistent.
|
||||||
|
function DiffuseTexture({ root, url }: { root: Object3D; url: string }) {
|
||||||
|
const texture = useTexture(assetUrl(url));
|
||||||
|
|
||||||
|
// Diffuse art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
|
||||||
|
// `NoColorSpace`, which double-decodes it in the shader and washes out
|
||||||
|
// contrast. Mark it sRGB so the GPU decodes it once, correctly.
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
|
// Apply the diffuse texture to every mesh material on the loaded model.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
root.traverse((child) => {
|
||||||
|
const mesh = child as THREE.Mesh;
|
||||||
|
if (mesh.isMesh) {
|
||||||
|
const material = Array.isArray(mesh.material)
|
||||||
|
? mesh.material[0]
|
||||||
|
: mesh.material;
|
||||||
|
if (material && 'map' in material) {
|
||||||
|
material.map = texture;
|
||||||
|
material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [root, texture]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
import { Suspense, useLayoutEffect } from 'react';
|
|
||||||
import { useLoader } from '@react-three/fiber';
|
|
||||||
import { useTexture } from '@react-three/drei';
|
|
||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import type * as THREE from 'three';
|
|
||||||
import type { Object3D } from 'three';
|
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from './assetUrl';
|
import { CustomModelMesh } from './CustomModelMesh';
|
||||||
import { FlexibleModelLoader } from './flexibleModelLoader';
|
import type { ViewerProps } from '../viewers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
|
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
|
||||||
@@ -14,65 +9,12 @@ import { FlexibleModelLoader } from './flexibleModelLoader';
|
|||||||
* (TTS model URLs are often extension-less). `DiffuseURL` is applied to the
|
* (TTS model URLs are often extension-less). `DiffuseURL` is applied to the
|
||||||
* model's materials when present.
|
* model's materials when present.
|
||||||
*/
|
*/
|
||||||
export default function CustomModelViewer({ object }: { object: TTSObject }) {
|
export default function CustomModelViewer({ object, fill }: ViewerProps) {
|
||||||
const meshUrl = object.CustomMesh?.MeshURL;
|
|
||||||
if (!meshUrl) {
|
|
||||||
return (
|
return (
|
||||||
<Scene>
|
<Scene fill={fill}>
|
||||||
<mesh>
|
<CustomModelMesh object={object} />
|
||||||
<boxGeometry args={[1, 1, 1]} />
|
|
||||||
<meshStandardMaterial color="#52525b" />
|
|
||||||
</mesh>
|
|
||||||
</Scene>
|
</Scene>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
export { CustomModelMesh } from './CustomModelMesh';
|
||||||
<Scene>
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Model meshUrl={meshUrl} diffuseUrl={object.CustomMesh?.DiffuseURL} />
|
|
||||||
</Suspense>
|
|
||||||
</Scene>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Model({
|
|
||||||
meshUrl,
|
|
||||||
diffuseUrl,
|
|
||||||
}: {
|
|
||||||
meshUrl: string;
|
|
||||||
diffuseUrl?: string;
|
|
||||||
}) {
|
|
||||||
const root = useLoader(FlexibleModelLoader, assetUrl(meshUrl));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<primitive object={root} scale={0.5} />
|
|
||||||
{diffuseUrl && <DiffuseTexture root={root} url={diffuseUrl} />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rendered inside the Canvas so `useTexture` can access the R3F store. Only
|
|
||||||
// mounted when a diffuse URL exists, so the hook count stays consistent.
|
|
||||||
function DiffuseTexture({ root, url }: { root: Object3D; url: string }) {
|
|
||||||
const texture = useTexture(assetUrl(url));
|
|
||||||
|
|
||||||
// Apply the diffuse texture to every mesh material on the loaded model.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
root.traverse((child) => {
|
|
||||||
const mesh = child as THREE.Mesh;
|
|
||||||
if (mesh.isMesh) {
|
|
||||||
const material = Array.isArray(mesh.material)
|
|
||||||
? mesh.material[0]
|
|
||||||
: mesh.material;
|
|
||||||
if (material && 'map' in material) {
|
|
||||||
material.map = texture;
|
|
||||||
material.needsUpdate = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [root, texture]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { useMemo, useRef, useState } from 'react';
|
||||||
|
import type { RefObject, WheelEvent } from 'react';
|
||||||
|
import { useFrame } from '@react-three/fiber';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { TTSObject } from '@tts/shared';
|
||||||
|
import { useBounds } from '@react-three/drei';
|
||||||
|
import Scene from './Scene';
|
||||||
|
import { CardObjectMesh } from './CardMesh';
|
||||||
|
import type { ViewerProps } from '../viewers';
|
||||||
|
|
||||||
|
/** How many cards to show to each side of the active card. */
|
||||||
|
const HALF_WINDOW = 3;
|
||||||
|
/** Angular spacing between adjacent cards in the arc, in radians. */
|
||||||
|
const ARC_STEP = 0.32;
|
||||||
|
/** Minimum radius of the arc, in world units. */
|
||||||
|
const ARC_RADIUS = 3.2;
|
||||||
|
/** Extra clearance between the active card's edge and its neighbors, in world units. */
|
||||||
|
const ARC_PADDING = 0.2;
|
||||||
|
/**
|
||||||
|
* A deck carousel: the deck's contained cards are fanned in a 3D arc with the
|
||||||
|
* active card front and center. Prev/next controls step through the deck, each
|
||||||
|
* card animating to its new slot. Side cards are turned 90° in y (album flow)
|
||||||
|
* so only the active card's face is framed; all neighbors edge-on around it.
|
||||||
|
*
|
||||||
|
* The camera is fitted to just the active card (not the whole carousel): the
|
||||||
|
* shared scene's auto-fit is disabled and `useBounds` refits whenever the
|
||||||
|
* selection changes.
|
||||||
|
*
|
||||||
|
* Only a window of cards around the active one is rendered (the rest stay
|
||||||
|
* hidden), so large decks stay lean. Falls back to a single card (the deck
|
||||||
|
* object itself) when there are no contained cards.
|
||||||
|
*/
|
||||||
|
export default function DeckViewer({ object, fill }: ViewerProps) {
|
||||||
|
const cards = (object.ContainedObjects ?? []).filter(
|
||||||
|
(o) => o.CardID != null || o.CustomImage != null,
|
||||||
|
);
|
||||||
|
const count = cards.length;
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const hasCards = count > 0;
|
||||||
|
const centerRef = useRef<THREE.Group>(null);
|
||||||
|
// The active card's world-space width, measured once it's laid out. Used to
|
||||||
|
// widen the arc so neighbors clear the card's edges (a fixed radius only fits
|
||||||
|
// square cards; wider cards clip their neighbors).
|
||||||
|
const [cardWidth, setCardWidth] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const step = (dir: number) => setActive((a) => (a + dir + count) % count);
|
||||||
|
const radius = arcRadius(cardWidth);
|
||||||
|
|
||||||
|
const visible = useMemo(
|
||||||
|
() =>
|
||||||
|
cards
|
||||||
|
.map((card, i) => ({ card, i, k: i - active }))
|
||||||
|
.filter((v) => Math.abs(v.k) <= HALF_WINDOW),
|
||||||
|
[cards, active],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The active card always settles to the arc center, so the camera only needs
|
||||||
|
// to frame it once on mount.
|
||||||
|
const didFit = useRef(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Scene
|
||||||
|
fit={false}
|
||||||
|
autoRotate={false}
|
||||||
|
fill={fill}
|
||||||
|
shadow={false}
|
||||||
|
overlay={
|
||||||
|
hasCards ? (
|
||||||
|
<CarouselControls active={active} count={count} onStep={step} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasCards ? (
|
||||||
|
<>
|
||||||
|
{visible.map(({ card, i, k }) => (
|
||||||
|
// Key by the card's index (stable across renders) so the element
|
||||||
|
// persists and tweens as its slot changes; the index-path key is
|
||||||
|
// unique even though cards in a deck share the same GUID.
|
||||||
|
<CarouselCard
|
||||||
|
key={i}
|
||||||
|
card={card}
|
||||||
|
k={k}
|
||||||
|
radius={radius}
|
||||||
|
groupRef={k === 0 ? centerRef : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<FitActive
|
||||||
|
targetRef={centerRef}
|
||||||
|
didFit={didFit}
|
||||||
|
onMeasure={setCardWidth}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// No contained cards: show the deck object itself, laid flat so the
|
||||||
|
// shared scene's contact shadow has a surface to project onto.
|
||||||
|
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<CardObjectMesh object={object} />
|
||||||
|
</group>
|
||||||
|
)}
|
||||||
|
</Scene>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fits the camera once, on the first frame, to frame the active card from the
|
||||||
|
* front. Runs in a frame callback (not an effect) so the active card has been
|
||||||
|
* moved to its arc slot by its own `useFrame` first — otherwise the group is
|
||||||
|
* still at the origin and the camera would frame the carousel center.
|
||||||
|
*
|
||||||
|
* The card's front face points toward +Z, so the camera is placed directly in
|
||||||
|
* front of it and looks straight at it, rather than keeping its initial side
|
||||||
|
* angle (which drei's `fit()` would do). The active card always settles in the
|
||||||
|
* same slot, so no refit is needed while navigating — that would restart the
|
||||||
|
* camera tween on every step and feel laggy.
|
||||||
|
*/
|
||||||
|
function FitActive({
|
||||||
|
targetRef,
|
||||||
|
didFit,
|
||||||
|
onMeasure,
|
||||||
|
}: {
|
||||||
|
targetRef: RefObject<THREE.Group | null>;
|
||||||
|
didFit: RefObject<boolean>;
|
||||||
|
onMeasure: (width: number) => void;
|
||||||
|
}) {
|
||||||
|
const bounds = useBounds();
|
||||||
|
// Whether the active card's width has been measured once. The fit waits one
|
||||||
|
// frame after measuring so the arc radius (and thus the active card's slot)
|
||||||
|
// has settled before framing it.
|
||||||
|
const measured = useRef(false);
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
const node = targetRef.current;
|
||||||
|
if (!node) return;
|
||||||
|
// Stop refreshing once fit: drei's `bounds.refresh()` resets the camera
|
||||||
|
// goal, so calling it every frame would wipe the fit we just set before
|
||||||
|
// the camera tween ever runs.
|
||||||
|
if (didFit.current) return;
|
||||||
|
bounds.refresh(node);
|
||||||
|
const { size, center, distance } = bounds.getSize();
|
||||||
|
// Report the active card's width so the arc radius can widen for wide
|
||||||
|
// cards (see `arcRadius`).
|
||||||
|
onMeasure(size.x);
|
||||||
|
if (!measured.current) {
|
||||||
|
measured.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
didFit.current = true;
|
||||||
|
// The card's front face points toward +Z, so put the camera in front of it
|
||||||
|
// and look straight at it.
|
||||||
|
bounds
|
||||||
|
.moveTo([center.x, center.y, center.z + distance])
|
||||||
|
.lookAt({ target: center });
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single card that tweens into its arc slot each frame. */
|
||||||
|
function CarouselCard({
|
||||||
|
card,
|
||||||
|
k,
|
||||||
|
radius,
|
||||||
|
groupRef,
|
||||||
|
}: {
|
||||||
|
card: TTSObject;
|
||||||
|
k: number;
|
||||||
|
radius: number;
|
||||||
|
groupRef?: RefObject<THREE.Group | null>;
|
||||||
|
}) {
|
||||||
|
const localRef = useRef<THREE.Group>(null);
|
||||||
|
const group = groupRef ?? localRef;
|
||||||
|
// Start at the target so the first render doesn't tween into place.
|
||||||
|
const state = useRef(slotTransform(k, radius));
|
||||||
|
const target = useMemo(() => slotTransform(k, radius), [k, radius]);
|
||||||
|
|
||||||
|
useFrame((_, dt) => {
|
||||||
|
const g = group.current;
|
||||||
|
if (!g) return;
|
||||||
|
// Smooth per-frame damping independent of frame rate.
|
||||||
|
const f = 1 - Math.pow(0.0001, dt);
|
||||||
|
const t = target;
|
||||||
|
const s = state.current;
|
||||||
|
s.x += (t.x - s.x) * f;
|
||||||
|
s.z += (t.z - s.z) * f;
|
||||||
|
s.rot += (t.rot - s.rot) * f;
|
||||||
|
s.scale += (t.scale - s.scale) * f;
|
||||||
|
g.position.set(s.x, 0, s.z);
|
||||||
|
g.rotation.y = s.rot;
|
||||||
|
g.scale.setScalar(s.scale);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={group}>
|
||||||
|
<CardObjectMesh object={card} />
|
||||||
|
{/* A soft radial shadow under this card; it inherits the card group's
|
||||||
|
position/rotation/scale, so it tweens with the card. Card meshes are
|
||||||
|
laid flat (face up), so the plane sits just below the card's bottom. */}
|
||||||
|
<CardShadow />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A soft elliptical drop shadow under a single carousel card. The card mesh is
|
||||||
|
// ~2 world-units tall (centered), so its bottom sits at local y = -1; we put a
|
||||||
|
// radial-gradient plane just below it. I can't use `ContactShadows` here
|
||||||
|
// because the card is a thin vertical panel — a top-down capture would only
|
||||||
|
// catch a line. Instead we fake a soft ambient contact shadow with a gradient.
|
||||||
|
const CARD_HALF_HEIGHT = 1;
|
||||||
|
const SHADOW_SCALE = 2.6;
|
||||||
|
|
||||||
|
function CardShadow() {
|
||||||
|
// A soft radial gradient, dark at the center fading to transparent — reads as
|
||||||
|
// a soft blob of shadow rather than a hard cast shadow.
|
||||||
|
const texture = useMemo(makeSoftShadowTexture, []);
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
position={[0, -CARD_HALF_HEIGHT, 0]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
scale={[SHADOW_SCALE, SHADOW_SCALE, 1]}
|
||||||
|
>
|
||||||
|
<planeGeometry args={[1, 1]} />
|
||||||
|
<meshBasicMaterial map={texture} transparent depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a radial-gradient texture: dark center fading out to transparent. */
|
||||||
|
function makeSoftShadowTexture(): THREE.Texture {
|
||||||
|
const size = 256;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return new THREE.Texture();
|
||||||
|
const g = ctx.createRadialGradient(
|
||||||
|
size / 2,
|
||||||
|
size / 2,
|
||||||
|
0,
|
||||||
|
size / 2,
|
||||||
|
size / 2,
|
||||||
|
size / 2,
|
||||||
|
);
|
||||||
|
const stops: [number, number][] = [
|
||||||
|
[0, 0.55],
|
||||||
|
[0.45, 0.35],
|
||||||
|
[0.75, 0.12],
|
||||||
|
[1, 0],
|
||||||
|
];
|
||||||
|
for (const [t, a] of stops) {
|
||||||
|
g.addColorStop(t, `rgba(0, 0, 0, ${a})`);
|
||||||
|
}
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Slots {
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
rot: number;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** World transform for a card at arc offset `k` (0 = front and center). */
|
||||||
|
function slotTransform(k: number, radius: number): Slots {
|
||||||
|
const ang = k * ARC_STEP;
|
||||||
|
// Side cards turn edge-on (album flow); the active card stays forward.
|
||||||
|
const turn = k === 0 ? 0 : Math.sign(k) * (Math.PI / 2);
|
||||||
|
return {
|
||||||
|
x: Math.sin(ang) * radius,
|
||||||
|
z: Math.cos(ang) * radius,
|
||||||
|
rot: turn,
|
||||||
|
scale: 1.15 - 0.15 * Math.abs(k),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arc radius that keeps the nearest neighbors clear of the active card's
|
||||||
|
* edges. A neighbor at `k = 1` sits at `x = sin(ARC_STEP) * radius`, so the
|
||||||
|
* radius must exceed `cardWidth / 2 / sin(ARC_STEP)` for the neighbor to clear
|
||||||
|
* the card's half-width (plus padding). Falls back to the minimum when the
|
||||||
|
* card width isn't known yet.
|
||||||
|
*/
|
||||||
|
function arcRadius(cardWidth: number | null): number {
|
||||||
|
if (cardWidth == null) return ARC_RADIUS;
|
||||||
|
return Math.max(ARC_RADIUS, (cardWidth / 2 + ARC_PADDING) / Math.sin(ARC_STEP));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prev/next controls and a sliding-dot pagination, rendered as the scene
|
||||||
|
* overlay. All dots live in a fixed-width track that slides so the active dot
|
||||||
|
* stays centered; dots fade out as they leave the window. A counter above the
|
||||||
|
* bar shows the current index (`1 / 51`). Round first/last and prev/next
|
||||||
|
* buttons flank the dots. Scrolling over the pagination steps through the
|
||||||
|
* deck.
|
||||||
|
*/
|
||||||
|
function CarouselControls({
|
||||||
|
active,
|
||||||
|
count,
|
||||||
|
onStep,
|
||||||
|
}: {
|
||||||
|
active: number;
|
||||||
|
count: number;
|
||||||
|
onStep: (dir: number) => void;
|
||||||
|
}) {
|
||||||
|
const prev = () => onStep(-1);
|
||||||
|
const next = () => onStep(1);
|
||||||
|
const first = () => onStep(-active);
|
||||||
|
const last = () => onStep(count - 1 - active);
|
||||||
|
|
||||||
|
// Dots visible on each side of the active dot.
|
||||||
|
const DOT_WINDOW = 4;
|
||||||
|
const DOT_SIZE = 8;
|
||||||
|
const ACTIVE_WIDTH = 26;
|
||||||
|
const GAP = 6;
|
||||||
|
const SLOTS = DOT_WINDOW * 2 + 1;
|
||||||
|
const slotW = DOT_SIZE + GAP;
|
||||||
|
// Extra width the active dot (plus its gap) pushes beyond a normal slot.
|
||||||
|
const extra = ACTIVE_WIDTH + GAP - slotW;
|
||||||
|
const trackW = SLOTS * slotW + extra;
|
||||||
|
// Left offset of a dot at slot `s` (0 = leftmost). Dots after the active
|
||||||
|
// slot shift right by `extra` to make room for the wider active dot.
|
||||||
|
const slotLeft = (s: number) => s * slotW + (s > DOT_WINDOW ? extra : 0);
|
||||||
|
const dots = Array.from({ length: count }, (_, i) => i);
|
||||||
|
|
||||||
|
const onWheel = (e: React.WheelEvent) => {
|
||||||
|
// Scroll up/left goes back, scroll down/right goes forward.
|
||||||
|
onStep(e.deltaY < 0 ? -1 : 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const roundBtn =
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-40 disabled:hover:bg-zinc-900/80 disabled:hover:text-zinc-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-x-0 bottom-2 z-10 flex flex-col items-center gap-2">
|
||||||
|
<span className="rounded-md bg-zinc-900/80 px-2 py-0.5 font-mono text-xs text-zinc-300 backdrop-blur">
|
||||||
|
{active + 1} / {count}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={first}
|
||||||
|
disabled={active === 0}
|
||||||
|
aria-label="First card"
|
||||||
|
className={roundBtn}
|
||||||
|
>
|
||||||
|
«
|
||||||
|
</button>
|
||||||
|
<button onClick={prev} aria-label="Previous card" className={roundBtn}>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
onWheel={onWheel}
|
||||||
|
role="group"
|
||||||
|
aria-label="Browse cards"
|
||||||
|
className="flex items-center rounded-full bg-zinc-900/80 px-2 py-1.5 backdrop-blur"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden"
|
||||||
|
style={{ width: trackW, height: DOT_SIZE }}
|
||||||
|
>
|
||||||
|
{dots.map((i) => {
|
||||||
|
const isActive = i === active;
|
||||||
|
const slot = DOT_WINDOW + (i - active);
|
||||||
|
const inWindow = slot >= 0 && slot < SLOTS;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onStep(i - active)}
|
||||||
|
aria-label={`Go to card ${i + 1}`}
|
||||||
|
aria-current={isActive ? 'true' : undefined}
|
||||||
|
className="absolute top-0 flex items-center justify-center rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
left: slotLeft(slot),
|
||||||
|
width: isActive ? ACTIVE_WIDTH : DOT_SIZE,
|
||||||
|
height: DOT_SIZE,
|
||||||
|
opacity: inWindow ? 1 : 0,
|
||||||
|
pointerEvents: inWindow ? 'auto' : 'none',
|
||||||
|
backgroundColor: isActive ? '#f4f4f5' : '#71717a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={next} aria-label="Next card" className={roundBtn}>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={last}
|
||||||
|
disabled={active === count - 1}
|
||||||
|
aria-label="Last card"
|
||||||
|
className={roundBtn}
|
||||||
|
>
|
||||||
|
»
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { TTSObject } from '@tts/shared';
|
||||||
|
import { pdfUrl } from '@tts/http';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A PDF document (`Custom_PDF`). Renders the document inline in an iframe and
|
||||||
|
* offers a standalone link to open it in a new tab. The URL is routed through
|
||||||
|
* the proxy `/pdf` endpoint, which forces `Content-Disposition: inline` so the
|
||||||
|
* browser displays the PDF instead of downloading it.
|
||||||
|
*/
|
||||||
|
export default function PdfViewer({ object }: { object: TTSObject }) {
|
||||||
|
const url = object.CustomPDF?.PDFUrl;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
This object has no PDF URL.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxied = pdfUrl(url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="truncate font-mono text-xs text-zinc-500">{url}</p>
|
||||||
|
<a
|
||||||
|
href={proxied}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="shrink-0 rounded-lg bg-zinc-100 px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-zinc-300"
|
||||||
|
>
|
||||||
|
Open PDF in new tab
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
src={proxied}
|
||||||
|
title="PDF preview"
|
||||||
|
className="min-h-0 w-full flex-1 rounded-lg border border-zinc-800 bg-zinc-950"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,50 +1,245 @@
|
|||||||
import { Suspense, type ReactNode } from 'react';
|
import { Suspense, useEffect, useRef, useState, type RefObject, type ReactNode } from 'react';
|
||||||
import { Canvas } from '@react-three/fiber';
|
import { Icon } from '@iconify/react';
|
||||||
import { ContactShadows, OrbitControls } from '@react-three/drei';
|
import { Canvas, useFrame } from '@react-three/fiber';
|
||||||
import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing';
|
import { Bounds, ContactShadows, Environment, Lightformer, OrbitControls, useProgress } from '@react-three/drei';
|
||||||
|
import { BrightnessContrast, EffectComposer, ToneMapping, Vignette } from '@react-three/postprocessing';
|
||||||
|
import { ToneMappingMode } from 'postprocessing';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared 3D scene wrapper for object viewers. Provides a consistent camera,
|
* Shared 3D scene wrapper for object viewers. Provides a consistent camera,
|
||||||
* 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.
|
||||||
|
*
|
||||||
|
* `autoRotate` and `enablePan` tune the orbit controls (a tabletop view, for
|
||||||
|
* example, pans instead of rotating). `fullscreen` adds a toggle button that
|
||||||
|
* `maxPolarAngle` (radians) clamps how
|
||||||
|
* far the camera can tilt below the horizon, e.g. to stop a tabletop view from
|
||||||
|
* peeking under face-down cards.
|
||||||
|
*
|
||||||
|
* `fit` (default true) bounds, fits, and clips the camera to the scene's
|
||||||
|
* content on mount and resize. A viewer that needs to frame a specific part of
|
||||||
|
* its content (e.g. the active card in a deck carousel) can set it to false and
|
||||||
|
* call `useBounds()` itself to refit.
|
||||||
|
*
|
||||||
|
* By default the scene renders in an `aspect-[3/4]` frame; set `fill` to expand
|
||||||
|
* to the full height of its container (used by the inspector view).
|
||||||
*/
|
*/
|
||||||
export default function Scene({ children }: { children: ReactNode }) {
|
export default function Scene({
|
||||||
|
children,
|
||||||
|
autoRotate = true,
|
||||||
|
enablePan = false,
|
||||||
|
fullscreen = false,
|
||||||
|
fit = true,
|
||||||
|
fill = false,
|
||||||
|
overlay,
|
||||||
|
shadowScale = 22,
|
||||||
|
maxPolarAngle = Math.PI,
|
||||||
|
shadowBlur = 0.012,
|
||||||
|
shadow = true,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
autoRotate?: boolean;
|
||||||
|
enablePan?: boolean;
|
||||||
|
fullscreen?: boolean;
|
||||||
|
/** Whether the shared scene fits + clips its children with `Bounds` (default true). */
|
||||||
|
fit?: boolean;
|
||||||
|
/** Expand to fill the container height instead of the default aspect-[3/4] frame. */
|
||||||
|
fill?: boolean;
|
||||||
|
/** HTML rendered inside the scene container (e.g. controls), shown in fullscreen. */
|
||||||
|
overlay?: ReactNode;
|
||||||
|
/** Contact shadow plane size in world units; defaults to a generous 22. */
|
||||||
|
shadowScale?: number;
|
||||||
|
/** Contact shadow blur as a fraction of the plane size; a larger value is softer. */
|
||||||
|
shadowBlur?: number;
|
||||||
|
/** Whether to render a scene-level contact shadow under the content (default true). */
|
||||||
|
shadow?: boolean;
|
||||||
|
/** Max camera polar angle in radians; defaults to unrestricted (π). */
|
||||||
|
maxPolarAngle?: number;
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
// Points at the wrapped children so the shadow can measure the real content
|
||||||
|
// bounding box (independent of drei's `Bounds` camera fit).
|
||||||
|
const contentRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
document.addEventListener('fullscreenchange', onChange);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
if (document.fullscreenElement) void document.exitFullscreen();
|
||||||
|
else void containerRef.current?.requestFullscreen();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-80 w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-900 to-zinc-950">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`relative w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-300 to-zinc-400 ${
|
||||||
|
fill ? 'h-full min-h-0' : 'aspect-3/4'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LoadingOverlay />
|
||||||
|
{fullscreen && (
|
||||||
|
<button
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||||
|
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||||
|
className="absolute right-2 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
<Icon icon={isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen'} className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{overlay}
|
||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [2.2, 1.8, 2.6], fov: 40 }}
|
camera={{ position: [2.2, 1.8, 2.6], fov: 40 }}
|
||||||
dpr={[1, 2]}
|
dpr={[1, 2]}
|
||||||
gl={{ antialias: true }}
|
gl={{ antialias: true, alpha: true }}
|
||||||
>
|
>
|
||||||
<ambientLight intensity={0.5} />
|
<ambientLight intensity={0.1} />
|
||||||
<directionalLight position={[4, 6, 3]} intensity={1.4} />
|
|
||||||
<directionalLight position={[-4, 2, -3]} intensity={0.4} color="#b3c7ff" />
|
|
||||||
<pointLight position={[0, 3, 0]} intensity={0.3} />
|
|
||||||
|
|
||||||
<Suspense fallback={null}>{children}</Suspense>
|
<Environment resolution={256}>
|
||||||
|
<Lightformer intensity={.5} position={[0, 5, 0]} scale={[10, 10, 1]} />
|
||||||
|
<Lightformer intensity={.5} position={[0, 0, 5]} scale={[10, 10, 1]} />
|
||||||
|
<Lightformer intensity={.5} position={[0, 0, -5]} scale={[10, 10, 1]} />
|
||||||
|
<Lightformer intensity={.5} position={[-5, 0, 0]} scale={[10, 10, 1]} />
|
||||||
|
<Lightformer intensity={.5} position={[5, 0, -0]} scale={[10, 10, 1]} />
|
||||||
|
<Lightformer intensity={0.6} color="#fff1d6" position={[0, -3, 0]} scale={[6, 6, 1]} />
|
||||||
|
</Environment>
|
||||||
|
|
||||||
<ContactShadows
|
{shadow && (
|
||||||
position={[0, -0.5, 0]}
|
<AdaptiveContactShadow shadowScale={shadowScale} shadowBlur={shadowBlur} contentRef={contentRef} />
|
||||||
opacity={0.55}
|
)}
|
||||||
scale={8}
|
|
||||||
blur={2.4}
|
<Suspense fallback={null}>
|
||||||
far={3}
|
<Bounds fit={fit} observe={fit} clip={fit}>
|
||||||
resolution={256}
|
<group ref={contentRef}>{children}</group>
|
||||||
/>
|
</Bounds>
|
||||||
|
</Suspense>
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
enablePan={false}
|
enablePan={enablePan}
|
||||||
minDistance={1}
|
minDistance={0.01}
|
||||||
maxDistance={8}
|
maxDistance={8}
|
||||||
autoRotate
|
maxPolarAngle={maxPolarAngle}
|
||||||
autoRotateSpeed={1.2}
|
autoRotate={autoRotate}
|
||||||
|
makeDefault
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EffectComposer>
|
<EffectComposer>
|
||||||
<Bloom intensity={0.25} luminanceThreshold={0.85} mipmapBlur />
|
<ToneMapping mode={ToneMappingMode.AGX} />
|
||||||
|
<BrightnessContrast brightness={0.12} contrast={.5} />
|
||||||
<Vignette eskil={false} offset={0.25} darkness={0.6} />
|
<Vignette eskil={false} offset={0.25} darkness={0.6} />
|
||||||
</EffectComposer>
|
</EffectComposer>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A loading overlay shown while assets (textures, models, traces) are being
|
||||||
|
* fetched. Reads drei's global progress store, which tracks every loader in
|
||||||
|
* the scene, so it works outside the Canvas. Hidden once loading completes.
|
||||||
|
*/
|
||||||
|
function LoadingOverlay() {
|
||||||
|
const { active, progress, item, loaded, total } = useProgress();
|
||||||
|
if (!active) return null;
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-zinc-950/70 text-sm text-zinc-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-zinc-600 border-t-zinc-200" />
|
||||||
|
<span>Loading assets… {Math.round(progress)}%</span>
|
||||||
|
</div>
|
||||||
|
<span className="max-w-80 truncate text-xs text-zinc-500">
|
||||||
|
{loaded}/{total} {item}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A contact shadow sized and placed to the scene's actual content bounds.
|
||||||
|
*
|
||||||
|
* A fixed-size shadow plane baked for a 2-unit token is invisible to a whole
|
||||||
|
* card or model ten times larger, so the plane is (re)sized to the bounding
|
||||||
|
* box of whatever is wrapped in the scene, measured each frame from a ref.
|
||||||
|
* Keeping it scene-level (instead of per mesh) keeps it world-horizontal at
|
||||||
|
* the table and costs one shadow render per frame even when there are many
|
||||||
|
* meshes, which per-mesh planes would multiply.
|
||||||
|
*
|
||||||
|
* The plane sits on the very bottom (y minimum) of the content, centered on
|
||||||
|
* its footprint, and spans a bit wider so the shadow edge doesn't fall inside
|
||||||
|
* the object. `far` covers the full content height so tall objects aren't
|
||||||
|
* clipped. Falls back to `shadowScale` while the bounds are still empty.
|
||||||
|
*/
|
||||||
|
function AdaptiveContactShadow({
|
||||||
|
shadowScale,
|
||||||
|
shadowBlur,
|
||||||
|
contentRef,
|
||||||
|
}: {
|
||||||
|
shadowScale: number;
|
||||||
|
shadowBlur: number;
|
||||||
|
contentRef: RefObject<THREE.Group | null>;
|
||||||
|
}) {
|
||||||
|
// Current computed shadow config, kept in a ref so it's stable between frames
|
||||||
|
// and only recomputed when the content bounds actually change.
|
||||||
|
const state = useRef({ scale: 0, posX: 0, posY: 0, posZ: 0, far: 0 });
|
||||||
|
// Scratch box, reused each frame to avoid allocations.
|
||||||
|
const box = useRef(new THREE.Box3()).current;
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (!content) return;
|
||||||
|
content.updateWorldMatrix(true, true);
|
||||||
|
box.setFromObject(content);
|
||||||
|
if (box.isEmpty()) return;
|
||||||
|
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
// The shadow plane rests on the very bottom of the content, centered on its
|
||||||
|
// footprint. `far` covers the full content height (plus margin) so the
|
||||||
|
// capture doesn't clip tall objects; `scale` covers the footprint.
|
||||||
|
const scale = Math.max(size.x, size.z) * SHADOW_PAD;
|
||||||
|
const posY = box.min.y;
|
||||||
|
const far = size.y + SHADOW_HEIGHT_MARGIN;
|
||||||
|
const s = state.current;
|
||||||
|
if (
|
||||||
|
scale !== s.scale ||
|
||||||
|
center.x !== s.posX ||
|
||||||
|
posY !== s.posY ||
|
||||||
|
center.z !== s.posZ ||
|
||||||
|
far !== s.far
|
||||||
|
) {
|
||||||
|
Object.assign(s, { scale, posX: center.x, posY, posZ: center.z, far });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const s = state.current;
|
||||||
|
const scale = s.scale || shadowScale;
|
||||||
|
// Sit just below the lowest point of the content so it rests on it without
|
||||||
|
// intersecting; the plane uses a (fixed) world-horizontal orientation.
|
||||||
|
const position = [s.posX, s.posY - 0.01, s.posZ] as [number, number, number];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContactShadows
|
||||||
|
position={position}
|
||||||
|
opacity={0.2}
|
||||||
|
scale={scale}
|
||||||
|
blur={scale * shadowBlur}
|
||||||
|
far={s.far || scale * 0.01}
|
||||||
|
resolution={1024}
|
||||||
|
color="#000000"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far the shadow plane extends past the content footprint, as a multiple. */
|
||||||
|
const SHADOW_PAD = 1.1;
|
||||||
|
/** Extra capture depth beyond the content height, so the falloff edge isn't clipped. */
|
||||||
|
const SHADOW_HEIGHT_MARGIN = 0.5;
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useTexture } from '@react-three/drei';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { TTSObject } from '@tts/shared';
|
||||||
|
import {
|
||||||
|
circleShape,
|
||||||
|
extrudeShapeParts,
|
||||||
|
hexShape,
|
||||||
|
rectShape,
|
||||||
|
roundedRectShape,
|
||||||
|
scaleShape,
|
||||||
|
type ExtrudedGeometry,
|
||||||
|
} from '@tts/mesh';
|
||||||
|
import { assetUrl } from '@tts/http';
|
||||||
|
import { flipTexture } from './flipTexture';
|
||||||
|
import {
|
||||||
|
getSharedGeometry,
|
||||||
|
getSharedMaterial,
|
||||||
|
objectTint,
|
||||||
|
tintKey,
|
||||||
|
tintedColor,
|
||||||
|
} from './sharedResources';
|
||||||
|
|
||||||
|
/** `CustomTile.Type` enum from Tabletop Simulator. */
|
||||||
|
const TileType = {
|
||||||
|
Box: 0,
|
||||||
|
Hex: 1,
|
||||||
|
Circle: 2,
|
||||||
|
Rounded: 3,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const TILE_SIZE = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tile mesh for an object, exported so the full-setup view can compose it
|
||||||
|
* into a shared scene.
|
||||||
|
*/
|
||||||
|
export function TileObjectMesh({ object }: { object: TTSObject }) {
|
||||||
|
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
|
||||||
|
const thickness = object.CustomImage?.CustomTile?.Thickness ?? 0.2;
|
||||||
|
const type = object.CustomImage?.CustomTile?.Type ?? TileType.Box;
|
||||||
|
const stretch = object.CustomImage?.CustomTile?.Stretch ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TileMesh
|
||||||
|
url={url}
|
||||||
|
thickness={thickness}
|
||||||
|
type={type}
|
||||||
|
stretch={stretch}
|
||||||
|
tint={objectTint(object)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
||||||
|
// Exported so the full-setup view can compose it into a shared scene.
|
||||||
|
export function TileMesh({
|
||||||
|
url,
|
||||||
|
thickness,
|
||||||
|
type,
|
||||||
|
stretch,
|
||||||
|
tint,
|
||||||
|
}: {
|
||||||
|
url?: string;
|
||||||
|
thickness: number;
|
||||||
|
type: number;
|
||||||
|
stretch: boolean;
|
||||||
|
tint: THREE.Color;
|
||||||
|
}) {
|
||||||
|
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
||||||
|
|
||||||
|
// Tile art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
|
||||||
|
// `NoColorSpace`, which double-decodes it in the shader and washes out
|
||||||
|
// contrast. Mark it sRGB so the GPU decodes it once, correctly.
|
||||||
|
if (texture) texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
|
// Build the extruded geometry from the tile shape. When `stretch` is false
|
||||||
|
// and a texture is available, scale the shape to the image's aspect ratio so
|
||||||
|
// the tile matches the source proportions instead of being square. Shared
|
||||||
|
// across tiles with the same shape so the full-setup view reuses geometry.
|
||||||
|
const { front, back, walls } = useMemo(() => {
|
||||||
|
const img = texture?.image as HTMLImageElement;
|
||||||
|
const aspect = stretch ? img.width / img.height : 1;
|
||||||
|
const shape = tileShape(type, aspect);
|
||||||
|
const parts = extrudeShapeParts(shape, { height: thickness });
|
||||||
|
const key = `tile:${type}:${aspect}:${thickness}`;
|
||||||
|
return {
|
||||||
|
front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
||||||
|
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
||||||
|
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
||||||
|
};
|
||||||
|
}, [type, thickness, stretch, texture]);
|
||||||
|
|
||||||
|
// The back face maps with the same planar UVs as the front, so flip it
|
||||||
|
// left/right to avoid a mirrored texture when viewed from behind.
|
||||||
|
const backMap = useMemo(() => (texture ? flipTexture(texture) : null), [texture]);
|
||||||
|
|
||||||
|
// Shared materials: the front/back carry the tile texture (or a neutral
|
||||||
|
// color when absent); the walls are a solid white, matching TTS tinting.
|
||||||
|
// The tint is baked into the color and the cache key so tinted variants
|
||||||
|
// don't collide.
|
||||||
|
const tintK = tintKey(tint);
|
||||||
|
const faceKey = `tile-face:${url ?? 'none'}:${tintK}`;
|
||||||
|
const faceMat = getSharedMaterial(faceKey, {
|
||||||
|
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
||||||
|
map: texture ?? undefined,
|
||||||
|
roughness: 0.8,
|
||||||
|
});
|
||||||
|
const backMat = getSharedMaterial(faceKey + ':back', {
|
||||||
|
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
||||||
|
map: backMap ?? undefined,
|
||||||
|
roughness: 0.8,
|
||||||
|
});
|
||||||
|
const wallMat = getSharedMaterial(`tile-wall:${tintK}`, {
|
||||||
|
color: tintedColor(new THREE.Color('#ffffff'), tint),
|
||||||
|
roughness: 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Front face carries the tile texture. */}
|
||||||
|
<mesh geometry={front} material={faceMat} />
|
||||||
|
{/* Back face, flipped so it isn't mirrored. */}
|
||||||
|
<mesh geometry={back} material={backMat} />
|
||||||
|
{/* Sides are a solid white, matching TTS tile tinting. */}
|
||||||
|
<mesh geometry={walls} material={wallMat} />
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the 2D footprint for a tile type, scaled to a target aspect ratio. */
|
||||||
|
function tileShape(type: number, aspect: number) {
|
||||||
|
// Base shape is square (1x1); scale x to the aspect ratio so the tile is
|
||||||
|
// `aspect` wide and 1 tall (or keep 1x1 when the aspect is 1).
|
||||||
|
const sx = aspect;
|
||||||
|
const sy = 1;
|
||||||
|
switch (type) {
|
||||||
|
case TileType.Hex:
|
||||||
|
return scaleShape(hexShape(TILE_SIZE / 2), sx, sy);
|
||||||
|
case TileType.Circle:
|
||||||
|
return scaleShape(circleShape(TILE_SIZE / 2), sx, sy);
|
||||||
|
case TileType.Rounded:
|
||||||
|
return scaleShape(roundedRectShape(TILE_SIZE, TILE_SIZE, 0.08), sx, sy);
|
||||||
|
case TileType.Box:
|
||||||
|
default:
|
||||||
|
return scaleShape(rectShape(TILE_SIZE, TILE_SIZE), sx, sy);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,7 @@
|
|||||||
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 {
|
|
||||||
circleShape,
|
|
||||||
extrudeShapeParts,
|
|
||||||
hexShape,
|
|
||||||
rectShape,
|
|
||||||
roundedRectShape,
|
|
||||||
scaleShape,
|
|
||||||
type ExtrudedGeometry,
|
|
||||||
} from '@tts/mesh';
|
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from './assetUrl';
|
import { TileObjectMesh } from './TileMesh';
|
||||||
|
import type { ViewerProps } from '../viewers';
|
||||||
/** `CustomTile.Type` enum from Tabletop Simulator. */
|
|
||||||
const TileType = {
|
|
||||||
Box: 0,
|
|
||||||
Hex: 1,
|
|
||||||
Circle: 2,
|
|
||||||
Rounded: 3,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const TILE_SIZE = 2;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
|
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
|
||||||
@@ -32,88 +11,15 @@ const TILE_SIZE = 2;
|
|||||||
* When `CustomTile.Stretch` is false, the tile's aspect ratio follows the
|
* When `CustomTile.Stretch` is false, the tile's aspect ratio follows the
|
||||||
* source image instead of being forced square.
|
* source image instead of being forced square.
|
||||||
*/
|
*/
|
||||||
export default function TileViewer({ object }: { object: TTSObject }) {
|
export default function TileViewer({ object, fill }: ViewerProps) {
|
||||||
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
|
|
||||||
const thickness = object.CustomImage?.CustomTile?.Thickness ?? 0.2;
|
|
||||||
const type = object.CustomImage?.CustomTile?.Type ?? TileType.Box;
|
|
||||||
const stretch = object.CustomImage?.CustomTile?.Stretch ?? true;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Scene>
|
<Scene fill={fill}>
|
||||||
<TileMesh url={url} thickness={thickness} type={type} stretch={stretch} />
|
{/* Lay the tile flat on the ground (the mesh is authored standing). */}
|
||||||
|
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<TileObjectMesh object={object} />
|
||||||
|
</group>
|
||||||
</Scene>
|
</Scene>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
export { TileObjectMesh, TileMesh } from './TileMesh';
|
||||||
function TileMesh({
|
|
||||||
url,
|
|
||||||
thickness,
|
|
||||||
type,
|
|
||||||
stretch,
|
|
||||||
}: {
|
|
||||||
url?: string;
|
|
||||||
thickness: number;
|
|
||||||
type: number;
|
|
||||||
stretch: boolean;
|
|
||||||
}) {
|
|
||||||
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
|
||||||
|
|
||||||
// Build the extruded geometry from the tile shape. When `stretch` is false
|
|
||||||
// and a texture is available, scale the shape to the image's aspect ratio so
|
|
||||||
// the tile matches the source proportions instead of being square.
|
|
||||||
const { caps, walls } = useMemo(() => {
|
|
||||||
const img = texture?.image as HTMLImageElement;
|
|
||||||
const aspect = stretch ? img.width / img.height : 1;
|
|
||||||
const shape = tileShape(type, aspect);
|
|
||||||
const { caps, walls } = extrudeShapeParts(shape, { height: thickness });
|
|
||||||
return { caps: toGeometry(caps), walls: toGeometry(walls) };
|
|
||||||
}, [type, thickness, stretch, texture]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
{/* Top/bottom faces carry the tile texture. */}
|
|
||||||
<mesh geometry={caps}>
|
|
||||||
<meshStandardMaterial
|
|
||||||
color={texture ? '#ffffff' : '#52525b'}
|
|
||||||
map={texture ?? undefined}
|
|
||||||
roughness={0.8}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
{/* Sides are a solid white, matching TTS tile tinting. */}
|
|
||||||
<mesh geometry={walls}>
|
|
||||||
<meshStandardMaterial color="#ffffff" roughness={0.8} />
|
|
||||||
</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the 2D footprint for a tile type, scaled to a target aspect ratio. */
|
|
||||||
function tileShape(type: number, aspect: number) {
|
|
||||||
// Base shape is square (1x1); scale x to the aspect ratio so the tile is
|
|
||||||
// `aspect` wide and 1 tall (or keep 1x1 when the aspect is 1).
|
|
||||||
const sx = aspect;
|
|
||||||
const sy = 1;
|
|
||||||
switch (type) {
|
|
||||||
case TileType.Hex:
|
|
||||||
return scaleShape(hexShape(TILE_SIZE / 2), sx, sy);
|
|
||||||
case TileType.Circle:
|
|
||||||
return scaleShape(circleShape(TILE_SIZE / 2), sx, sy);
|
|
||||||
case TileType.Rounded:
|
|
||||||
return scaleShape(roundedRectShape(TILE_SIZE, TILE_SIZE, 0.08), sx, sy);
|
|
||||||
case TileType.Box:
|
|
||||||
default:
|
|
||||||
return scaleShape(rectShape(TILE_SIZE, TILE_SIZE), sx, sy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useTexture } from '@react-three/drei';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { TTSObject } from '@tts/shared';
|
||||||
|
import {
|
||||||
|
circleShape,
|
||||||
|
extrudeShapeParts,
|
||||||
|
traceToShape,
|
||||||
|
traceToUvBounds,
|
||||||
|
type ExtrudedGeometry,
|
||||||
|
} from '@tts/mesh';
|
||||||
|
import { traceImage } from '@tts/http';
|
||||||
|
import { assetUrl } from '@tts/http';
|
||||||
|
import {
|
||||||
|
getSharedGeometry,
|
||||||
|
getSharedMaterial,
|
||||||
|
objectTint,
|
||||||
|
tintKey,
|
||||||
|
tintedColor,
|
||||||
|
} from './sharedResources';
|
||||||
|
|
||||||
|
const TOKEN_SIZE = 1.8;
|
||||||
|
|
||||||
|
/** How far (in trace pixels) the token silhouette is inset from the artwork. */
|
||||||
|
const TRACE_INSET = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The token mesh for an object, exported so the full-setup view can compose it
|
||||||
|
* into a shared scene.
|
||||||
|
*/
|
||||||
|
export function TokenObjectMesh({ object }: { object: TTSObject }) {
|
||||||
|
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
|
||||||
|
const thickness = object.CustomImage?.CustomToken?.Thickness ?? 0.1;
|
||||||
|
|
||||||
|
return <TokenMesh url={url} thickness={thickness} tint={objectTint(object)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
||||||
|
// Exported so the full-setup view can compose it into a shared scene.
|
||||||
|
export function TokenMesh({
|
||||||
|
url,
|
||||||
|
thickness,
|
||||||
|
tint,
|
||||||
|
}: {
|
||||||
|
url?: string;
|
||||||
|
thickness: number;
|
||||||
|
tint: THREE.Color;
|
||||||
|
}) {
|
||||||
|
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
||||||
|
|
||||||
|
// Token art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
|
||||||
|
// `NoColorSpace`, which double-decodes it in the shader and washes out
|
||||||
|
// contrast. Mark it sRGB so the GPU decodes it once, correctly.
|
||||||
|
if (texture) texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
|
// Trace the image's alpha channel into a shape. Suspends until the trace
|
||||||
|
// resolves so the surrounding Suspense boundary (and `Bounds`) only mounts
|
||||||
|
// once the token geometry is present. Falls back to a circle when there's no
|
||||||
|
// image or the trace fails.
|
||||||
|
const trace = useTrace(url);
|
||||||
|
|
||||||
|
const { front, back, walls } = useMemo(() => {
|
||||||
|
// The traced shape and its UV framing share the same transform, so the
|
||||||
|
// full image rectangle maps to the same bounds in mesh coordinates.
|
||||||
|
const scale = TOKEN_SIZE / Math.max(trace?.width ?? 0, trace?.height ?? 0);
|
||||||
|
const shape = trace ? traceToShape(trace, scale) : circleShape(TOKEN_SIZE / 2);
|
||||||
|
const uvBounds = trace ? traceToUvBounds(trace, scale) : undefined;
|
||||||
|
const parts = extrudeShapeParts(shape, { height: thickness, uvBounds });
|
||||||
|
// 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
|
||||||
|
// geometry.
|
||||||
|
const key = `token:${url ?? 'none'}:${thickness}`;
|
||||||
|
return {
|
||||||
|
front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
||||||
|
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
||||||
|
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
||||||
|
};
|
||||||
|
}, [trace, thickness, url]);
|
||||||
|
|
||||||
|
// A token is solid: front, back, and walls all carry the texture (projected
|
||||||
|
// UV), unlike tiles/cards where only the faces are textured. The tint is
|
||||||
|
// baked into the color and cache key so tinted variants don't collide.
|
||||||
|
const material = getSharedMaterial(`token:${url ?? 'none'}:${tintKey(tint)}`, {
|
||||||
|
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
||||||
|
map: texture ?? undefined,
|
||||||
|
roughness: 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh geometry={front} material={material} />
|
||||||
|
<mesh geometry={back} material={material} />
|
||||||
|
<mesh geometry={walls} material={material} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// while the boundary is held open. A URL maps to either a pending promise (while
|
||||||
|
// loading) or the resolved value (once loaded).
|
||||||
|
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 so the retry
|
||||||
|
* render completes instead of suspending forever.
|
||||||
|
*/
|
||||||
|
function useTrace(url: string | undefined): TraceData | null {
|
||||||
|
if (!url) return null;
|
||||||
|
const cached = traceCache.get(url);
|
||||||
|
if (cached === undefined) {
|
||||||
|
const promise = traceImage(url, 'alpha', -TRACE_INSET).then((result) => {
|
||||||
|
const value: TraceData | null = result.shape
|
||||||
|
? {
|
||||||
|
shape: result.shape,
|
||||||
|
width: result.width,
|
||||||
|
height: result.height,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
// Replace the pending promise with the resolved value so later renders
|
||||||
|
// return it instead of re-suspending on a settled promise.
|
||||||
|
traceCache.set(url, value);
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
traceCache.set(url, promise);
|
||||||
|
throw promise;
|
||||||
|
}
|
||||||
|
if (cached instanceof Promise) throw cached;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,34 +1,23 @@
|
|||||||
import { useTexture } from '@react-three/drei';
|
|
||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from './assetUrl';
|
import { TokenObjectMesh } from './TokenMesh';
|
||||||
|
import type { ViewerProps } from '../viewers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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, fill }: ViewerProps) {
|
||||||
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
|
|
||||||
const thickness = object.CustomImage?.CustomToken?.Thickness ?? 0.1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Scene>
|
<Scene fill={fill}>
|
||||||
<TokenMesh url={url} thickness={thickness} />
|
{/* Lay the token flat on the ground (see CardViewer). */}
|
||||||
|
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<TokenObjectMesh object={object} />
|
||||||
|
</group>
|
||||||
</Scene>
|
</Scene>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
export { TokenObjectMesh, TokenMesh } from './TokenMesh';
|
||||||
function TokenMesh({ url, thickness }: { url?: string; thickness: number }) {
|
|
||||||
const texture = url ? useTexture(assetUrl(url)) : null;
|
|
||||||
return (
|
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
|
||||||
<cylinderGeometry args={[0.9, 0.9, thickness, 48]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
color={texture ? '#ffffff' : '#52525b'}
|
|
||||||
map={texture ?? undefined}
|
|
||||||
roughness={0.8}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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)}`;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { applyMapTransform } from './cardMaterial';
|
||||||
|
|
||||||
|
describe('applyMapTransform', () => {
|
||||||
|
it('injects the repeat/offset uniforms and UV transform into the shader', () => {
|
||||||
|
const mat = new THREE.MeshStandardMaterial();
|
||||||
|
applyMapTransform(mat, new THREE.Vector2(0.5, 0.25), new THREE.Vector2(0.1, 0.2));
|
||||||
|
|
||||||
|
expect(mat.onBeforeCompile).toBeTypeOf('function');
|
||||||
|
|
||||||
|
const shader = {
|
||||||
|
uniforms: {} as Record<string, { value: unknown }>,
|
||||||
|
vertexShader: '#include <uv_vertex>\nvoid main() {}',
|
||||||
|
};
|
||||||
|
mat.onBeforeCompile!(shader as never, {} as never);
|
||||||
|
|
||||||
|
// Uniforms are copied, so the caller's vectors stay reusable.
|
||||||
|
expect(shader.uniforms.uMapRepeat!.value).toEqual(new THREE.Vector2(0.5, 0.25));
|
||||||
|
expect(shader.uniforms.uMapOffset!.value).toEqual(new THREE.Vector2(0.1, 0.2));
|
||||||
|
// The uniforms are declared in the GLSL (three.js does not auto-declare
|
||||||
|
// uniforms added via `onBeforeCompile`), and the override is injected right
|
||||||
|
// after the chunk include, which stays in place (it declares `vMapUv`/`uv`).
|
||||||
|
expect(shader.vertexShader).toContain('uniform vec2 uMapRepeat;');
|
||||||
|
expect(shader.vertexShader).toContain('uniform vec2 uMapOffset;');
|
||||||
|
expect(shader.vertexShader).toContain('#include <uv_vertex>\n\tvMapUv = uv * uMapRepeat + uMapOffset;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies the vectors so later mutation of the inputs has no effect', () => {
|
||||||
|
const mat = new THREE.MeshStandardMaterial();
|
||||||
|
const repeat = new THREE.Vector2(1, 1);
|
||||||
|
const offset = new THREE.Vector2(0, 0);
|
||||||
|
applyMapTransform(mat, repeat, offset);
|
||||||
|
|
||||||
|
repeat.set(9, 9);
|
||||||
|
offset.set(9, 9);
|
||||||
|
|
||||||
|
const shader = { uniforms: {} as Record<string, { value: unknown }>, vertexShader: '' };
|
||||||
|
mat.onBeforeCompile!(shader as never, {} as never);
|
||||||
|
expect(shader.uniforms.uMapRepeat!.value).toEqual(new THREE.Vector2(1, 1));
|
||||||
|
expect(shader.uniforms.uMapOffset!.value).toEqual(new THREE.Vector2(0, 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-card UV transform injected into a `MeshStandardMaterial` shader.
|
||||||
|
*
|
||||||
|
* Cards share one texture (the deck's sprite sheet, cached by drei) and one
|
||||||
|
* geometry, but each card samples a different sprite cell. Rather than cloning
|
||||||
|
* the texture per card (which re-uploads the sheet on every GPU bind), the
|
||||||
|
* repeat/offset is pushed into the material as a uniform. The shader source is
|
||||||
|
* identical across cards, so three.js still compiles a single shared program.
|
||||||
|
*
|
||||||
|
* We inject our own uniform instead of setting `texture.repeat`/`offset`
|
||||||
|
* because three r185 derives the map UVs from a `mapTransform` matrix that is
|
||||||
|
* refreshed from `map.matrix` every frame, overwriting any per-material
|
||||||
|
* transform we set on the shared texture.
|
||||||
|
*/
|
||||||
|
// Uniforms added via `onBeforeCompile` are not auto-declared by three.js, so
|
||||||
|
// they must be declared in the GLSL explicitly (the built-in `mapTransform` is
|
||||||
|
// declared in `uv_pars_vertex.glsl.js`).
|
||||||
|
const UNIFORM_DECLS = /* glsl */ `
|
||||||
|
uniform vec2 uMapRepeat;
|
||||||
|
uniform vec2 uMapOffset;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const VERTEX_INJECT = /* glsl */ `
|
||||||
|
#include <uv_vertex>
|
||||||
|
vMapUv = uv * uMapRepeat + uMapOffset;
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a repeat/offset to a card material's map sampling. Call once per
|
||||||
|
* material (the transform is baked into the shader). `repeat`/`offset` are
|
||||||
|
* copied, so the caller may reuse the vectors.
|
||||||
|
*/
|
||||||
|
export function applyMapTransform(
|
||||||
|
material: THREE.MeshStandardMaterial,
|
||||||
|
repeat: THREE.Vector2,
|
||||||
|
offset: THREE.Vector2,
|
||||||
|
): void {
|
||||||
|
// Clone eagerly so later mutation of the caller's vectors can't leak into
|
||||||
|
// the uniform once the material is compiled.
|
||||||
|
const r = repeat.clone();
|
||||||
|
const o = offset.clone();
|
||||||
|
material.onBeforeCompile = (shader) => {
|
||||||
|
shader.uniforms.uMapRepeat = { value: r };
|
||||||
|
shader.uniforms.uMapOffset = { value: o };
|
||||||
|
shader.vertexShader =
|
||||||
|
UNIFORM_DECLS +
|
||||||
|
shader.vertexShader.replace('#include <uv_vertex>', VERTEX_INJECT);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -8,15 +8,19 @@ import { registerViewer } from '../viewers';
|
|||||||
const TileViewer = lazy(() => import('./TileViewer'));
|
const TileViewer = lazy(() => import('./TileViewer'));
|
||||||
const TokenViewer = lazy(() => import('./TokenViewer'));
|
const TokenViewer = lazy(() => import('./TokenViewer'));
|
||||||
const CardViewer = lazy(() => import('./CardViewer'));
|
const CardViewer = lazy(() => import('./CardViewer'));
|
||||||
|
const DeckViewer = lazy(() => import('./DeckViewer'));
|
||||||
const CustomModelViewer = lazy(() => import('./CustomModelViewer'));
|
const CustomModelViewer = lazy(() => import('./CustomModelViewer'));
|
||||||
|
const PdfViewer = lazy(() => import('./PdfViewer'));
|
||||||
|
|
||||||
registerViewer('Tile', TileViewer);
|
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('Deck', CardViewer);
|
registerViewer('CardCustom', CardViewer);
|
||||||
registerViewer('DeckCustom', CardViewer);
|
registerViewer('Deck', DeckViewer);
|
||||||
registerViewer('Custom_Deck', CardViewer);
|
registerViewer('DeckCustom', DeckViewer);
|
||||||
|
registerViewer('Custom_Deck', DeckViewer);
|
||||||
registerViewer('Custom_Model', CustomModelViewer);
|
registerViewer('Custom_Model', CustomModelViewer);
|
||||||
registerViewer('Custom_Model_Bag', CustomModelViewer);
|
registerViewer('Custom_Model_Bag', CustomModelViewer);
|
||||||
registerViewer('Custom_Model_Infinite_Bag', CustomModelViewer);
|
registerViewer('Custom_Model_Infinite_Bag', CustomModelViewer);
|
||||||
|
registerViewer('Custom_PDF', PdfViewer);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { TTSObject } from '@tts/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-object tint (`ColorDiffuse`, 0–1 per channel) as a three.js color,
|
||||||
|
* defaulting to white when absent. TTS multiplies this by the object's base
|
||||||
|
* color, so textured faces are tinted too.
|
||||||
|
*/
|
||||||
|
export function objectTint(object: TTSObject): THREE.Color {
|
||||||
|
const c = object.ColorDiffuse;
|
||||||
|
return c ? new THREE.Color(c.r, c.g, c.b) : new THREE.Color(1, 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stable cache key fragment for a tint, so tinted variants don't collide. */
|
||||||
|
export function tintKey(color: THREE.Color): string {
|
||||||
|
return `${color.r},${color.g},${color.b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multiply a base color by the object's tint. */
|
||||||
|
export function tintedColor(base: THREE.Color, tint: THREE.Color): THREE.Color {
|
||||||
|
return base.clone().multiply(tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module-level caches so the full-setup view can share geometry and materials
|
||||||
|
* across many objects instead of rebuilding them per object. Keyed by a
|
||||||
|
* canonical string describing the resource, so identical objects reuse one
|
||||||
|
* instance. drei already caches textures globally by URL, so sharing the
|
||||||
|
* material on top avoids per-object material allocation for tiles/tokens with
|
||||||
|
* the same image.
|
||||||
|
*
|
||||||
|
* These caches live for the session (like drei's global texture cache) and are
|
||||||
|
* not disposed on unmount; see `docs/status/full-setup-view.md`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const geometryCache = new Map<string, THREE.BufferGeometry>();
|
||||||
|
const materialCache = new Map<string, THREE.MeshStandardMaterial>();
|
||||||
|
|
||||||
|
/** Get or create a geometry for `key`. */
|
||||||
|
export function getSharedGeometry(
|
||||||
|
key: string,
|
||||||
|
build: () => THREE.BufferGeometry,
|
||||||
|
): THREE.BufferGeometry {
|
||||||
|
let geo = geometryCache.get(key);
|
||||||
|
if (!geo) {
|
||||||
|
geo = build();
|
||||||
|
geometryCache.set(key, geo);
|
||||||
|
}
|
||||||
|
return geo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get or create a standard material for `key`. */
|
||||||
|
export function getSharedMaterial(
|
||||||
|
key: string,
|
||||||
|
params: THREE.MeshStandardMaterialParameters,
|
||||||
|
): THREE.MeshStandardMaterial {
|
||||||
|
let mat = materialCache.get(key);
|
||||||
|
if (!mat) {
|
||||||
|
mat = new THREE.MeshStandardMaterial(params);
|
||||||
|
materialCache.set(key, mat);
|
||||||
|
}
|
||||||
|
return mat;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { TTSObject } from '@tts/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a TTS object's `Transform` (position/rotation/scale in TTS world
|
||||||
|
* units) into three.js props so the full-setup view can place objects at their
|
||||||
|
* real table positions.
|
||||||
|
*
|
||||||
|
* TTS is left-handed with Y up and +Z toward the player; three.js is
|
||||||
|
* right-handed with Y up. Converting by reflecting the Z axis (negating Z in
|
||||||
|
* position) maps a TTS placement onto the three.js scene while preserving the
|
||||||
|
* physical layout. The rotation is conjugated by the reflection: `R_three =
|
||||||
|
* M·R_tts·M` with `M = diag(1,1,-1)`, which negates `rotX`/`rotY` but leaves
|
||||||
|
* `rotZ` unchanged.
|
||||||
|
*
|
||||||
|
* Our viewer meshes are authored for inspection (standing up, extruded along
|
||||||
|
* Z, sized in arbitrary units), so each class also gets a base-size correction
|
||||||
|
* and a "lay flat" rotation to match TTS world units and orientation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Degrees → radians. */
|
||||||
|
const rad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base-size correction: our viewer mesh size → TTS world units at scale 1.
|
||||||
|
* `scale` in the transform is multiplied by this so objects end up the right
|
||||||
|
* relative size on the table.
|
||||||
|
*/
|
||||||
|
const CORRECTION: Record<string, number> = {
|
||||||
|
// Card long axis is 2 units in the viewer; 1.0 in TTS.
|
||||||
|
Card: 0.5,
|
||||||
|
CardCustom: 0.5,
|
||||||
|
Deck: 0.5,
|
||||||
|
DeckCustom: 0.5,
|
||||||
|
Custom_Deck: 0.5,
|
||||||
|
// Tile is 2 units in the viewer; 1.0 in TTS.
|
||||||
|
Tile: 0.5,
|
||||||
|
Custom_Tile: 0.5,
|
||||||
|
// Token is 1.8 units in the viewer; 0.7 in TTS.
|
||||||
|
Custom_Token: 0.7 / 1.8,
|
||||||
|
// Custom models are loaded at 0.5 scale in the viewer; 1.0 in TTS.
|
||||||
|
Custom_Model: 2,
|
||||||
|
Custom_Model_Bag: 2,
|
||||||
|
Custom_Model_Infinite_Bag: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotation that lays an extruded mesh flat. The tile/token/card meshes extrude
|
||||||
|
* along +Z with their face in the XY plane (standing up); rotating −90° about
|
||||||
|
* X maps the +Z face normal to +Y, so the face points up like an object lying
|
||||||
|
* on a TTS table. Custom models are authored standing up already, so they need
|
||||||
|
* no correction.
|
||||||
|
*/
|
||||||
|
const LAY_FLAT: [number, number, number] = [-Math.PI / 2, 0, 0];
|
||||||
|
|
||||||
|
const FLAT_CLASSES = new Set([
|
||||||
|
'Card',
|
||||||
|
'CardCustom',
|
||||||
|
'Deck',
|
||||||
|
'DeckCustom',
|
||||||
|
'Custom_Deck',
|
||||||
|
'Tile',
|
||||||
|
'Custom_Tile',
|
||||||
|
'Custom_Token',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface ObjectPlacement {
|
||||||
|
position: [number, number, number];
|
||||||
|
rotation: [number, number, number];
|
||||||
|
scale: [number, number, number];
|
||||||
|
/** Rotation applied to the mesh before the TTS rotation (lay flat). */
|
||||||
|
layFlat: [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the three.js placement for an object from its `Transform`. Returns
|
||||||
|
* null when the object has no transform (shouldn't happen in a real save).
|
||||||
|
*/
|
||||||
|
export function objectPlacement(object: TTSObject): ObjectPlacement | null {
|
||||||
|
const t = object.Transform;
|
||||||
|
if (!t) return null;
|
||||||
|
const corr = CORRECTION[object.Name] ?? 1;
|
||||||
|
return {
|
||||||
|
position: [t.posX, t.posY, -t.posZ],
|
||||||
|
// Reflection conjugates the rotation: Rx/Ry negate, Rz keeps its sign.
|
||||||
|
rotation: [-rad(t.rotX), -rad(t.rotY), rad(t.rotZ)],
|
||||||
|
scale: [t.scaleX * corr, t.scaleY * corr, t.scaleZ * corr],
|
||||||
|
layFlat: FLAT_CLASSES.has(object.Name) ? LAY_FLAT : [0, 0, 0],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary view for a single bgm package: its metadata and counts of parts,
|
||||||
|
* surfaces, and setups, each linking to its collection page.
|
||||||
|
*/
|
||||||
|
export default function BgmPackagePage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const partCount = Object.keys(pkg.parts).length;
|
||||||
|
const surfaceCount = Object.keys(pkg.surfaces).length;
|
||||||
|
const setupCount = Object.keys(pkg.setups).length;
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
label: 'Parts',
|
||||||
|
count: partCount,
|
||||||
|
to: `/bgm/${pkg.meta.id}/parts`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Surfaces',
|
||||||
|
count: surfaceCount,
|
||||||
|
to: `/bgm/${pkg.meta.id}/surfaces`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Setups',
|
||||||
|
count: setupCount,
|
||||||
|
to: `/bgm/${pkg.meta.id}/setups`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[{ label: 'Board games', to: '/bgm' }, { label: pkg.meta.title ?? pkg.meta.id }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{pkg.meta.title ?? pkg.meta.id}</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{pkg.meta.designer ? `${pkg.meta.designer} · ` : ''}
|
||||||
|
{pkg.meta.players ? `${pkg.meta.players} players · ` : ''}
|
||||||
|
{pkg.meta.language}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-3">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<li key={section.label}>
|
||||||
|
<Link
|
||||||
|
to={section.to}
|
||||||
|
className="block rounded-lg border border-zinc-800 bg-zinc-900 p-3 hover:border-zinc-600"
|
||||||
|
>
|
||||||
|
<div className="font-medium">{section.label}</div>
|
||||||
|
<div className="mt-1 text-sm text-zinc-400">{section.count}</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import packages from 'virtual:bgm/packages';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists every discovered bgm package. The `virtual:bgm/packages` module's
|
||||||
|
* default export is an array of all packages the loader found in the games
|
||||||
|
* root.
|
||||||
|
*/
|
||||||
|
export default function BgmPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs crumbs={[{ label: 'Board games' }]} />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Board games</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{packages.length} package{packages.length === 1 ? '' : 's'} discovered.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{packages.map((pkg) => (
|
||||||
|
<li key={pkg.meta.id}>
|
||||||
|
<Link
|
||||||
|
to={`/bgm/${pkg.meta.id}`}
|
||||||
|
className="block rounded-lg border border-zinc-800 bg-zinc-900 p-3 hover:border-zinc-600"
|
||||||
|
>
|
||||||
|
<div className="font-medium">{pkg.meta.title ?? pkg.meta.id}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">
|
||||||
|
{pkg.meta.designer ? `${pkg.meta.designer} · ` : ''}
|
||||||
|
{pkg.meta.players ? `${pkg.meta.players} players · ` : ''}
|
||||||
|
{pkg.meta.language}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
|
{Object.keys(pkg.parts).length} parts · {Object.keys(pkg.surfaces).length} surfaces ·{' '}
|
||||||
|
{Object.keys(pkg.setups).length} setups
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Suspense, useEffect, useMemo } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { flattenObjects } from '@tts/extract';
|
||||||
|
import type { TTSObject } from '@tts/shared';
|
||||||
|
import { useModStore } from '../stores/modStore';
|
||||||
|
import { useSearchStore } from '../stores/searchStore';
|
||||||
|
import Scene from '../components/viewers/Scene';
|
||||||
|
import { TileObjectMesh } from '../components/viewers/TileMesh';
|
||||||
|
import { TokenObjectMesh } from '../components/viewers/TokenMesh';
|
||||||
|
import { CardObjectMesh } from '../components/viewers/CardMesh';
|
||||||
|
import { CustomModelMesh } from '../components/viewers/CustomModelMesh';
|
||||||
|
import { objectPlacement } from '../components/viewers/transform';
|
||||||
|
|
||||||
|
/** Object classes that carry renderable assets (mirrors `viewers/register.ts`). */
|
||||||
|
const RENDERABLE = new Set([
|
||||||
|
'Tile',
|
||||||
|
'Custom_Tile',
|
||||||
|
'Custom_Token',
|
||||||
|
'Card',
|
||||||
|
'CardCustom',
|
||||||
|
'Deck',
|
||||||
|
'DeckCustom',
|
||||||
|
'Custom_Deck',
|
||||||
|
'Custom_Model',
|
||||||
|
'Custom_Model_Bag',
|
||||||
|
'Custom_Model_Infinite_Bag',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A "full setup" view: renders every loadable object in a save into a single
|
||||||
|
* shared scene, placed at its real position from the save's `Transform`.
|
||||||
|
* Geometry and materials are shared across objects (see
|
||||||
|
* `components/viewers/sharedResources.ts`).
|
||||||
|
*/
|
||||||
|
export default function FullSetupPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { mod, loading, error, load } = useModStore();
|
||||||
|
const item = useSearchStore((s) => s.items.find((i) => i.id === id));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) load(id, item?.fileUrl);
|
||||||
|
}, [id, item?.fileUrl, load]);
|
||||||
|
|
||||||
|
const objects = useMemo(
|
||||||
|
() => (mod ? flattenObjects(mod.mod) : []),
|
||||||
|
[mod],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderable = useMemo(
|
||||||
|
() => objects.filter((o) => RENDERABLE.has(o.Name)),
|
||||||
|
[objects],
|
||||||
|
);
|
||||||
|
const skipped = objects.length - renderable.length;
|
||||||
|
|
||||||
|
const placed = useMemo(
|
||||||
|
() =>
|
||||||
|
renderable
|
||||||
|
.map((object) => ({ object, placement: objectPlacement(object) }))
|
||||||
|
.filter(
|
||||||
|
(p): p is { object: TTSObject; placement: NonNullable<ReturnType<typeof objectPlacement>> } =>
|
||||||
|
p.placement !== null,
|
||||||
|
),
|
||||||
|
[renderable],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) return <p className="text-sm text-zinc-400">Loading mod…</p>;
|
||||||
|
if (error) return <p className="text-sm text-red-400">{error}</p>;
|
||||||
|
if (!mod) return <p className="text-sm text-zinc-500">No mod loaded.</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Full setup</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{objects.length} objects · {renderable.length} rendered
|
||||||
|
{skipped > 0 ? ` · ${skipped} skipped` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Scene>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
{/* Key by index, not GUID: cards in a deck share the deck's GUID. */}
|
||||||
|
{placed.map(({ object, placement }, index) => (
|
||||||
|
<group
|
||||||
|
key={index}
|
||||||
|
position={placement.position}
|
||||||
|
rotation={placement.rotation}
|
||||||
|
scale={placement.scale}
|
||||||
|
>
|
||||||
|
<group rotation={placement.layFlat}>
|
||||||
|
<RenderObject object={object} />
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
</Scene>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch a renderable object to the mesh component for its class. */
|
||||||
|
function RenderObject({ object }: { object: TTSObject }) {
|
||||||
|
switch (object.Name) {
|
||||||
|
case 'Tile':
|
||||||
|
case 'Custom_Tile':
|
||||||
|
return <TileObjectMesh object={object} />;
|
||||||
|
case 'Custom_Token':
|
||||||
|
return <TokenObjectMesh object={object} />;
|
||||||
|
case 'Card':
|
||||||
|
case 'CardCustom':
|
||||||
|
case 'Deck':
|
||||||
|
case 'DeckCustom':
|
||||||
|
case 'Custom_Deck':
|
||||||
|
return <CardObjectMesh object={object} />;
|
||||||
|
case 'Custom_Model':
|
||||||
|
case 'Custom_Model_Bag':
|
||||||
|
case 'Custom_Model_Infinite_Bag':
|
||||||
|
return <CustomModelMesh object={object} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Suspense, useEffect, useMemo, useState } from 'react';
|
import { Suspense, useEffect, useMemo, useState } from 'react';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { buildTree, collectRefs } from '@tts/extract';
|
import { buildTree, collectRefs } from '@tts/extract';
|
||||||
import { useModStore } from '../stores/modStore';
|
import { useModStore } from '../stores/modStore';
|
||||||
import { useSearchStore } from '../stores/searchStore';
|
import { useSearchStore } from '../stores/searchStore';
|
||||||
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.
|
||||||
@@ -16,18 +15,21 @@ 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);
|
||||||
}, [id, item?.fileUrl, load]);
|
}, [id, item?.fileUrl, load]);
|
||||||
|
|
||||||
const tree = useMemo(() => (mod ? buildTree(mod) : []), [mod]);
|
const tree = useMemo(() => (mod ? buildTree(mod.mod) : []), [mod]);
|
||||||
const refs = useMemo(() => (mod ? collectRefs(mod) : []), [mod]);
|
const refs = useMemo(() => (mod ? collectRefs(mod.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>;
|
||||||
@@ -37,81 +39,48 @@ export default function ModPage() {
|
|||||||
const Viewer = selected ? resolveViewer(selected.object) : null;
|
const Viewer = selected ? resolveViewer(selected.object) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex h-full min-h-0">
|
||||||
<div>
|
{/* Independently scrolling tree; the filter bar stays pinned above it. */}
|
||||||
<h1 className="text-2xl font-semibold">Mod {id}</h1>
|
<aside className="flex h-full min-h-0 w-72 shrink-0 flex-col border-r border-zinc-800 bg-zinc-900">
|
||||||
<p className="mt-1 text-sm text-zinc-400">
|
|
||||||
{mod.GameMode} · {mod.Date} · {tree.length} objects · {refs.length}{' '}
|
|
||||||
asset refs
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href={modFileUrl(id!, item?.fileUrl)}
|
|
||||||
className="mt-3 inline-block rounded-lg bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-300"
|
|
||||||
>
|
|
||||||
Download save file
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]">
|
|
||||||
<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>
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
<section className="h-full min-h-0 flex-1 p-4">
|
||||||
{selected ? (
|
{selected && Viewer ? (
|
||||||
<>
|
/* Key by selection path so the Canvas remounts and the camera
|
||||||
<header className="mb-4">
|
refits to the newly selected object. */
|
||||||
<span
|
<ErrorBoundary key={selectedPath}>
|
||||||
title={selected.object.Name}
|
|
||||||
className="inline-flex items-center gap-1 text-zinc-400"
|
|
||||||
>
|
|
||||||
{iconsForObject(selected.object.Name).map((icon) => (
|
|
||||||
<Icon key={icon} icon={icon} className="h-5 w-5" />
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<h2 className="mt-1 text-lg font-semibold">{selected.label}</h2>
|
|
||||||
<p className="font-mono text-xs text-zinc-500">
|
|
||||||
{selected.object.GUID}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
{Viewer && (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex h-80 items-center justify-center text-sm text-zinc-500">
|
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||||
Loading viewer…
|
Loading viewer…
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Viewer object={selected.object} />
|
<Viewer object={selected.object} fill />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-zinc-500">
|
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||||
Select an object from the tree to inspect it.
|
Select an object from the tree to inspect it.
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { PartView, ErrorBoundary } from '@tts/tabletop';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import Scene from '../components/viewers/Scene';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/** Detail view for a single part within a package. */
|
||||||
|
export default function PartPage() {
|
||||||
|
const { id, type, part } = useParams<{ id: string; type: string; part: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const found = pkg.parts[`${type}#${part}`];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Parts', to: `/bgm/${pkg.meta.id}/parts` },
|
||||||
|
{ label: `${type}#${part}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!found ? (
|
||||||
|
<p className="text-sm text-zinc-400">No part “{type}#{part}”.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{found.type}#{found.id}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{found.size ? `size ${found.size.join('×')}mm` : ''}
|
||||||
|
{found.fillet ? ` · fillet ${found.fillet}mm` : ''}
|
||||||
|
</p>
|
||||||
|
</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">
|
||||||
|
{JSON.stringify(found, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All parts of a package, grouped by part type. Each part links to its
|
||||||
|
* `/bgm/:id/parts/:type/:part` detail page.
|
||||||
|
*/
|
||||||
|
export default function PartsPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const parts = Object.values(pkg.parts);
|
||||||
|
const byType = new Map<string, typeof parts>();
|
||||||
|
for (const part of parts) {
|
||||||
|
const list = byType.get(part.type) ?? [];
|
||||||
|
list.push(part);
|
||||||
|
byType.set(part.type, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Parts' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Parts</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">{parts.length} part{parts.length === 1 ? '' : 's'}.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{[...byType.entries()].map(([type, list]) => (
|
||||||
|
<section key={type} className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold">{type}</h2>
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{list.map((part) => (
|
||||||
|
<li key={`${part.type}#${part.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
||||||
|
<Link
|
||||||
|
to={`/bgm/${pkg.meta.id}/parts/${part.type}/${part.id}`}
|
||||||
|
className="font-medium hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
{part.id}
|
||||||
|
</Link>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">
|
||||||
|
{part.size ? `size ${part.size.join('×')}mm` : ''}
|
||||||
|
{part.fillet ? ` · fillet ${part.fillet}mm` : ''}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import TabletopScene from '../components/tabletop/TabletopScene';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/** Detail view for a single setup within a package. */
|
||||||
|
export default function SetupPage() {
|
||||||
|
const { id, type, setup } = useParams<{ id: string; type: string; setup: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const found = pkg.setups[`${type}#${setup}`];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Setups', to: `/bgm/${pkg.meta.id}/setups` },
|
||||||
|
{ label: `${type}#${setup}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!found ? (
|
||||||
|
<p className="text-sm text-zinc-400">No setup “{type}#{setup}”.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{found.type}#{found.id}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{Object.keys(found.setup).length} path
|
||||||
|
{Object.keys(found.setup).length === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<TabletopScene pkg={pkg} setup={found} />
|
||||||
|
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
|
||||||
|
{JSON.stringify(found.setup, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All setups of a package. Each setup links to its
|
||||||
|
* `/bgm/:id/setups/:type/:setup` detail page.
|
||||||
|
*/
|
||||||
|
export default function SetupsPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const setups = Object.values(pkg.setups);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Setups' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Setups</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{setups.length} setup{setups.length === 1 ? '' : 's'}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{setups.map((setup) => (
|
||||||
|
<li key={`${setup.type}#${setup.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
||||||
|
<Link
|
||||||
|
to={`/bgm/${pkg.meta.id}/setups/${setup.type}/${setup.id}`}
|
||||||
|
className="font-medium hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
{setup.id}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/** Detail view for a single surface within a package. */
|
||||||
|
export default function SurfacePage() {
|
||||||
|
const { id, type, surface } = useParams<{ id: string; type: string; surface: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const found = pkg.surfaces[`${type}#${surface}`];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Surfaces', to: `/bgm/${pkg.meta.id}/surfaces` },
|
||||||
|
{ label: `${type}#${surface}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!found ? (
|
||||||
|
<p className="text-sm text-zinc-400">No surface “{type}#{surface}”.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{found.type}#{found.id}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{found.size ? `size ${found.size.join('×')}mm` : ''} · {found.layout.length} routes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<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)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All surfaces of a package. Each surface links to its
|
||||||
|
* `/bgm/:id/surfaces/:type/:surface` detail page.
|
||||||
|
*/
|
||||||
|
export default function SurfacesPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const surfaces = Object.values(pkg.surfaces);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Surfaces' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Surfaces</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{surfaces.length} surface{surfaces.length === 1 ? '' : 's'}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{surfaces.map((surface) => (
|
||||||
|
<li key={`${surface.type}#${surface.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
||||||
|
<Link
|
||||||
|
to={`/bgm/${pkg.meta.id}/surfaces/${surface.type}/${surface.id}`}
|
||||||
|
className="font-medium hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
{surface.id}
|
||||||
|
</Link>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">
|
||||||
|
{surface.size ? `size ${surface.size.join('×')}mm` : ''} · {surface.layout.length} routes
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import packages from 'virtual:bgm/packages';
|
||||||
|
import type { SerializedPackage } from '@tts/bgm';
|
||||||
|
|
||||||
|
/** Look up a bgm package by id from the `virtual:bgm/packages` module. */
|
||||||
|
export function findPackage(id: string | undefined): SerializedPackage | undefined {
|
||||||
|
return packages.find((p) => p.meta.id === id);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { TTSMod } from '@tts/shared';
|
import type { ModDetails } from '@tts/shared';
|
||||||
import { fetchMod } from '../api';
|
import { fetchMod } from '../api';
|
||||||
|
|
||||||
interface ModState {
|
interface ModState {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
fileUrl?: string;
|
fileUrl?: string;
|
||||||
mod: TTSMod | null;
|
mod: ModDetails | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
load: (id: string, fileUrl?: string) => Promise<void>;
|
load: (id: string, fileUrl?: string) => Promise<void>;
|
||||||
@@ -21,11 +21,11 @@ export const useModStore = create<ModState>((set) => ({
|
|||||||
load: async (id, fileUrl) => {
|
load: async (id, fileUrl) => {
|
||||||
set({ loading: true, error: null, id, fileUrl });
|
set({ loading: true, error: null, id, fileUrl });
|
||||||
try {
|
try {
|
||||||
const mod = await fetchMod(id, fileUrl);
|
const details = await fetchMod(id, fileUrl);
|
||||||
set({ mod, loading: false });
|
set({ mod: details, loading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ loading: false, error: String(err) });
|
set({ loading: false, error: String(err) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
clear: () => set({ id: null, fileUrl: undefined, mod: null, error: null, loading: false }),
|
clear: () => set({ id: null, fileUrl: undefined, mod: null, loading: false, error: null }),
|
||||||
}));
|
}));
|
||||||
Vendored
+18
@@ -1 +1,19 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ambient types for the virtual `bgm` modules served by the bgm vite plugin.
|
||||||
|
*
|
||||||
|
* - `virtual:bgm/packages` — every discovered package.
|
||||||
|
* - `virtual:bgm/package/<id>` — a single package's assembled JSON.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { bgm } from '@tts/bgm';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
bgm({ root: fileURLToPath(new URL('../../games', import.meta.url)) }),
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
// Proxy API calls to the Hono backend during development.
|
// Proxy API calls to the Hono backend during development.
|
||||||
proxy: {
|
proxy: {
|
||||||
@@ -11,6 +17,8 @@ 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',
|
||||||
|
'/pdf': 'http://localhost:3000',
|
||||||
|
'/trace': 'http://localhost:3000',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
+13
-7
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
> **Scope:** The system's architecture and dependency graph. For implementation
|
> **Scope:** The system's architecture and dependency graph. For implementation
|
||||||
> details (files, endpoints, build order), see
|
> details (files, endpoints, build order), see
|
||||||
> [`implementation-plan.md`](./implementation-plan.md). For the rationale behind
|
> [`status/implementation-plan.md`](./status/implementation-plan.md). For the
|
||||||
> key decisions, see [`decisions.md`](./decisions.md).
|
> rationale behind key decisions, see [`decisions.md`](./decisions.md).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -31,6 +31,8 @@ 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/engine` | Message bus, queue/tick, triggers, orchestrators | Isomorphic |
|
||||||
| `packages/shared` | Shared types + zod schemas | Isomorphic |
|
| `packages/shared` | Shared types + zod schemas | Isomorphic |
|
||||||
|
|
||||||
## Dependency graph
|
## Dependency graph
|
||||||
@@ -40,6 +42,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,8 +53,10 @@ 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 (the filename is derived from the save URL path).
|
||||||
- **`apps/proxy` → `packages/shared`** — uses shared types and zod schemas for
|
- **`apps/proxy` → `packages/shared`** — uses shared types and zod schemas for
|
||||||
request/response validation.
|
request/response validation.
|
||||||
- **`packages/tts` → `packages/shared`** — consumes `TTSMod` / `TTSObject`
|
- **`packages/tts` → `packages/shared`** — consumes `TTSMod` / `TTSObject`
|
||||||
@@ -82,13 +87,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 +103,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.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# bgm-commands
|
||||||
|
|
||||||
|
Command execution for [bgm](./format.md) board games, built into
|
||||||
|
[`@tts/tabletop`](./tabletop.md). A command is a unit of scripted
|
||||||
|
interaction — focus the camera, wait for a tap, move a part, show a caption —
|
||||||
|
that runs against the tabletop state store and render layer.
|
||||||
|
|
||||||
|
This doc covers **command execution**: the async lifecycle, run contexts, and
|
||||||
|
tap interaction. The message layer above this — how commands are *declared*
|
||||||
|
and *fired* (triggers, orchestrators, the message queue) — is specified in
|
||||||
|
[`engine.md`](./engine.md). Commands are async functions registered
|
||||||
|
with the engine's handler registry; `@tts/tabletop` provides the concrete
|
||||||
|
commands that mutate the tabletop store and render layer.
|
||||||
|
|
||||||
|
## 1. async commands
|
||||||
|
|
||||||
|
A command is an async function that returns a result. Every command ends in
|
||||||
|
one of three states, emitted as a message discriminated on the type suffix
|
||||||
|
(see `engine.md` §3):
|
||||||
|
|
||||||
|
- `:done` — completed normally.
|
||||||
|
- `:cancel` — interrupted (a newer command superseded it, the user skipped,
|
||||||
|
the surface was disabled). **Not a failure.**
|
||||||
|
- `:error` — genuinely failed (asset missing, bad path, a thrown exception).
|
||||||
|
|
||||||
|
`cancel` is distinct from `error`: a superseded or skipped command stops
|
||||||
|
cleanly, while a broken command surfaces loudly. The runtime treats them
|
||||||
|
differently — a script that is superseded unwinds without alarming the player,
|
||||||
|
but an `error` is reported.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CommandResult<Name extends string, R = void> =
|
||||||
|
| { type: `${Name}:done`; data: R }
|
||||||
|
| { type: `${Name}:cancel` }
|
||||||
|
| { type: `${Name}:error`; error: Error };
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. run contexts
|
||||||
|
|
||||||
|
Each command invocation creates its own **run context**: the unit of
|
||||||
|
cancellation and the carrier of command-specific state.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CommandRun {
|
||||||
|
id: string;
|
||||||
|
command: Command;
|
||||||
|
status: 'running' | 'ok' | 'cancel' | 'error';
|
||||||
|
data: unknown; // command-specific state, e.g. a pending tap target
|
||||||
|
cancel(): void;
|
||||||
|
done: Promise<CommandResult>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A command **owns its own state and its own waiting**; the runtime only
|
||||||
|
orchestrates. Its job is to start a run, track its status, cancel it when
|
||||||
|
superseded, and react to its terminal state. This keeps commands
|
||||||
|
self-contained and testable in isolation.
|
||||||
|
|
||||||
|
## 3. fire-and-forget vs self-managed waiting
|
||||||
|
|
||||||
|
Commands fall into two categories:
|
||||||
|
|
||||||
|
- **Fire-and-forget** (`focus`, `highlight`, `caption`) — start and return
|
||||||
|
`ok` immediately (or when their tween settles). The runtime does not block
|
||||||
|
on them.
|
||||||
|
- **Self-managed waiting** (`wait: tap`, a dialog) — the command resolves its
|
||||||
|
own promise when its condition is met. The runtime just awaits it.
|
||||||
|
|
||||||
|
Fire-and-forget commands still get a run context and a cancel path. A `focus`
|
||||||
|
tween superseded by a newer `focus` must be cancellable, or two cameras fight.
|
||||||
|
"Fire-and-forget" means the runtime doesn't await it, not that it has no
|
||||||
|
lifecycle.
|
||||||
|
|
||||||
|
**Supersede groups** cancel a running command when another in the same group
|
||||||
|
starts. A `focus` command belongs to a `camera` group, so a second `focus`
|
||||||
|
cancels the first. A superseded command's `signal` is aborted, and it emits
|
||||||
|
`:cancel`.
|
||||||
|
|
||||||
|
A command is an async function taking the `RunContext` (with its `args`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Command<Args, Result> = (ctx: RunContext & { args: Args }) => Promise<Result>;
|
||||||
|
```
|
||||||
|
|
||||||
|
The engine wraps it: it builds the context from the message, runs the function,
|
||||||
|
and emits `:done` on resolve, `:cancel` on abort, `:error` on throw.
|
||||||
|
|
||||||
|
## 4. tap interaction
|
||||||
|
|
||||||
|
Only tap interaction is supported. A tap on a part is detected and reported to
|
||||||
|
the command layer as a `TapEvent`. Parts may declare **trigger points** —
|
||||||
|
named, circular regions the author wants to be tappable.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TriggerPoint {
|
||||||
|
id: string;
|
||||||
|
position: [number, number]; // part-local frame, mm
|
||||||
|
radius: number; // mm
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TapEvent {
|
||||||
|
part: string; // package:type#id
|
||||||
|
position: [number, number]; // part-local frame, mm
|
||||||
|
trigger: TriggerPoint | null; // nearest within radius, or null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Trigger points are authored in the **part's local frame** (mm, relative to
|
||||||
|
the part's origin), not world space. A part moves, rotates, and flips
|
||||||
|
(facing), so a world-space point would break the moment it moves. The tap
|
||||||
|
point is transformed into the part's local frame at tap time.
|
||||||
|
- Distance is measured in the part's plane. The reported trigger point is the
|
||||||
|
nearest one within its `radius`; ties go to the first declared.
|
||||||
|
- **Every tap on the part is reported**, with the nearest trigger point (or
|
||||||
|
`null` when none is in range). The command decides how to react — resolve,
|
||||||
|
reject with a "wrong spot" shake, or ignore. The runtime stays dumb; the
|
||||||
|
command owns the UX.
|
||||||
|
|
||||||
|
Commands subscribe to the tap stream via the context and unsubscribe on
|
||||||
|
cancel, so a cancelled `wait: tap` never leaks a handler.
|
||||||
|
|
||||||
|
## 5. run context
|
||||||
|
|
||||||
|
The context a command receives is the handle to everything it can affect. The
|
||||||
|
engine defines the base `RunContext` (see `engine.md` §5): `signal`
|
||||||
|
(cancellation), `emit`, `wait`, and `enableTrigger`/`disableTrigger`. Tabletop
|
||||||
|
extends it with the handles commands need to mutate the board:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TabletopRunContext extends RunContext {
|
||||||
|
pkg: Package;
|
||||||
|
store: TabletopStore; // movePart, setPart, enableSurface, ...
|
||||||
|
onTap(handler: (e: TapEvent) => void): () => void; // returns unsubscribe
|
||||||
|
// camera, highlight, and overlay handles are added as those subsystems land
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. where trigger points come from
|
||||||
|
|
||||||
|
The tap detector reads trigger points from a runtime map keyed by part id; it
|
||||||
|
does not care where they are declared. Declaration (on the part definition, in
|
||||||
|
a setup, or in a script) is the deferred "how to declare" half and lives in
|
||||||
|
`format.md`.
|
||||||
|
|
||||||
|
## Open decisions
|
||||||
|
|
||||||
|
- **Where commands are declared** — the `script` role and its schema
|
||||||
|
(`format.md`), deferred.
|
||||||
|
- **Animation** — a general "ease toward target placement" layer (preferred)
|
||||||
|
vs explicit per-move tweens.
|
||||||
|
- **Camera** — `CameraControls` (drei) vs hand-rolled.
|
||||||
|
- **Triggering** — does a setup reference a script to auto-run, or is a script
|
||||||
|
a separate page the player picks?
|
||||||
|
- **Narration** — pre-recorded audio assets per script, or TTS at runtime?
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
# bgm-engine
|
||||||
|
|
||||||
|
The message layer that drives [bgm](./format.md) board games, built into
|
||||||
|
[`@tts/engine`](../architecture.md). It unifies the two halves of scripted
|
||||||
|
interaction — *declaring* what should happen and *executing* it — into a single
|
||||||
|
reactive loop: **messages** flow through a **queue**, and **handlers** react to
|
||||||
|
them.
|
||||||
|
|
||||||
|
This doc covers the message model (what flows), the queue and its tick (how it
|
||||||
|
flows), and the handlers (who reacts). Command *execution* — the async
|
||||||
|
lifecycle, run contexts, and tap interaction — is specified in
|
||||||
|
[`commands.md`](./commands.md); this doc is the layer above it.
|
||||||
|
|
||||||
|
## package split
|
||||||
|
|
||||||
|
The engine is a **pure** package: the message bus, queue, tick, trigger
|
||||||
|
registry, and the handler runner. It has no r3f, no React, and no store, so it
|
||||||
|
is node-testable in isolation (mirroring `@tts/extract`'s isomorphic, zero-dep
|
||||||
|
style). It defines the contract — `Message`, the handler registry, `Trigger`,
|
||||||
|
`Orchestrator`, and `RunContext`.
|
||||||
|
|
||||||
|
[`@tts/tabletop`](./tabletop.md) is the intended consumer of that contract:
|
||||||
|
it will register the built-in commands (`move`, `focus`, `caption`,
|
||||||
|
`enableSurface`, ...) that mutate the tabletop store and drive the render layer.
|
||||||
|
The engine never imports tabletop, and tabletop is expected to depend on the
|
||||||
|
engine for the message types and the handler registry. **This wiring is designed
|
||||||
|
but not yet landed** — today `@tts/tabletop` has no dependency on the engine;
|
||||||
|
it ships its state store and render layer standalone (see
|
||||||
|
[`../status/bgm-tabletop.md`](../status/bgm-tabletop.md)). A headless sim or bot
|
||||||
|
harness can consume the engine without the render layer.
|
||||||
|
|
||||||
|
## 1. messages
|
||||||
|
|
||||||
|
A **message** is the unit of communication. It is both an *event* (something
|
||||||
|
happened) and an *intent* (something should happen) — the two are the same
|
||||||
|
thing. A message is dispatched to the handlers registered for its `type`; a
|
||||||
|
handler may emit new messages in response.
|
||||||
|
|
||||||
|
Messages are a **discriminated union** on `type`. The engine defines the
|
||||||
|
generic shapes; the host's concrete union extends them with its own command
|
||||||
|
types.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TapMessage {
|
||||||
|
type: 'tap';
|
||||||
|
data: TapEvent; // part, position, trigger
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandMessage<Name extends string, Args> {
|
||||||
|
type: Name;
|
||||||
|
data: Args;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A message is identified by `type`, matching the format's `type#id`
|
||||||
|
convention. A `move` message both *runs* the move command and is *observable*
|
||||||
|
as an event; the command's completion is itself a message, which is what
|
||||||
|
triggers match and orchestrators await.
|
||||||
|
|
||||||
|
The loop is just: **message → handler → message**. Handlers consume messages
|
||||||
|
and emit new ones; the queue serializes them.
|
||||||
|
|
||||||
|
## 2. the queue and ticking
|
||||||
|
|
||||||
|
Messages are not processed inline. They are **enqueued** and handled on the
|
||||||
|
next **tick**. This kills reentrancy (a handler cannot cause unbounded
|
||||||
|
recursion), gives a natural debounce, and makes the whole system a
|
||||||
|
deterministic frame.
|
||||||
|
|
||||||
|
### tick contract
|
||||||
|
|
||||||
|
The engine is pure — it has no render loop and must stay node-testable. It
|
||||||
|
exposes `tick()`, and the host calls it:
|
||||||
|
|
||||||
|
- In `@tts/tabletop`, a `useFrame` drives `tick()`.
|
||||||
|
- In tests, `tick()` is called manually.
|
||||||
|
|
||||||
|
The engine never assumes a render loop.
|
||||||
|
|
||||||
|
### drain semantics
|
||||||
|
|
||||||
|
- **Snapshot-and-drain.** At `tick()`, snapshot the queue and process it.
|
||||||
|
Messages emitted *during* the drain go to the *next* tick. This guarantees
|
||||||
|
no reentrancy within a drain and makes ordering deterministic.
|
||||||
|
- **FIFO within a tick.** Simple and predictable.
|
||||||
|
- **One tick drains the whole snapshot** (not one message per tick), so a
|
||||||
|
burst of messages all resolve in one frame.
|
||||||
|
|
||||||
|
### awaiting
|
||||||
|
|
||||||
|
A handler suspends on `await ctx.wait(pred)` and resumes when a matching
|
||||||
|
message is processed during a drain. Its own emissions go to the next tick, so
|
||||||
|
it cannot re-enter itself.
|
||||||
|
|
||||||
|
## 3. message types
|
||||||
|
|
||||||
|
### interaction messages
|
||||||
|
|
||||||
|
Interaction is the player's input, reported to the engine as messages. Only
|
||||||
|
tap interaction is supported (see `commands.md` §4).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TapMessage {
|
||||||
|
type: 'tap';
|
||||||
|
data: TapEvent;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A tap on a part is reported with the nearest trigger point (or `null` on a
|
||||||
|
miss). The handler decides how to react — resolve, reject with a "wrong spot"
|
||||||
|
shake, or ignore. The runtime stays dumb; the handler owns the UX.
|
||||||
|
|
||||||
|
### command messages
|
||||||
|
|
||||||
|
A command message names a command to run. Its handler is the command
|
||||||
|
implementation; its completion is emitted as a result message. A command's
|
||||||
|
result is a **discriminated union on the type suffix**, carrying the terminal
|
||||||
|
state:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CommandResult<Name extends string, R = void> =
|
||||||
|
| { type: `${Name}:done`; data: R }
|
||||||
|
| { type: `${Name}:cancel` }
|
||||||
|
| { type: `${Name}:error`; error: Error };
|
||||||
|
|
||||||
|
// e.g. move:done { data: MoveResult } | move:cancel | move:error
|
||||||
|
```
|
||||||
|
|
||||||
|
The command-id-as-key convention means a message both *is* the intent and
|
||||||
|
*observes* the result. `move:done`, `focus:done`, etc. are the messages that
|
||||||
|
triggers match and orchestrators await. A cancelled command emits `:cancel`, an
|
||||||
|
errored one `:error` — a trigger matching `move:done` does not fire on a
|
||||||
|
cancel.
|
||||||
|
|
||||||
|
## 4. handlers
|
||||||
|
|
||||||
|
There are three kinds of handler. All three consume messages and emit
|
||||||
|
messages; they differ in how they're declared and how they run.
|
||||||
|
|
||||||
|
| Handler | Declared | Runs | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **Trigger** | data (yaml) | synchronously on match | declarative reactive glue |
|
||||||
|
| **Orchestrator** | code (`main.ts`) | async, awaits | imperative flow |
|
||||||
|
| **Command** | code (built-in) | async, on its message | atomic execution |
|
||||||
|
|
||||||
|
### triggers — declarative reactive glue
|
||||||
|
|
||||||
|
A trigger matches a message by `type` and named params, and emits messages in
|
||||||
|
response. It is declared as data, keyed by `role+type+id` like other defs, and
|
||||||
|
collision-checked the same way.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
role: trigger
|
||||||
|
type: tap
|
||||||
|
id: draw
|
||||||
|
match:
|
||||||
|
part: carcassonne:tile#a
|
||||||
|
trigger: draw
|
||||||
|
emit:
|
||||||
|
- move: { part: carcassonne:tile#a, to: /grid/5/5 }
|
||||||
|
- focus: { path: /grid/5/5 }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `type` selects the message kind; `match` binds named params from the
|
||||||
|
payload (like a route's candidates).
|
||||||
|
- `emit` uses the command-id-as-key convention.
|
||||||
|
- Multiple triggers can match the same message — both fire, which is usually
|
||||||
|
what you want.
|
||||||
|
- A trigger is a **pre-registered handler**: it's a message consumer that
|
||||||
|
emits commands. An orchestrator can do the same thing imperatively with
|
||||||
|
`ctx.on(...)`.
|
||||||
|
|
||||||
|
### orchestrators — imperative async flow
|
||||||
|
|
||||||
|
An orchestrator is the code counterpart to a trigger: an async function that
|
||||||
|
emits messages and awaits matching ones. It is a proper TS module, declared
|
||||||
|
per folder as `main.ts` — unique per folder like `package.yaml`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// main.ts
|
||||||
|
export default async function main(ctx: RunContext): Promise<void> {
|
||||||
|
await ctx.focus({ path: '/deck' });
|
||||||
|
await ctx.caption({ text: 'Draw a tile' });
|
||||||
|
const tap = await ctx.wait((m) => m.type === 'tap');
|
||||||
|
await ctx.move({ part: tap.data.part, to: '/grid/5/5' });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`main.ts` is executable code, loaded by the host, not the engine.** The
|
||||||
|
engine defines the contract (the orchestrator type and runner); the host
|
||||||
|
dynamically imports `main.ts` and hands the exported orchestrator to the
|
||||||
|
engine. The engine never imports user code.
|
||||||
|
- **A default export async function.** `main.ts` exports a single async
|
||||||
|
function as its default export, taking the `RunContext`. It is the folder's
|
||||||
|
orchestrator.
|
||||||
|
- **Trigger control lives here.** The orchestrator toggles triggers at runtime
|
||||||
|
by their `type#id`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
ctx.enableTrigger('tap', 'draw');
|
||||||
|
ctx.disableTrigger('tap', 'draw');
|
||||||
|
```
|
||||||
|
|
||||||
|
Declaration is data; activation is code. The orchestrator owns game-flow
|
||||||
|
logic ("no more placements this turn" → disable the trigger), while the
|
||||||
|
trigger stays a dumb declarative mapping.
|
||||||
|
|
||||||
|
### commands — atomic execution
|
||||||
|
|
||||||
|
A command is an async function, the same shape as an orchestrator. It takes a
|
||||||
|
`RunContext` (with its `args`), returns its result, and throws on error. The
|
||||||
|
engine wraps it: it builds the context from the message, runs the function, and
|
||||||
|
emits the result message — `:done` on resolve, `:cancel` on abort, `:error` on
|
||||||
|
throw.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Command<Args, Result> = (ctx: RunContext & { args: Args }) => Promise<Result>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Commands are the **single mutation path** — the only way state changes.
|
||||||
|
Triggers and orchestrators never mutate state directly; they emit command
|
||||||
|
messages, and the command handlers execute them.
|
||||||
|
|
||||||
|
## 5. run context
|
||||||
|
|
||||||
|
Every handler runs against a `RunContext`, the handle to everything it can
|
||||||
|
affect and the unit of cancellation.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface RunContext {
|
||||||
|
signal: AbortSignal; // cancellation: superseded, skipped, surface disabled
|
||||||
|
emit(msg: Message): void;
|
||||||
|
wait(pred: (m: Message) => boolean): Promise<Message>; // rejects on abort
|
||||||
|
enableTrigger(type: string, id?: string): void;
|
||||||
|
disableTrigger(type: string, id?: string): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Cancellation** is an `AbortSignal`. A superseded command or a disabled
|
||||||
|
surface aborts the signal; a `wait` rejects on abort, and the command's
|
||||||
|
`:cancel` result is emitted.
|
||||||
|
- **Errors** are thrown. A command that throws emits `:error`; an orchestrator
|
||||||
|
that throws surfaces loudly.
|
||||||
|
- Commands and orchestrators are the same shape: an async function taking the
|
||||||
|
context. An orchestrator is a command that returns `void` and is never
|
||||||
|
awaited by a parent.
|
||||||
|
|
||||||
|
## 6. solo-only
|
||||||
|
|
||||||
|
This design is **solo-only** — no multiplayer. Other players either don't
|
||||||
|
exist or are automated with an automata. An automata is just another message
|
||||||
|
consumer that emits commands: a stateful trigger or orchestrator. The engine
|
||||||
|
doesn't care whether a `tap` message came from a human or a bot decision —
|
||||||
|
same queue, same handlers. Solo-only simplifies the design: no network, no
|
||||||
|
sync, no authoritative-server concerns. "Other players" are just more message
|
||||||
|
producers.
|
||||||
|
|
||||||
|
## Open decisions
|
||||||
|
|
||||||
|
- **`main.ts` loading.** The host dynamically imports `main.ts`; the exact
|
||||||
|
loading boundary (Vite dynamic import, error handling, HMR) is deferred to
|
||||||
|
implementation. The engine defines the orchestrator type; the host loads the
|
||||||
|
module and hands the exported orchestrator to the engine.
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
# Board Game Manifest — Technical Reference
|
||||||
|
|
||||||
|
> The concrete behavior of the board game manifest (bgm) format.
|
||||||
|
>
|
||||||
|
> Definitions can live in JSON/YAML/TOML files or in markdown code blocks. In
|
||||||
|
> codeblock mode, each code block is a virtual definition file, named relative
|
||||||
|
> to the current markdown file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. json features
|
||||||
|
|
||||||
|
### The `$variants` directive
|
||||||
|
|
||||||
|
For objects with a `$variants` key, the value is a CSV. Parse it into an object
|
||||||
|
array with `typed-csv`, extend the original object with each row, and return
|
||||||
|
the array.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
job: 'hero'
|
||||||
|
$variants: ./heroes.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
```csv
|
||||||
|
name,parents
|
||||||
|
string,string[]
|
||||||
|
clark,[jonathan;martha]
|
||||||
|
bruce,[]
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "job": "hero", "name": "clark", "parents": ["jonathan", "martha"] },
|
||||||
|
{ "job": "hero", "name": "bruce", "parents": [] }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inline vs file
|
||||||
|
|
||||||
|
`$variants` can be a single source or an array of sources. Each source is a
|
||||||
|
file/URL path if its first line ends in `.csv`, otherwise it is inline CSV.
|
||||||
|
This keeps the two forms self-documenting and applies the same rule to single
|
||||||
|
values and array elements alike. In YAML a block scalar (`|`) is the natural
|
||||||
|
way to write inline CSV; in JSON you'd use `\n`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
$variants: |
|
||||||
|
id,name,faceCrop
|
||||||
|
string,string,[number;number;number;number]
|
||||||
|
fish,Fish,[0;0;5;2]
|
||||||
|
grain,Grain,[1;0;5;2]
|
||||||
|
```
|
||||||
|
|
||||||
|
An array of sources concatenates their rows. This lets one part definition
|
||||||
|
pull from several CSVs with different schemas — e.g. a deck where the regular
|
||||||
|
cards share a face sheet but the jokers have their own:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
$variants:
|
||||||
|
- ./cards.csv
|
||||||
|
- ./jokers.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
Each source is parsed with its own schema, and its rows extend the original
|
||||||
|
object independently.
|
||||||
|
|
||||||
|
### CSV conventions
|
||||||
|
|
||||||
|
CSV is parsed with `typed-csv`:
|
||||||
|
|
||||||
|
- The first row is the header, the second row is the type declaration
|
||||||
|
(`string`, `number`, `string[]`, ...), and the remaining rows are data.
|
||||||
|
- Rows are validated against a zod schema derived from the type row.
|
||||||
|
- **`crop` inside a CSV cell** uses `;` as the element separator
|
||||||
|
(`[0;0;5;2]`), because `,` is the CSV delimiter. `typed-csv` loads it into
|
||||||
|
an array with value `[0,0,5,2]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Definition discovery
|
||||||
|
|
||||||
|
Definitions are organized in **packages**. A loader loads a package
|
||||||
|
declaration, then uses its `include` paths to find the definitions.
|
||||||
|
|
||||||
|
### Code blocks as virtual files
|
||||||
|
|
||||||
|
A code block is a virtual definition file. Its name is derived from the
|
||||||
|
`role=` on its info string — `role.type.lang` — so it is discoverable by the
|
||||||
|
default `include: ./**/*.yaml` and addressable by that name:
|
||||||
|
|
||||||
|
````md
|
||||||
|
```yaml role=part.cargo
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml role=surface.game#main
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml role=package
|
||||||
|
...
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
- `role=part.cargo` names the block `part.cargo.yaml`.
|
||||||
|
- `role=surface.game#main` names it `surface.game.yaml`.
|
||||||
|
- `role=package` names it `package.yaml`.
|
||||||
|
- The name is what `$variants: ./cargo.csv` and `include: parts/*.yaml`
|
||||||
|
resolve against. When there is a real file in that path, the codeblock wins.
|
||||||
|
- `file=` overrides the auto-name when present, e.g. `file=parts/cargo.yaml`
|
||||||
|
names the block `parts/cargo.yaml` regardless of its role.
|
||||||
|
- A block without `role=` is **not** a definition — it is ignored. Discovery
|
||||||
|
is explicit: a block is a definition only when its `role=` (or, for real
|
||||||
|
files, its filename) declares a known `role.type`.
|
||||||
|
|
||||||
|
### role= on the info string
|
||||||
|
|
||||||
|
A block's role is declared on the info string, using the same `role.type#id`
|
||||||
|
shape as the block's identity. `type` and `id` are optional — anything not
|
||||||
|
given comes from the content (or from `$variants` rows):
|
||||||
|
|
||||||
|
````md
|
||||||
|
```yaml role=part.cargo
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml role=surface.game#main
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml role=package
|
||||||
|
...
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
- `role=part.cargo` declares a part of type `cargo`; its `id` comes from the
|
||||||
|
content or from `$variants`.
|
||||||
|
- `role=surface.game#main` declares a surface of type `game` with id `main`.
|
||||||
|
- `role=package` declares a package; it has no type.
|
||||||
|
- A `role`/`type`/`id` given on the info string **conflicts** with the same
|
||||||
|
key in the content and errors. `id` on the info string cannot combine with
|
||||||
|
`$variants`, since every row supplies its own `id`.
|
||||||
|
- A block without `role=` is not a definition — discovery is explicit (see
|
||||||
|
above).
|
||||||
|
|
||||||
|
### Real files
|
||||||
|
|
||||||
|
A real `role.type.lang` file (e.g. `part.cargo.yaml`) is a definition by its
|
||||||
|
filename, with no `role=` needed. `role` and `type` are parsed from the name;
|
||||||
|
`id` comes from the content or `$variants`. A real file and a code block with
|
||||||
|
the same name are the same definition; the code block wins.
|
||||||
|
|
||||||
|
### Duplicates
|
||||||
|
|
||||||
|
Two definitions with the same `role.type` are grouped under the same name.
|
||||||
|
They must not define the same `id` — a duplicate `type#id` errors. Blocks with
|
||||||
|
the same `role.type` but different ids are fine.
|
||||||
|
|
||||||
|
### include
|
||||||
|
|
||||||
|
`include` is a list of git-style path patterns — the defs that make up the
|
||||||
|
package. **Defaults to `./**/*.yaml`**, so all yaml in the same and sub
|
||||||
|
folders is discovered with no configuration. This also matches the package
|
||||||
|
declaration itself, which is fine — it's the package, not a part.
|
||||||
|
|
||||||
|
Patterns are resolved **relative to the package declaration's own directory**,
|
||||||
|
not the games root. So a package declared in `carcassonne/carcassonne.md`
|
||||||
|
with the default `./**/*.yaml` only picks up yaml under `carcassonne/` — it
|
||||||
|
never absorbs defs from a sibling game. To reach outside its folder, a
|
||||||
|
package can use a `../`-relative pattern or an absolute-from-root pattern
|
||||||
|
(e.g. `**/shared/*.yaml`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Roles
|
||||||
|
|
||||||
|
json objects in yaml blocks are handled if they are declared as a definition
|
||||||
|
by their `role=` (or, for real files, their filename) for either
|
||||||
|
- `package`
|
||||||
|
- `part`
|
||||||
|
- `surface`
|
||||||
|
- `setup`
|
||||||
|
- `dialog`
|
||||||
|
|
||||||
|
a valid object can either be the root or in the list of the yaml block.
|
||||||
|
|
||||||
|
for all roles except package, `type` and `id` are needed.
|
||||||
|
`type#id` is used for identification so that combo must be unique in the package.
|
||||||
|
|
||||||
|
A block declares its role on the info string — `role=part.cargo` is equivalent
|
||||||
|
to `role: part` + `type: cargo` in the content (see §2). A real file declares
|
||||||
|
it in its filename. The info string/filename and content must not both set the
|
||||||
|
same key.
|
||||||
|
|
||||||
|
### package
|
||||||
|
|
||||||
|
The package is the container for a game's definitions. It is declared with a
|
||||||
|
`role: package` object:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
role: package
|
||||||
|
id: harbor
|
||||||
|
title: Harbor
|
||||||
|
designer: Jane Doe
|
||||||
|
players: 2
|
||||||
|
language: en
|
||||||
|
```
|
||||||
|
|
||||||
|
- `role`: for block discovery.
|
||||||
|
- `id`: package identification.
|
||||||
|
- `title` — game name.
|
||||||
|
- `include` — the defs that make up the package (see §2).
|
||||||
|
- Optional metadata: `designer`, `development` (artist/developer), `publisher`,
|
||||||
|
`players` (player count), `language`.
|
||||||
|
|
||||||
|
### part
|
||||||
|
|
||||||
|
A part is a game component. It is identified by a `package:type#id` string,
|
||||||
|
placed on the board via `setup`, and visualized by routes.
|
||||||
|
|
||||||
|
#### part value types
|
||||||
|
|
||||||
|
- `image` — a url to an image.
|
||||||
|
- `crop` — a tuple `[col, row, cols, rows]`. Divides the image into a grid
|
||||||
|
and picks the cell at `[col, row]` with size `[width/cols, height/rows]`.
|
||||||
|
Negative `cols` flips the rendered image.
|
||||||
|
- `size` — a tuple `[width, height, depth]` in mm units.
|
||||||
|
|
||||||
|
#### part props
|
||||||
|
|
||||||
|
- `face` — `sprite`. Used for texture.
|
||||||
|
- `faceCrop` — `crop` for `face`.
|
||||||
|
- `back` — `sprite`. Used for texture. Defaults to `face`.
|
||||||
|
- `backCrop` — `crop` for `back`.
|
||||||
|
- `shape` — `sprite`. Traced for its profile to create the mesh for the part.
|
||||||
|
Defaults to the full rect of the back image.
|
||||||
|
- `size` — `size`. The token is scaled to fit in the box. The x/y aspect
|
||||||
|
ratio is kept, but not z (thickness).
|
||||||
|
- `fillet` — number in mm. Used to fillet the shape. Defaults to `0`.
|
||||||
|
- `facing` — the **physical affordance**: the facings the piece supports.
|
||||||
|
A card supports `[face, back, standing]`; a tile supports `[face, back]`.
|
||||||
|
Declares what's *possible*, not what's legal on a given zone (see `surface`
|
||||||
|
`layout`). Defaults to `[face]`.
|
||||||
|
|
||||||
|
#### example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
role: part
|
||||||
|
type: token
|
||||||
|
id: wood
|
||||||
|
face: ./assets/tokens.png
|
||||||
|
faceCrop: [1, 0, 5, 2]
|
||||||
|
back: ./assets/tokens.png
|
||||||
|
backCrop: [3, 0, 5, 2]
|
||||||
|
shape: ./assets/token-shape.png
|
||||||
|
size: [20, 20, 3]
|
||||||
|
fillet: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
A `wood` token: the `face` and `back` sprites come from the same sheet,
|
||||||
|
`faceCrop`/`backCrop` picking different cells of the `5×2` grid. The shape is
|
||||||
|
traced from `token-shape.png`, sized `20×20×3` mm with a `2` mm fillet.
|
||||||
|
|
||||||
|
### surface
|
||||||
|
|
||||||
|
A `surface` is a **view** over the state store, purely for **visual rendering**.
|
||||||
|
It has a reference `size` (`[width, height]` in mm) and a `layout` list of
|
||||||
|
routes. The size is a reference — it may be scaled to fit larger or smaller
|
||||||
|
tables. It does not affect part placement; placement lives in the state store
|
||||||
|
(see §4). A surface need not cover every part — parts with no matching route on
|
||||||
|
this surface are simply not shown.
|
||||||
|
|
||||||
|
A surface also declares how it is **mounted**: as the root table surface, on a
|
||||||
|
HUD area, or as a child of another surface. `mount` is always an object, with
|
||||||
|
`x`, `y`, and `rotation` (defaulting to `0`) anchoring it like a route. The
|
||||||
|
`kind` selects the mount type:
|
||||||
|
|
||||||
|
- `table` — the root table surface (default).
|
||||||
|
- `hud` — mounted to a HUD area, e.g. a player's hand.
|
||||||
|
- `child` — mounted relative to a parent surface. A surface lists its
|
||||||
|
`children` (`type#id` refs) so a surface can be repeated, like a player
|
||||||
|
board; each child is mounted relative to its parent's anchor.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: board
|
||||||
|
id: harbor
|
||||||
|
role: surface
|
||||||
|
size: [300, 200]
|
||||||
|
mount:
|
||||||
|
kind: table
|
||||||
|
x: 0
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
children:
|
||||||
|
- board#player
|
||||||
|
layout:
|
||||||
|
- route: /dock/:seat
|
||||||
|
candidates:
|
||||||
|
$variants: ./seats.csv
|
||||||
|
- route: /deck
|
||||||
|
x: -100
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: hud
|
||||||
|
id: hand
|
||||||
|
role: surface
|
||||||
|
size: [200, 100]
|
||||||
|
mount:
|
||||||
|
kind: hud
|
||||||
|
area: bottom-left
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: board
|
||||||
|
id: player
|
||||||
|
role: surface
|
||||||
|
size: [200, 200]
|
||||||
|
mount:
|
||||||
|
kind: child
|
||||||
|
x: 100
|
||||||
|
y: 50
|
||||||
|
rotation: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### setup
|
||||||
|
|
||||||
|
`setup` seeds the state store: the enabled surfaces and the part placement.
|
||||||
|
Each valid game state is a valid setup.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
role: setup
|
||||||
|
type: game
|
||||||
|
id: main
|
||||||
|
surfaces:
|
||||||
|
- board#harbor
|
||||||
|
- hud#hand
|
||||||
|
setup:
|
||||||
|
- path: /dock/0
|
||||||
|
parts: harbor:boat#fleet
|
||||||
|
- path: /deck
|
||||||
|
parts: harbor:card
|
||||||
|
facing: back
|
||||||
|
- path: /table
|
||||||
|
parts: harbor:token#wood
|
||||||
|
facing: standing
|
||||||
|
```
|
||||||
|
|
||||||
|
`surfaces` lists the surfaces enabled at the start. A surface not listed is
|
||||||
|
disabled and not rendered. When `surfaces` is omitted, all surfaces are
|
||||||
|
enabled.
|
||||||
|
|
||||||
|
`setup` is an **ordered list of placements**. Each placement moves its `parts`
|
||||||
|
to its `path`, and entries are applied in order — so a part listed in a later
|
||||||
|
placement ends up on that placement's path. This makes a setup read like "deal
|
||||||
|
the deck, then move these cards to the flop".
|
||||||
|
|
||||||
|
`parts` can be a single part id, a bare type without an id, or a list of
|
||||||
|
either. A bare type expands to all parts of that type during game state
|
||||||
|
initialization.
|
||||||
|
|
||||||
|
`facing` sets how the placed parts are oriented on the board, defaulting to
|
||||||
|
`face`:
|
||||||
|
|
||||||
|
- `face` — lay flat, front up, resting on the bottom face.
|
||||||
|
- `back` — lay flat, front down (flipped over), resting on the top face.
|
||||||
|
- `standing` — stand upright on the bottom edge, front texture still showing.
|
||||||
|
|
||||||
|
A part's `facing` is seeded into the game state and can change at runtime; it
|
||||||
|
only affects orientation, never the part's texture.
|
||||||
|
|
||||||
|
A setup also declares the **interaction affordances** — which dialogs are the
|
||||||
|
tool for which open interactions on which paths:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
interactions:
|
||||||
|
- dialog: insert
|
||||||
|
on: [draw, grid] # insertion uses the `insert` dialog on these paths
|
||||||
|
- dialog: shuffle # open the shuffle dialog on any deck
|
||||||
|
```
|
||||||
|
|
||||||
|
`interactions` is a list of declarations: each names a `dialog` (a `role:
|
||||||
|
dialog` definition, see below) and the paths it applies to (`on`, matching by
|
||||||
|
path or by stack). It declares the *interaction surface*, not the legality of
|
||||||
|
the resulting command — rule scripts (later) gate legality. A dialog can be
|
||||||
|
opened by a player gesture or pushed by a rule script; only the trigger differs.
|
||||||
|
|
||||||
|
### dialog
|
||||||
|
|
||||||
|
A `dialog` is declarative content shown in the layer-3 shell. Opening and
|
||||||
|
closing it never issues a command or mutates state; it is pure UI. Its **action
|
||||||
|
buttons** issue commands — the bridge between the dialog and the rule seam.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
role: dialog
|
||||||
|
type: prompt
|
||||||
|
id: discard
|
||||||
|
title: Choose a card to discard
|
||||||
|
body: |
|
||||||
|
Select a card from your hand.
|
||||||
|
actions:
|
||||||
|
- label: Confirm
|
||||||
|
command: { move: { part: "#chosen", to: /discard } }
|
||||||
|
widget: stack # a stack-of-parts content type
|
||||||
|
```
|
||||||
|
|
||||||
|
A dialog's content is a **title**, **body**, **action buttons**, and an optional
|
||||||
|
`widget`. Supported content types include a **stack of parts** — an ordered,
|
||||||
|
scrolled view of a path's stack with an insertion cursor — which is what powers
|
||||||
|
insertion and shuffling dialogs against the state store.
|
||||||
|
|
||||||
|
`dialog` is a same-shaped definition as the others: `type`/`id` identify it
|
||||||
|
(`type#id` unique in the package), and it collides-checks like `part`/`setup`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Concepts
|
||||||
|
|
||||||
|
### Game state
|
||||||
|
|
||||||
|
The board's state is a **state store**: the set of **enabled surfaces** and a
|
||||||
|
map from path to a **stack** of parts. It is the authoritative record of which
|
||||||
|
surfaces are in play and where every part is placed.
|
||||||
|
|
||||||
|
A path is a URL path with named params, like `/dock/1`.
|
||||||
|
|
||||||
|
A part is identified by a `package:type#id` string.
|
||||||
|
|
||||||
|
A surface is enabled or disabled; a disabled surface is not rendered. Setup
|
||||||
|
seeds the enabled set (see §3), and it changes at runtime as the game
|
||||||
|
progresses (e.g. enabling the main board after an expansion-chooser scene).
|
||||||
|
|
||||||
|
Dialogs are **not** part of the state store. The dialog stack is UI state
|
||||||
|
hosted by the shell; opening/closing a dialog never mutates the store. A dialog's
|
||||||
|
action buttons issue commands (see `role: dialog` §3), which is the one-way
|
||||||
|
bridge onto the state.
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
A route is a **visualization route**: it maps a part to a location on a
|
||||||
|
surface. Routes match the keys of the state store, but they are defined by a
|
||||||
|
surface and need not cover every placed part — a part with no matching route on
|
||||||
|
a given surface is simply not shown there. Routes exist only for game parts; a
|
||||||
|
surface is not a part and never appears on a route.
|
||||||
|
|
||||||
|
A route matches all parts on the path; the placement of each individual part on
|
||||||
|
the stack is a separate concern.
|
||||||
|
|
||||||
|
A route is an express-style URL path with named params, plus the `x`, `y`, and
|
||||||
|
`rotation` of its anchor. Routes are defined in a **list**, not a map, so the
|
||||||
|
same route path may appear more than once:
|
||||||
|
|
||||||
|
A route may also declare a zone `facing` — the **legal facing** on that zone.
|
||||||
|
This constrains what the placed parts may be, independent of each part's
|
||||||
|
physical affordance (see `part` `facing`). A state is legal only when the part's
|
||||||
|
facing is in both the part's affordance *and* the zone's legal set. When omitted,
|
||||||
|
the zone is unrestricted beyond the part's affordance. An MTG discard pile
|
||||||
|
requires `[face]`; a play area allows `[face, tap]`; a facedown deck requires
|
||||||
|
`[back]`. The same card is face-up in the discard but tapped in play — the
|
||||||
|
piece's affordance is unchanged, only the zone's rule differs.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
layout:
|
||||||
|
- route: /dock/:seat
|
||||||
|
x: 40
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
- route: /deck
|
||||||
|
x: -100
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Candidates
|
||||||
|
|
||||||
|
To match a class of routes against a list of positions, keep a single route with its param and give it a
|
||||||
|
`candidates` array to match `:param` against, each candidate carrying its own `x`/`y`/`rotation`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
layout:
|
||||||
|
- route: /dock/:seat
|
||||||
|
candidates:
|
||||||
|
$variants: ./seats.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
```csv
|
||||||
|
seat,x,y,rotation
|
||||||
|
string,number,number,number
|
||||||
|
0,40,0,0
|
||||||
|
1,40,20,0
|
||||||
|
```
|
||||||
|
|
||||||
|
The router should select only the first candidate with all params matched against its props — the fields in the candidate's CSV row (e.g. `:seat` matches the candidate's `seat` value).
|
||||||
|
|
||||||
|
A candidate inherits the route's `x`, `y`, `rotation`, and `stacking`, and may override any of them with its own values. When no candidates match, the whole route fails to match.
|
||||||
|
|
||||||
|
### Stacking
|
||||||
|
|
||||||
|
When multiple parts live on a path, only the top (last) one shows by default.
|
||||||
|
To override this, add stacking strategies:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
layout:
|
||||||
|
- route: /deck
|
||||||
|
x: -100
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
stacking:
|
||||||
|
curve: M 0 0 C 20 -20 40 -20 60 0
|
||||||
|
limit: 5
|
||||||
|
align: center
|
||||||
|
steps: 4
|
||||||
|
tilt: 0.1
|
||||||
|
zStart: 0
|
||||||
|
zEnd: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
- `curve` — an SVG path string to spread the content along, relative to the
|
||||||
|
anchor `x`, `y`, `rotation`.
|
||||||
|
- `limit` — how many parts to display. `0` shows all, `3` shows the first 3,
|
||||||
|
`-3` shows the last 3.
|
||||||
|
- `align` — `start`, `end`, or `center` of the curve.
|
||||||
|
- `steps` — the maximum number of parts per curve length unit. Defaults to
|
||||||
|
`1`. See the positioning process below.
|
||||||
|
- `tilt` — rotation in degrees applied to every shown part about the card's
|
||||||
|
local Y (long) axis. It applies even without a `curve`, so a bare `tilt`
|
||||||
|
rotates a straight pile. Defaults to `1` when not specified.
|
||||||
|
- `zStart` / `zEnd` — the height (surface-normal) in mm at the start and end
|
||||||
|
of the `curve`. The stack ramps linearly between them across its span,
|
||||||
|
lifting it in 3D. Requires a `curve`.
|
||||||
|
|
||||||
|
#### positioning process
|
||||||
|
|
||||||
|
1. **Determine the step length.** It is `curve length / max(steps, # of
|
||||||
|
parts on path − 1)`.
|
||||||
|
2. **Determine the alignment.** It places the span of
|
||||||
|
`step length × (# of parts − 1)` on the curve.
|
||||||
|
3. **Place each part.** Part `#0` is at the start, the last part at the end,
|
||||||
|
each `step length` apart.
|
||||||
|
4. **Lift each part.** The part's height is `zStart + (zEnd − zStart) × u`,
|
||||||
|
where `u` is its normalized position along the `curve`.
|
||||||
|
5. **Tilt each part.** Every part is rotated `tilt` about its local Y (long)
|
||||||
|
axis.
|
||||||
|
|
||||||
|
### Edge cases
|
||||||
|
|
||||||
|
- Object with no matching route → **not placed on this surface**. The game
|
||||||
|
state is still valid — the part simply isn't visualized. A surface is a view
|
||||||
|
over the state store, not a mirror of it, and may show only a subset (e.g. a
|
||||||
|
player's hand on the HUD).
|
||||||
|
- Route with no matching object → empty, fine.
|
||||||
|
- Multiple routes match one path -> first route wins.
|
||||||
|
- Multiple parts on one path → **stack** (see §4 Stacking). One route wins
|
||||||
|
for all parts on a path, and the stacking strategy decides what's shown
|
||||||
|
(it may drop parts that are not dropped on other matching routes).
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# bgm Interactions
|
||||||
|
|
||||||
|
The free-interaction layer (layer 3 of the layering) — how a player interacts
|
||||||
|
with a bgm game that has no rules yet: a sandbox. It builds on the state model:
|
||||||
|
see [`state-model.md`](./state-model.md) for components-vs-setup, path→stack ×
|
||||||
|
facing, and the anchoring scope.
|
||||||
|
|
||||||
|
> **Status:** Implemented (layer 3, sandbox). The operation set, the
|
||||||
|
> command/dialog split, and the deck-pick-up dialog are built in
|
||||||
|
> `@tts/tabletop` (`interactions.ts`, `dialog.tsx`). Rule-enforced play (layer
|
||||||
|
> 4) is not yet wired — the rule seam is a no-op filter over free interaction.
|
||||||
|
|
||||||
|
## 1. The operation set is closed and tiny
|
||||||
|
|
||||||
|
Two assumptions from the state model do most of the work:
|
||||||
|
|
||||||
|
1. **Parts are never created or destroyed** — the set of parts is fixed (the
|
||||||
|
setup's parts).
|
||||||
|
2. **State is only path, stack, and facing** — there is nowhere to store a free
|
||||||
|
position in space.
|
||||||
|
|
||||||
|
Together they collapse the *entire* space of free interaction into three
|
||||||
|
operations:
|
||||||
|
|
||||||
|
1. **`move(id, path, index?)`** — relocate a part to a path, at a stack
|
||||||
|
position (default: top of stack).
|
||||||
|
2. **`setFacing(id, facing)`** — change facing, within the part's physical
|
||||||
|
affordance.
|
||||||
|
3. **reorder** — a `move` with an explicit `index`.
|
||||||
|
|
||||||
|
That's it. There is no arbitrary positioning and no free placement in 3D because
|
||||||
|
the state model has nowhere to store one. "Free" means *unconstrained over these
|
||||||
|
three operations*, not "free in space."
|
||||||
|
|
||||||
|
## 2. Free interaction and rule play are the same operations
|
||||||
|
|
||||||
|
This is the payoff. Rule-enforced play (layer 4) is **the same three
|
||||||
|
operations, gated by a legality check**:
|
||||||
|
|
||||||
|
- The interaction layer produces an **intent** — "player wants
|
||||||
|
`move(card, /discard)`."
|
||||||
|
- **Sandbox mode**: apply it directly.
|
||||||
|
- **Rule mode**: validate the intent against the setup's shape + the rule script
|
||||||
|
first; reject if illegal.
|
||||||
|
|
||||||
|
So the rule engine needs no interaction vocabulary of its own — it is a **filter
|
||||||
|
over free interaction**. `move`/`setFacing` are the shared primitives; rules
|
||||||
|
decide which are legal in the current state. Drag-and-drop and a scripted move
|
||||||
|
both funnel through the same store mutation. That is the seam between layer 3
|
||||||
|
and layer 4.
|
||||||
|
|
||||||
|
## 3. Commands vs the dialog stack
|
||||||
|
|
||||||
|
There are two channels, and they do not mix:
|
||||||
|
|
||||||
|
- **Commands** — intent to *change state* (`move`, `setFacing`). They go through
|
||||||
|
the rule seam and mutate the store.
|
||||||
|
- **The dialog stack** — transient UI contexts (the deck pick-up, a "confirm
|
||||||
|
discard," a hint prompt). Opening/closing a dialog **never issues a command**
|
||||||
|
and never touches state; it is pure UI.
|
||||||
|
|
||||||
|
This simplifies the model. The dialog stack is **UI state, hosted by the layer-3
|
||||||
|
shell, not by the game-state store** — dialogs don't belong in path/stack/facing.
|
||||||
|
|
||||||
|
How they connect — one direction only:
|
||||||
|
|
||||||
|
- **Player-initiated**: a click on a deck pushes the deck dialog; the dialog's
|
||||||
|
insert button issues a `move` command.
|
||||||
|
- **Script-initiated**: a rule script pushes the same dialog (e.g. to force a
|
||||||
|
discard) and awaits the player's `move` through it. The script's open/close
|
||||||
|
is tied to the dialog stack; it can `pushDialog`/`popDialog` without mutating
|
||||||
|
game state.
|
||||||
|
|
||||||
|
So the deck dialog is **one implementation, driven either way** — by a player
|
||||||
|
click or by a rule script. Only the *trigger* differs.
|
||||||
|
|
||||||
|
Dialogs are **authored in the manifest**, not hardcoded. A `role: dialog`
|
||||||
|
definition declares the title, body, action buttons, and an optional widget
|
||||||
|
(a stack of parts). Setups declare which dialogs are the tool for which
|
||||||
|
interactions via `interactions:` (see [`format.md`](./format.md) §3). The
|
||||||
|
rule seam stays clean because a dialog's buttons issue commands while its
|
||||||
|
open/close is stack-only.
|
||||||
|
|
||||||
|
## 4. Compound interactions: the deck pick-up dialog
|
||||||
|
|
||||||
|
A dialog is where compound, multi-step manipulation lives, because it owns the
|
||||||
|
transient sub-state that the store must not. The prime example — inserting a
|
||||||
|
card into the middle of a deck:
|
||||||
|
|
||||||
|
1. pick up the deck
|
||||||
|
2. scroll through it to find the place
|
||||||
|
3. insert the card at the cursor
|
||||||
|
4. put the deck back
|
||||||
|
|
||||||
|
(even 0: put down your held hand of cards first).
|
||||||
|
|
||||||
|
The dialog is an **alternate view of the stack**: the deck is "lifted" off the
|
||||||
|
table into the dialog (it stays on its path; the rest of the table becomes
|
||||||
|
backdrop). Its contents are shown **in order**, with an **insertion cursor**
|
||||||
|
between cards that you scroll. The cursor *is* the index: `move(id, path,
|
||||||
|
index)`'s index is discovered by scrolling the visible deck, not typed.
|
||||||
|
|
||||||
|
This is a real interaction *mode*, owned by the dialog:
|
||||||
|
|
||||||
|
- The scroll position is state.
|
||||||
|
- Everything else pauses while it's open (or the held part is kept visible).
|
||||||
|
- It only exists for **stacks**; single parts just snap onto a path.
|
||||||
|
|
||||||
|
We treat it as a **reusable pattern** — a stack-inspector dialog — not a one-off
|
||||||
|
hack for one command, so `deal`, `draw`, and `look-at-the-top` can reuse the
|
||||||
|
same lifted-deck view.
|
||||||
|
|
||||||
|
## 5. The primitives map to concrete gestures
|
||||||
|
|
||||||
|
- **Move** — pick up a part → it leaves its stack (transient "in hand"); drag →
|
||||||
|
resolve the nearest path anchor within a threshold; drop → `move` (drop on
|
||||||
|
nothing returns to origin).
|
||||||
|
- **Facing** — click cycles the part through its physical affordance
|
||||||
|
(`face → back → standing`, or the declared list).
|
||||||
|
- **Reorder / insert** — the deck dialog above.
|
||||||
|
|
||||||
|
The "in hand" state is transient and illegal, so it lives **outside the store**
|
||||||
|
(a UI-level held part); only the committed outcome (drop) mutates the store.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Held source for insertion** — single held card (fits the physical model; the
|
||||||
|
dialog inserts it at the cursor), vs the dialog owns the source (you cursor a
|
||||||
|
card *from* the deck to lift). Lean single-held-card, but confirm it doesn't
|
||||||
|
fight the hand step.
|
||||||
|
- **Multi-part ops** — picking up a whole stack, dealing N cards. Deferred; the
|
||||||
|
single-part primitives are the foundation.
|
||||||
|
- **Scroll window** — a window over a subset of the deck can return with the
|
||||||
|
cursor's scroll position; exact widget is a render concern, not a state one.
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# bgm State Model
|
||||||
|
|
||||||
|
The format's model of what a board game *is*, and the shape of the states it can
|
||||||
|
be in. This is the organizing model behind the concrete specs:
|
||||||
|
[`format.md`](./format.md) (the manifest), [`tabletop.md`](./tabletop.md) (the
|
||||||
|
render library), and [`engine.md`](./engine.md) / [`commands.md`](./commands.md)
|
||||||
|
(scripted interaction).
|
||||||
|
|
||||||
|
> **Status:** Design. The loader and render layer implement components and a
|
||||||
|
> static setup today; the state-shape framing, zone-facing, and the anchoring
|
||||||
|
> scope cut below are proposals to guide the next phase.
|
||||||
|
|
||||||
|
## 1. Components are constants; setup is the shape of states
|
||||||
|
|
||||||
|
A bgm game is authored two ways, and they answer different questions:
|
||||||
|
|
||||||
|
- **Components** (`part`, `surface`) describe the **constants** — the physical
|
||||||
|
pieces and the board geometry that do not change during play. A tile is a
|
||||||
|
45×45 mm square with a meadow/road/city face; the play board has a draw pile
|
||||||
|
and an 11×11 grid. These are timeless facts about the game, not state.
|
||||||
|
- **setup** describes the **shape of the game's states** — which parts can be
|
||||||
|
where, how many, and with what facing. It is a *schema* over the state space,
|
||||||
|
not a concrete snapshot of one run.
|
||||||
|
|
||||||
|
The runtime store is then an **instance** of the setup's shape: the current
|
||||||
|
state of play, a point in the state space the setup describes. This buys three
|
||||||
|
things:
|
||||||
|
|
||||||
|
- **Validation** — a state is legal iff it matches the setup's shape (on the
|
||||||
|
right paths, right counts, right facing).
|
||||||
|
- **A contract for the rule engine** — legal play is a *transition between
|
||||||
|
shapes*; the rule layer (layer 4 in the layering vision) reasons over them.
|
||||||
|
- **A clean boundary** — components are timeless; setup is the state space; the
|
||||||
|
store is the current point.
|
||||||
|
|
||||||
|
### Open: does setup carry the initial state?
|
||||||
|
|
||||||
|
A game needs a concrete starting position, not just a schema. The default is
|
||||||
|
that a `setup` is **shape + initial instance** — one role that both describes
|
||||||
|
the legal state space and seeds the store. The alternative (schema-only, with
|
||||||
|
the initial state derived from the shape) is explored but not preferred.
|
||||||
|
|
||||||
|
## 2. The state: path → stack × facing
|
||||||
|
|
||||||
|
The state space is made of two axes.
|
||||||
|
|
||||||
|
### Path → stack
|
||||||
|
|
||||||
|
Every part lives on a **path** (a URL-style key like `/grid/5/5` or `/draw`),
|
||||||
|
and multiple parts on a path form an ordered **stack** (a deck, a pile of meeples,
|
||||||
|
a tile with a meeple on it). Placement is one axis. This is already in use.
|
||||||
|
|
||||||
|
### facing — part affordance × zone restriction
|
||||||
|
|
||||||
|
Facing is the second axis, and it has two distinct sources of constraint:
|
||||||
|
|
||||||
|
- **The part** declares the **physical affordance** — the facings the piece
|
||||||
|
physically supports. A card supports face/back/standing; a tile supports
|
||||||
|
face/back but not "tapped".
|
||||||
|
- **The path (zone)** declares the **legal facing** — what's allowed on that
|
||||||
|
zone. An MTG discard pile requires face-up; a play area allows tapped; a
|
||||||
|
facedown deck requires face-down. The same card is face-up in the discard,
|
||||||
|
tapped in play, face-up in exile — the piece's affordance doesn't change, only
|
||||||
|
the zone's rule does.
|
||||||
|
|
||||||
|
A state's facing is legal iff it is **both physically possible (part) and
|
||||||
|
zone-legal (path)** — the effective set is the intersection.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
role: part
|
||||||
|
type: card
|
||||||
|
id: basic
|
||||||
|
facing: [face, back, standing] # physical affordance
|
||||||
|
|
||||||
|
role: surface
|
||||||
|
type: board
|
||||||
|
id: main
|
||||||
|
layout:
|
||||||
|
- route: /discard
|
||||||
|
facing: [face] # zone restriction
|
||||||
|
- route: /play
|
||||||
|
facing: [face, tap] # zone restriction
|
||||||
|
```
|
||||||
|
|
||||||
|
Deck / the current facing lives in the store and must be in the intersection.
|
||||||
|
|
||||||
|
### Open questions
|
||||||
|
|
||||||
|
- **Default when a path declares no `facing`** — unrestricted (only the part's
|
||||||
|
affordance bounds it), or a sensible default like `face`? Lean unrestricted.
|
||||||
|
- **Is "tapped" a facing or a rotation?** In MTG it's a 90° in-plane rotation.
|
||||||
|
Default: fold common rotations into the facing enum (`face` / `back` /
|
||||||
|
`standing` / `tap`) for schema simplicity; arbitrary rotation is a later
|
||||||
|
extension.
|
||||||
|
- **Naming** — the part-side and path-side are different constraints wearing the
|
||||||
|
same word. Worth distinct terms (capability/typ) so they don't collide.
|
||||||
|
|
||||||
|
## 3. Anchoring scope: stacks-on-paths, not part-to-part networks
|
||||||
|
|
||||||
|
Real TTS mods anchor components to each other — a meeple on a tile, a fanned
|
||||||
|
hand, tokens scattered on a board. We are **not** modeling part-aligned-to-part
|
||||||
|
relative placement networks. Instead:
|
||||||
|
|
||||||
|
- **Stacks absorb part-on-part.** "Meeple on tile" is a stack `[tile, meeple]`
|
||||||
|
on a path. Much of TTS's anchoring collapses into the path→stack model.
|
||||||
|
- **Free relative placement is out of scope** — a meeple at an offset on a tile,
|
||||||
|
a fanned hand, arbitrary token scatter are not modeled.
|
||||||
|
|
||||||
|
This is a deliberate scope cut. It keeps the state model closed and simple, but
|
||||||
|
it is a **fidelity loss for converted games** and part of the "with some fixing"
|
||||||
|
cost of the TTS→bgm conversion. Components (layer 1), not the state model, are
|
||||||
|
the place to extend later if needed.
|
||||||
|
|
||||||
|
### The grid compromise
|
||||||
|
|
||||||
|
Because we don't model parts-aligned-to-parts, common layouts are expressed
|
||||||
|
explicitly — and grid layouts become verbose (the Carcassonne board is 121
|
||||||
|
hand-written row coordinates in `grid.csv`). The mitigations:
|
||||||
|
|
||||||
|
- **Grid shorthand** — a declarative `grid` (cols/rows, cell size, origin) that
|
||||||
|
expands to routes, instead of authored coordinates.
|
||||||
|
- **Free-placement shorthand** — a `free`/scatter mode for loose collections,
|
||||||
|
when the exact positions don't matter to play.
|
||||||
|
|
||||||
|
But there is **no general part-to-part network** on the roadmap. If a converted
|
||||||
|
game needs it, that is a format extension to design deliberately, not an
|
||||||
|
implicit assumption.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Concept | Role | Where |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Components | Constants — the physical pieces & board geometry | `part`, `surface` |
|
||||||
|
| Setup | The shape of the state space (+ initial instance) | `setup` |
|
||||||
|
| Store | The current state, an instance of the setup | `@tts/tabletop` |
|
||||||
|
| Facing | Part affordance × zone restriction | part + path/zone restriction |
|
||||||
|
| Anchoring | Stacks-on-paths only; no part networks | — |
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# bgm-tabletop
|
||||||
|
|
||||||
|
> **Status:** Implemented (items 1–8 of the plan). A few features below are
|
||||||
|
> designed but not yet wired (commands, HUD rendering); see §5 and
|
||||||
|
> [`../status/bgm-tabletop.md`](../status/bgm-tabletop.md).
|
||||||
|
|
||||||
|
An r3f-based interactive component library for working with [bgm](./format.md)
|
||||||
|
board games. It is used in the `web` app's bgm inspector routes.
|
||||||
|
|
||||||
|
## 1. stack
|
||||||
|
|
||||||
|
`react` - react, react router, tailwind v4
|
||||||
|
`r3f` - r3f, drei, postprocessing
|
||||||
|
`zustand` - for state management
|
||||||
|
|
||||||
|
## 2. states
|
||||||
|
|
||||||
|
source-of-truth game state:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
surfaces: Record<string, boolean>, // enabled per surface id
|
||||||
|
parts: Record<string, PartState>, // part id -> placement state
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartState {
|
||||||
|
path: string, // the path key this part is on
|
||||||
|
index: number, // the part's position in its path's stack
|
||||||
|
facing: 'face' | 'back' | 'standing', // how the part is oriented on the board
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**assumption:** each piece on the board has a unique id, even tokens of the same type. so a part id appears at most once, and a path's ordered children (for stacking) are derived from the map by sorting on `index`. this makes the render list keyed by piece id stable and unambiguous.
|
||||||
|
|
||||||
|
derived surface render state: game state + surface routes => map of piece id to `{ surface, route, candidate, index, stackSize, facing }` for rendering on a surface. keys of this map makes a stable render list.
|
||||||
|
|
||||||
|
- `route` - the matched route.
|
||||||
|
- `candidate` - the matched candidate for a `:param` route, carrying its anchor `x`/`y`/`rotation`. absent for routes without candidates.
|
||||||
|
- `index` - the piece's position in its path's stack.
|
||||||
|
- `stackSize` - the number of pieces on the path.
|
||||||
|
- `facing` - how the piece is oriented on the board (`face` / `back` / `standing`).
|
||||||
|
|
||||||
|
the render map is per enabled surface: a piece may appear on more than one enabled surface (e.g. an expansion path and the main board), and each is rendered independently.
|
||||||
|
|
||||||
|
## 3. components
|
||||||
|
|
||||||
|
- `SetupLoader` side effect only component that seeds the game state with setup (enabled surfaces + part placement).
|
||||||
|
- `WorldSurfaceView` mounts a surface to world space.
|
||||||
|
- `HudSurfaceView` mounts a surface to hud space.
|
||||||
|
- `PartPlacement` a stable per-part component that positions a part on a surface location. uses the stacking hook (below) to apply the route's stacking strategy.
|
||||||
|
- `PartView` used in `PartPlacement`, creates a mesh from part definition. a standalone component library, so it reuses geometry/shape code from `@tts/mesh` rather than the web app's viewers.
|
||||||
|
|
||||||
|
## 4. stacking
|
||||||
|
|
||||||
|
the format's stacking strategy (`curve` / `limit` / `align` / `steps` / `tilt` / `zStart` / `zEnd`, see `format.md` §4) is implemented as a hook, e.g. `useStacking(route.stacking, index, stackSize)`, returning the offset/rotation to apply to a piece: `{ x, y, rotation, z, tilt }`. `x`/`y`/`rotation` come from the `curve`; `z` is the surface-normal height ramped from `zStart` to `zEnd`; `tilt` is the rotation about the card's local Y (long) axis, applied to every part. `PartPlacement` consumes it.
|
||||||
|
|
||||||
|
## 5. commands
|
||||||
|
|
||||||
|
Scripted interaction — focus, tap-to-advance, move, caption — is built on an
|
||||||
|
async command layer. See [`commands.md`](./commands.md) for command
|
||||||
|
execution (lifecycle, run contexts, tap interaction), and
|
||||||
|
[`engine.md`](./engine.md) for the message layer above it (the queue,
|
||||||
|
triggers, and orchestrators that declare and fire commands).
|
||||||
|
|
||||||
|
## 6. usage
|
||||||
|
|
||||||
|
- we will inspect individual parts with `PartView` in the web app's part inspection route.
|
||||||
|
- as a library, the public surface is the components above: mount a surface with `WorldSurfaceView`/`HudSurfaceView`, seed state with `SetupLoader`, and let `PartPlacement`/`PartView` render the pieces. the web app is one consumer; the library should not assume the web app's routes or store.
|
||||||
|
- a surface is mounted only when enabled; a disabled surface is not rendered.
|
||||||
+150
-5
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Scope:** The rationale behind key design decisions. For the system's
|
> **Scope:** The rationale behind key design decisions. For the system's
|
||||||
> architecture, see [`architecture.md`](./architecture.md). For the concrete
|
> architecture, see [`architecture.md`](./architecture.md). For the concrete
|
||||||
> build plan, see [`implementation-plan.md`](./implementation-plan.md).
|
> build plan, see [`status/implementation-plan.md`](./status/implementation-plan.md).
|
||||||
>
|
>
|
||||||
> Each entry records the decision, the context, and the alternatives considered.
|
> Each entry records the decision, the context, and the alternatives considered.
|
||||||
> New entries are appended; existing entries are updated only to correct facts,
|
> New entries are appended; existing entries are updated only to correct facts,
|
||||||
@@ -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,147 @@ 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.
|
||||||
|
|
||||||
|
## D19 — Commands are async with ok/cancel/error results
|
||||||
|
|
||||||
|
**Decision:** Scripted interaction is built on async commands. Each command
|
||||||
|
returns `ok`, `cancel` (interrupted — superseded, skipped, surface disabled),
|
||||||
|
or `error` (genuinely failed). Each invocation gets its own run context — the
|
||||||
|
unit of cancellation and the carrier of command state. Commands are either
|
||||||
|
fire-and-forget (the runtime doesn't await them) or self-managed waiting (they
|
||||||
|
resolve their own promise when a condition is met); both get a run context and
|
||||||
|
cancel path. Supersede groups cancel a running command when another in the
|
||||||
|
group starts (e.g. a `camera` group so a second focus cancels the first).
|
||||||
|
|
||||||
|
**Context:** The user wants to script interaction sequences — focus, caption,
|
||||||
|
title, highlight, tap-to-advance, move, camera away. The state store and
|
||||||
|
render layer already exist; what's missing is a way to drive them over time
|
||||||
|
and react to input. Design: [`bgm/commands.md`](./bgm/commands.md).
|
||||||
|
|
||||||
|
**Alternatives considered:** A single monolithic script interpreter. Rejected
|
||||||
|
— commands as self-contained async units are testable in isolation and let
|
||||||
|
the runtime stay a thin orchestrator.
|
||||||
|
|
||||||
|
## D20 — Tap interaction reports every tap with the nearest trigger point
|
||||||
|
|
||||||
|
**Decision:** Only tap interaction is supported. A tap on a part is reported
|
||||||
|
to the command layer as a `TapEvent` carrying the part, the tap position in
|
||||||
|
the part's local frame, and the nearest trigger point within its `radius` (or
|
||||||
|
`null` on a miss). Trigger points are authored in the part's local frame with
|
||||||
|
mm radius; distance is measured in the part's plane; ties go to the first
|
||||||
|
declared. The command decides how to react to a miss — resolve, reject, or
|
||||||
|
ignore.
|
||||||
|
|
||||||
|
**Context:** Commands need to wait on player input (`wait: tap`). Reporting
|
||||||
|
every tap with the nearest trigger point keeps the runtime dumb and lets the
|
||||||
|
command own the UX (e.g. a "wrong spot" shake). Authoring trigger points in
|
||||||
|
the part's local frame keeps them valid as the part moves, rotates, and
|
||||||
|
flips.
|
||||||
|
|
||||||
|
**Alternatives considered:** Reporting only a hit and silently dropping
|
||||||
|
misses. Rejected — a command that needs to react to a wrong tap has no way to
|
||||||
|
do so. World-space trigger points. Rejected — they break when the part moves.
|
||||||
|
|
||||||
|
## D21 — Card sprite UVs live in the material shader, not the texture
|
||||||
|
|
||||||
|
**Decision:** A card's sprite cell is selected by a repeat/offset injected into
|
||||||
|
the material's shader (`cardMaterial.ts` extends `MeshStandardMaterial` via
|
||||||
|
`onBeforeCompile`) rather than by cloning the texture and setting its
|
||||||
|
`repeat`/`offset`.
|
||||||
|
|
||||||
|
**Context:** Cards in a deck share one sprite sheet (drei caches the texture by
|
||||||
|
URL), but each card samples a different cell. The previous approach cloned the
|
||||||
|
texture per card to set its UVs; each clone gets its own WebGL texture binding,
|
||||||
|
so navigating a deck re-uploaded the whole sheet on every step. Moving the
|
||||||
|
transform into a per-material uniform lets cards share the texture (one GPU
|
||||||
|
upload), the shader (identical injected source → one program), and the geometry,
|
||||||
|
with only the material uniforms differing.
|
||||||
|
|
||||||
|
We inject our own uniform rather than setting `texture.repeat`/`offset` because
|
||||||
|
three r185 derives map UVs from a `mapTransform` matrix refreshed from
|
||||||
|
`map.matrix` every frame, which would overwrite a per-material transform set on
|
||||||
|
the shared texture.
|
||||||
|
|
||||||
|
**Alternatives considered:** Cloning the texture per card (previous approach).
|
||||||
|
Rejected — re-uploads the sheet per card. A module-level cache of per-card
|
||||||
|
clones. Rejected — still one upload per unique card instead of one per sheet.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Docs
|
||||||
|
|
||||||
|
The documentation is split into the living specs, which describe how the
|
||||||
|
system works today, and the status/plans, which track development iterations
|
||||||
|
and go stale as work lands.
|
||||||
|
|
||||||
|
## Specs — how the system works
|
||||||
|
|
||||||
|
| Doc | Covers |
|
||||||
|
| --- | --- |
|
||||||
|
| [`architecture.md`](./architecture.md) | System architecture and the package dependency graph |
|
||||||
|
| [`decisions.md`](./decisions.md) | Key design decisions and the rationale behind them |
|
||||||
|
| [`bgm/format.md`](./bgm/format.md) | The board game manifest (bgm) format spec |
|
||||||
|
| [`bgm/engine.md`](./bgm/engine.md) | The bgm message layer: queue, triggers, orchestrators |
|
||||||
|
| [`bgm/commands.md`](./bgm/commands.md) | bgm command execution: lifecycle, run contexts, tap interaction |
|
||||||
|
| [`bgm/tabletop.md`](./bgm/tabletop.md) | The r3f tabletop component library |
|
||||||
|
| [`bgm/state-model.md`](./bgm/state-model.md) | The format's state model: components vs setup, facing, anchoring scope |
|
||||||
|
| [`bgm/interactions.md`](./bgm/interactions.md) | Free interaction: the operation set, the rule seam, and the deck pick-up dialog |
|
||||||
|
|
||||||
|
## Status & plans (dev logs)
|
||||||
|
|
||||||
|
| Doc | Covers |
|
||||||
|
| --- | --- |
|
||||||
|
| [`status/implementation-plan.md`](./status/implementation-plan.md) | Original build plan |
|
||||||
|
| [`status/bgm-loader.md`](./status/bgm-loader.md) | bgm loader — what's built, works, missing |
|
||||||
|
| [`status/bgm-tabletop.md`](./status/bgm-tabletop.md) | bgm tabletop — implementation plan / status |
|
||||||
|
| [`status/full-setup-view.md`](./status/full-setup-view.md) | Full-setup view plan |
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# bgm Loader — Status
|
||||||
|
|
||||||
|
> Status: current. What's built, what works, what's missing, and the known
|
||||||
|
> issues. Spec: [`../bgm/format.md`](../bgm/format.md).
|
||||||
|
|
||||||
|
## What's built
|
||||||
|
|
||||||
|
### `packages/bgm` — the loader core (new)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/types.ts` | Roles (`Package`, `Part`, `Surface`, `Setup`, `Route`, `Stacking`, `SurfaceMount`, …), `SerializedPackage` (the JSON the plugin emits), `BgmError`, `DefFile`, `ParsedDef` |
|
||||||
|
| `src/schemas.ts` | zod schemas + `validate*` for each role |
|
||||||
|
| `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/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). 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/*.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 |
|
||||||
|
|
||||||
|
Deps: `marked`, `typed-csv`, `yaml`, `smol-toml`, `picomatch`, `zod`, `vite`, `@types/picomatch`.
|
||||||
|
|
||||||
|
### Example game: harbor (fixture)
|
||||||
|
|
||||||
|
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`. Lives as the vitest fixture under `packages/bgm/src/__fixtures__/harbor/` (and a copy under `packages/tabletop/src/__fixtures__/vite-build/games/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.md`](./bgm-tabletop.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)
|
||||||
|
|
||||||
|
- `vite.config.ts` — wired with `bgm({ root: <repo>/games })`, importing the plugin from `@tts/bgm`.
|
||||||
|
- `src/vite-env.d.ts` — ambient `declare module 'virtual:bgm/packages'` (all packages) and `'virtual:bgm/package/*'` (one package).
|
||||||
|
- `src/pages/BgmPage.tsx` — list page: imports `virtual:bgm/packages` and shows every discovered package. Routed at `/bgm`.
|
||||||
|
- `src/pages/BgmPackagePage.tsx` — detail page: looks up a package by id from `bgm` and renders its parts/surfaces/setups. Routed at `/bgm/:id`.
|
||||||
|
|
||||||
|
The vite plugin itself lives in `packages/bgm/src/vite.ts` (exported from `@tts/bgm`), not in the web app — so the loader's plugin is tested in isolation from the web project.
|
||||||
|
|
||||||
|
## Works
|
||||||
|
|
||||||
|
- `pnpm --filter @tts/bgm build` and `typecheck` pass.
|
||||||
|
- Root `pnpm test`: **191 pass**.
|
||||||
|
- `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.
|
||||||
|
- `pnpm --filter @tts/tabletop build` / `test` pass; `pnpm --filter @tts/proxy typecheck` passes.
|
||||||
|
|
||||||
|
## Known issues
|
||||||
|
|
||||||
|
1. ~~Plugin emits empty maps~~ — fixed: `load` serializes `Map`s via `Object.fromEntries`.
|
||||||
|
2. ~~Plugin `load` uses `this.error`~~ — fixed: throws instead.
|
||||||
|
3. ~~HMR cache not invalidated~~ — fixed: dropped the closure cache; re-collects per `load`.
|
||||||
|
4. ~~`tinyglobby` leftover dep~~ — removed.
|
||||||
|
5. ~~Package-level test script finds no files~~ — added `packages/bgm/vitest.config.ts`.
|
||||||
|
|
||||||
|
## Not yet done
|
||||||
|
|
||||||
|
- ~~No consumer yet~~ — `apps/web/src/pages/BgmPage.tsx` lists packages from `virtual:bgm/packages`; `BgmPackagePage.tsx` shows one at `/bgm/:id`.
|
||||||
|
- **`$variants` URL paths** — spec mentions file/URL; URLs deferred.
|
||||||
|
- **zod `SerializedPackage` shape for the emitted JSON** — the plugin emits `SerializedPackage` objects; a zod schema for the emitted module would give runtime validation beyond the ambient `declare module`.
|
||||||
|
- **`setup` value expansion** — `type` without `id` → all parts of that type is documented but not implemented in the loader (it's a game-state init concern; noted as future).
|
||||||
|
- **Surface mounting is validated but not resolved** — `mount`/`children`/`surfaces` are parsed and validated, but the loader doesn't resolve child→parent relationships or enforce that a setup's `surfaces`/a surface's `children` reference existing surfaces. That's a game-state/rendering concern (see `../bgm/tabletop.md`).
|
||||||
|
- Docs for the loader itself (this file is the start).
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# bgm-tabletop — Implementation Plan / Status
|
||||||
|
|
||||||
|
> **Scope:** A standalone r3f component library that renders
|
||||||
|
> [bgm](../bgm/format.md) board games: a state store, surface mounting, part
|
||||||
|
> placement with stacking, and per-part meshes. Design:
|
||||||
|
> [`../bgm/tabletop.md`](../bgm/tabletop.md).
|
||||||
|
> **Status:** items 1–8 implemented and the full tabletop scene is wired into
|
||||||
|
> the web app's setup detail route (`/bgm/:id/setups/:type/:setup`). The
|
||||||
|
> part-inspection route renders `PartView` from the library.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A library (new `packages/tabletop`) that takes a bgm package and renders it as
|
||||||
|
an interactive 3D table: enabled surfaces mounted in world/HUD space, parts
|
||||||
|
placed on their routes, stacked per the format's stacking strategy. The web
|
||||||
|
app's bgm inspector routes are one consumer; the library must not depend on the
|
||||||
|
web app.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
`react`, `react-router` (types only), `tailwind` (styles only), `r3f`
|
||||||
|
(`@react-three/fiber`), `drei`, `postprocessing`, `zustand`, `three`,
|
||||||
|
`@tts/bgm` (types), `@tts/mesh` (geometry).
|
||||||
|
|
||||||
|
## Package layout
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/tabletop/
|
||||||
|
package.json # @tts/tabletop
|
||||||
|
tsconfig.json
|
||||||
|
vitest.config.ts
|
||||||
|
src/
|
||||||
|
index.ts # public exports
|
||||||
|
state.ts # zustand store + derived render state
|
||||||
|
setup.ts # SetupLoader: seed state from a setup
|
||||||
|
mount.ts # resolve surface mount tree (table/hud/child)
|
||||||
|
stacking.ts # useStacking hook
|
||||||
|
placement.ts # PartPlacement
|
||||||
|
partView.tsx # PartView: mesh from a part definition
|
||||||
|
surfaces/
|
||||||
|
WorldSurfaceView.tsx
|
||||||
|
HudSurfaceView.tsx
|
||||||
|
*.test.ts # colocated unit tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Work items
|
||||||
|
|
||||||
|
### 1. Package scaffold ✅
|
||||||
|
|
||||||
|
- New `packages/tabletop` workspace package (`pnpm-workspace.yaml` already
|
||||||
|
globs `packages/*`).
|
||||||
|
- Deps: `@tts/bgm`, `@tts/mesh`, `three`, `@react-three/fiber`, `@react-three/drei`,
|
||||||
|
`@react-three/postprocessing`, `zustand`. Dev: `vitest`, `typescript`,
|
||||||
|
`@types/three`.
|
||||||
|
- `tsconfig.json` mirroring `packages/bgm`'s (strict, ESM, `dist` output).
|
||||||
|
|
||||||
|
### 2. Part meshes + export + web integration ✅
|
||||||
|
|
||||||
|
First deliverable: `PartView` renders a single part's mesh from its definition,
|
||||||
|
reusing `@tts/mesh` geometry (not the web app's viewers). This is the smallest
|
||||||
|
useful slice and unblocks the web app's part inspection route immediately.
|
||||||
|
|
||||||
|
- `PartView` (`partView.tsx`): creates a mesh from a `Part` definition:
|
||||||
|
- `size` → world dimensions; `fillet` → corner radius.
|
||||||
|
- `face`/`faceCrop`/`back`/`backCrop` → textures (drei `useTexture`), sprite
|
||||||
|
UVs from `faceCrop`/`backCrop` (a `[col,row,cols,rows]` grid cell).
|
||||||
|
- `shape` → traced silhouette (via the proxy `/trace`, like the web token
|
||||||
|
viewer) or a fallback rect/rounded-rect.
|
||||||
|
- `extrudeShapeParts` from `@tts/mesh` for the mesh.
|
||||||
|
- Shared geometry/material caching (module-level `Map`s) so repeated parts
|
||||||
|
reuse buffers, mirroring the web viewers' `sharedResources`.
|
||||||
|
- Export `PartView` from `index.ts`.
|
||||||
|
- **Web integration**: replace the web app's part inspection route
|
||||||
|
(`/bgm/:id/parts/:type/:part`) to render `PartView` from the library, proving
|
||||||
|
it end-to-end.
|
||||||
|
|
||||||
|
### 3. State store (`state.ts`) ✅
|
||||||
|
|
||||||
|
Source-of-truth game state per `../bgm/tabletop.md` §2:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface GameState {
|
||||||
|
surfaces: Record<string, boolean>; // enabled per surface id
|
||||||
|
parts: Record<string, PartState>; // part id -> placement state
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartState {
|
||||||
|
path: string; // the path key this part is on
|
||||||
|
index: number; // the part's position in its path's stack
|
||||||
|
facing: 'face' | 'back' | 'standing'; // how the part is oriented on the board
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- A zustand store holding `GameState`.
|
||||||
|
- **Derived render state**: `game state + surface routes => map of piece id to
|
||||||
|
`{ surface, route, candidate, index, stackSize, face }``, per enabled surface.
|
||||||
|
Computed with a selector/memo so the render list is stable. A path's ordered
|
||||||
|
children (for stacking) are derived from the parts map by sorting on `index`.
|
||||||
|
- **Assumption**: each piece id is unique on the board (documented in
|
||||||
|
`../bgm/tabletop.md`); the render map is keyed by piece id.
|
||||||
|
|
||||||
|
### 4. Setup seeding (`setup.ts`) ✅
|
||||||
|
|
||||||
|
- `SetupLoader`: side-effect-only component that seeds the store from a
|
||||||
|
`Setup` — enables its `surfaces` (or all when omitted) and applies its
|
||||||
|
ordered `setup` placements (each moves its `parts` to a `path`).
|
||||||
|
- `setup` value expansion: a bare `type` (no id) expands to all parts of that
|
||||||
|
type (documented in `../bgm/format.md` §3; the loader doesn't do this — it's a
|
||||||
|
game-state init concern, so it lives here).
|
||||||
|
|
||||||
|
### 5. Surface mounting (`mount.ts`) ✅
|
||||||
|
|
||||||
|
- Resolve the surface mount tree from `Surface.mount` + `Surface.children`:
|
||||||
|
- `kind: table` — root, world space.
|
||||||
|
- `kind: hud` — HUD area (`mount.area`).
|
||||||
|
- `kind: child` — mounted relative to a parent that lists it in `children`.
|
||||||
|
- `WorldSurfaceView` / `HudSurfaceView` mount an enabled surface; a disabled
|
||||||
|
surface isn't rendered. Child surfaces mount relative to their parent's
|
||||||
|
anchor (`x`/`y`/`rotation`).
|
||||||
|
|
||||||
|
### 6. Part placement (`placement.ts`) ✅
|
||||||
|
|
||||||
|
- `PartPlacement`: stable per-part component that positions a part on a surface
|
||||||
|
location from the derived render state (route anchor + candidate anchor).
|
||||||
|
- Applies the route's stacking strategy via `useStacking`.
|
||||||
|
|
||||||
|
### 7. Stacking (`stacking.ts`) ✅
|
||||||
|
|
||||||
|
- `useStacking(route.stacking, index, stackSize)` → `{ x, y, rotation, z, tilt }`.
|
||||||
|
- Implements the format's positioning process (`../bgm/format.md` §4): step
|
||||||
|
length from curve length / `max(steps, count-1)`, alignment (`start`/`end`/
|
||||||
|
`center`), and `limit` (`0` all, `n` first n, `-n` last n).
|
||||||
|
- `z` ramps linearly from `zStart` to `zEnd` across the curve's span; `tilt`
|
||||||
|
rotates each shown part about its local Y (long) axis.
|
||||||
|
- Curve length from an SVG path string (small helper; no new dep).
|
||||||
|
|
||||||
|
### 8. Public API (`index.ts`) ✅
|
||||||
|
|
||||||
|
Export `SetupLoader`, `WorldSurfaceView`, `HudSurfaceView`, `PartPlacement`,
|
||||||
|
`PartView`, `useStacking`, and the store hooks. The web app consumes these; the
|
||||||
|
library never imports from `apps/*`.
|
||||||
|
|
||||||
|
## Reuse from `@tts/mesh`
|
||||||
|
|
||||||
|
- `extrudeShapeParts` / `extrudeShape` — front/back/walls geometry.
|
||||||
|
- `rectShape`, `roundedRectShape`, `circleShape`, `polygonShape`, `hexShape`,
|
||||||
|
`frameShape`, `scaleShape` — shape generators for parts without a `shape`
|
||||||
|
sprite.
|
||||||
|
- `shapeFromThree` — author shapes with the three.js path API.
|
||||||
|
- `ExtrudedGeometry` / `UVBounds` — raw typed arrays + UV framing.
|
||||||
|
|
||||||
|
The web viewers (`TokenViewer`/`CardViewer`) contain logic we'll mirror rather
|
||||||
|
than import: trace-to-shape conversion, sprite UV math, texture flipping. These
|
||||||
|
are candidates to lift into `@tts/mesh` or `@tts/tabletop` later so both
|
||||||
|
consumers share them (see Open decisions).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `state.ts` — derived render state: enabled surfaces, route matching,
|
||||||
|
candidate selection, stacking index/stackSize.
|
||||||
|
- `stacking.ts` — positioning process: step length, alignment, limit, z ramp,
|
||||||
|
tilt.
|
||||||
|
- `setup.ts` — seeding + bare-type expansion.
|
||||||
|
- `mount.ts` — mount tree resolution (table/hud/child, children refs).
|
||||||
|
- `partView.tsx` — geometry from a part def (size/fillet/crop), sprite UVs.
|
||||||
|
- A real `vite build` integration test (mirroring `packages/bgm/src/vite.test.ts`)
|
||||||
|
proving the library bundles against a fixture package.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `pnpm --filter @tts/tabletop build` / `typecheck` / `test`.
|
||||||
|
- Root `pnpm test` stays green.
|
||||||
|
- `pnpm --filter @tts/web build` — the part inspection route renders `PartView`
|
||||||
|
from the library (work item 2), proving it end-to-end.
|
||||||
|
|
||||||
|
## Free interaction (layer 3) ✅
|
||||||
|
|
||||||
|
Sandbox interaction is built per [`../bgm/interactions.md`](../bgm/interactions.md):
|
||||||
|
|
||||||
|
- `interactions.ts` — the held part + dialog stack (UI state, outside the game
|
||||||
|
store), plus pure helpers (`interactionsFor`, `dropPaths`, `pickPath`,
|
||||||
|
`partFacings`, `nextFacing`).
|
||||||
|
- `state.ts` — `setFacing` alongside `movePart` (the `move(id, path, index?)`
|
||||||
|
primitive, defaulting to top of stack).
|
||||||
|
- `placement.tsx` — `PartPlacement` is now interactive: pick up a part (held,
|
||||||
|
lifted above the board), drag to a path anchor to `move`, click to cycle
|
||||||
|
facing. A drop on a path with a stack-dialog interaction opens the deck
|
||||||
|
dialog instead of moving directly.
|
||||||
|
- `dialog.tsx` — `DialogLayer`, the stack-inspector dialog: an alternate view
|
||||||
|
of a stack with an insertion cursor; its insert button issues a `move`.
|
||||||
|
- `@tts/bgm` — the `dialog` role, `Setup.interactions`, and `Package.dialogs`
|
||||||
|
(schema + collection + serialization).
|
||||||
|
- Demo: `games/poker` declares an `interactions:` + `role: dialog` (stack
|
||||||
|
insert) on the deck.
|
||||||
|
|
||||||
|
The rule seam (layer 4) is a no-op filter: sandbox applies intents directly.
|
||||||
|
|
||||||
|
## Commands (not yet implemented)
|
||||||
|
|
||||||
|
Scripted interaction is designed in [`../bgm/commands.md`](../bgm/commands.md):
|
||||||
|
async commands with `ok`/`cancel`/`error` results, per-invocation run
|
||||||
|
contexts, fire-and-forget vs self-managed waiting, and tap interaction with
|
||||||
|
part-local trigger points. Implementation order: types + run-context manager,
|
||||||
|
tap detection, then the first commands (`wait: tap`, `focus`).
|
||||||
|
|
||||||
|
## Open decisions (defaults in bold)
|
||||||
|
|
||||||
|
- **Where the trace/sprite helpers live** — **lift into `@tts/mesh`** (shared
|
||||||
|
by web viewers + tabletop) vs duplicate in `@tts/tabletop`. Lifting is
|
||||||
|
cleaner but touches the web viewers; decide when `PartView` (work item 2)
|
||||||
|
needs them.
|
||||||
|
- **HUD rendering** — **drei `Html`/orthographic overlay** vs a second
|
||||||
|
`Canvas`. Default to an overlay so world + HUD share one scene.
|
||||||
|
- **Curve length** — **small internal SVG-path length helper** vs a dependency
|
||||||
|
(e.g. `svg-path-properties`). Prefer the helper to avoid a dep.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Full Setup View — Plan
|
||||||
|
|
||||||
|
> **Scope:** A new page that renders every loadable object in a save into a
|
||||||
|
> single shared 3D scene, sharing materials and geometry where possible. This
|
||||||
|
> complements the existing per-object inspection on the mod page.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The mod page (`apps/web/src/pages/ModPage.tsx`) inspects one object at a time
|
||||||
|
inside a shared `Scene`. A "full setup" view renders the whole save at once —
|
||||||
|
every tile, token, card, and custom model laid out on a virtual table — so a
|
||||||
|
user can see the entire scene at a glance.
|
||||||
|
|
||||||
|
## Current architecture (relevant pieces)
|
||||||
|
|
||||||
|
- Each viewer owns its own `<Scene>` wrapper (`components/viewers/Scene.tsx`),
|
||||||
|
which provides the camera, lights, orbit controls, contact shadows, and
|
||||||
|
post-processing. The mesh content lives inside each viewer:
|
||||||
|
- `TileViewer` → `<TileMesh>` (extruded tile, textured top face)
|
||||||
|
- `TokenViewer` → `<TokenMesh>` (alpha-traced extruded token)
|
||||||
|
- `CardViewer` → `<CardMesh>` (rounded-rect card, sprite UVs)
|
||||||
|
- `CustomModelViewer` → `<Model>` (GLTF/OBJ/FBX via `FlexibleModelLoader`)
|
||||||
|
- `@tts/extract` provides `flattenObjects(mod)` / `traverseMod` to enumerate
|
||||||
|
every object in a save.
|
||||||
|
- `@tts/mesh` provides the shape + extrusion helpers used by the viewers.
|
||||||
|
- Every object in a save carries a `Transform` (position/rotation/scale in TTS
|
||||||
|
world units), which the proxy passes through in the raw BSON. The shared
|
||||||
|
type now models it as `TTSObjectTransform`.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
### 1. Refactor viewers to expose their mesh content (no `Scene`)
|
||||||
|
|
||||||
|
Each viewer currently wraps its mesh in `<Scene>`. To compose everything into
|
||||||
|
one scene, extract and export the inner mesh components, keeping the existing
|
||||||
|
viewers as thin wrappers:
|
||||||
|
|
||||||
|
- `TileViewer` → export `TileObjectMesh` (accepts a `TTSObject`)
|
||||||
|
- `TokenViewer` → export `TokenObjectMesh`
|
||||||
|
- `CardViewer` → export `CardObjectMesh`
|
||||||
|
- `CustomModelViewer` → export `CustomModelMesh` (the `<Model>` + fallback box)
|
||||||
|
|
||||||
|
Each viewer now renders `<Scene><XxxObjectMesh object={…} /></Scene>`, so the
|
||||||
|
object-facing wrapper is the single source of truth for both the per-object
|
||||||
|
view and the full-setup view.
|
||||||
|
|
||||||
|
### 2. New page `FullSetupPage.tsx` at `/mod/:id/setup`
|
||||||
|
|
||||||
|
- Reuse `useModStore` (same load path as `ModPage`).
|
||||||
|
- `flattenObjects(mod)` → filter to renderable classes (those registered in
|
||||||
|
`components/viewers/register.ts`: `Tile`, `Custom_Tile`, `Custom_Token`,
|
||||||
|
`Card`, `CardCustom`, `Deck`, `DeckCustom`, `Custom_Deck`, `Custom_Model*`).
|
||||||
|
- Render **one** `<Scene>` containing all renderable objects as
|
||||||
|
`<group position={…}>` entries, dispatching to the right `*Mesh` by `Name`.
|
||||||
|
- Show a summary header (total objects, rendered count, skipped count) and
|
||||||
|
loading/error states matching `ModPage`.
|
||||||
|
|
||||||
|
### 3. Layout
|
||||||
|
|
||||||
|
- Place each object at its real position from the save's `Transform` instead of
|
||||||
|
a generated grid.
|
||||||
|
- `components/viewers/transform.ts` converts a TTS transform to three.js:
|
||||||
|
TTS is left-handed (Y up, +Z toward the player), three.js is right-handed,
|
||||||
|
so Z is reflected in position and rotation. Each class also gets a base-size
|
||||||
|
correction (our viewer meshes are authored for inspection, in arbitrary
|
||||||
|
units) and a "lay flat" rotation for the extruded tile/token/card meshes.
|
||||||
|
- `Scene`'s `Bounds fit` auto-fits the camera to the full layout, so no camera
|
||||||
|
work is needed.
|
||||||
|
- Non-renderable objects (bags, dice, boards without assets) are skipped and
|
||||||
|
counted, not dropped silently.
|
||||||
|
|
||||||
|
### 4. Share materials & geometry
|
||||||
|
|
||||||
|
- **Geometry cache (module-level `Map<string, BufferGeometry>`):** key by a
|
||||||
|
canonical string — `card:{w}:{h}:{t}`, `tile:{type}:{aspect}:{t}`,
|
||||||
|
`token:{url}:{t}`, `model:{meshUrl}`. All cards of the same size, or tiles
|
||||||
|
of the same type/size, reuse one geometry instead of rebuilding per object.
|
||||||
|
Token geometry is keyed by the source image URL (the trace is cached per
|
||||||
|
URL, so the silhouette is deterministic).
|
||||||
|
- **Material cache (module-level `Map<string, Material>`):** key by
|
||||||
|
`textureUrl + color + roughness`. drei already caches textures by URL
|
||||||
|
globally, so sharing the material on top avoids per-object material
|
||||||
|
allocation for tiles/tokens with the same image.
|
||||||
|
- **Cards:** the face/back textures are shared (drei caches them by URL) and
|
||||||
|
the sprite cell is selected via a per-material UV transform injected into the
|
||||||
|
shader (`cardMaterial.ts`), so cards share texture, shader, and geometry —
|
||||||
|
only the material uniforms differ. Materials are cached per card id + tint.
|
||||||
|
- Dispose shared resources on page unmount, or accept a module-level cache for
|
||||||
|
the session (see Open decisions).
|
||||||
|
|
||||||
|
### 5. Routing & navigation
|
||||||
|
|
||||||
|
- Add `<Route path="/mod/:id/setup" element={<FullSetupPage />} />` in
|
||||||
|
`apps/web/src/App.tsx`.
|
||||||
|
- Add a "Full setup" link/button on `ModPage` next to the download button.
|
||||||
|
|
||||||
|
### 6. Edge cases
|
||||||
|
|
||||||
|
- Objects with no asset (no `ImageURL`/`MeshURL`) render as neutral-colored
|
||||||
|
placeholders (matching the current viewers' fallback behavior).
|
||||||
|
- Token tracing suspends per URL (already cached in `TokenViewer`); the shared
|
||||||
|
`Scene` Suspense boundary handles it.
|
||||||
|
- Large saves: the grid + shared geometry keeps it performant, but cap or warn
|
||||||
|
on very large object counts if needed.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
- `apps/web/src/components/viewers/{Tile,Token,Card,CustomModel}Viewer.tsx` —
|
||||||
|
export object-facing mesh wrappers
|
||||||
|
- `apps/web/src/components/viewers/sharedResources.ts` — new geometry/material
|
||||||
|
caches
|
||||||
|
- `apps/web/src/components/viewers/transform.ts` — new TTS→three.js placement
|
||||||
|
conversion
|
||||||
|
- `apps/web/src/components/viewers/sharedResources.ts` — `objectTint`/
|
||||||
|
`tintedColor` helpers for the per-object `ColorDiffuse` tint
|
||||||
|
- `packages/shared/src/types.ts` — add `TTSObjectTransform` and `ColorDiffuse`
|
||||||
|
- `apps/web/src/pages/FullSetupPage.tsx` — new
|
||||||
|
- `apps/web/src/App.tsx` — route
|
||||||
|
- `apps/web/src/pages/ModPage.tsx` — nav link
|
||||||
|
- Possibly a small `geometryCache`/`materialCache` helper under
|
||||||
|
`components/viewers/`
|
||||||
|
|
||||||
|
## Open decisions (defaults in bold)
|
||||||
|
|
||||||
|
- **Layout style** — **real `Transform` placement** (was a grouped grid before
|
||||||
|
the transform data was wired in).
|
||||||
|
- **Geometry/material cache lifetime** — **session-level module cache**
|
||||||
|
(simplest, consistent with drei's global texture cache) vs dispose-on-unmount.
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
> **Scope:** The concrete build plan — files, endpoints, dependencies, build
|
> **Scope:** The concrete build plan — files, endpoints, dependencies, build
|
||||||
> order. For the system's architecture and dependency graph, see
|
> order. For the system's architecture and dependency graph, see
|
||||||
> [`architecture.md`](./architecture.md). For the rationale behind key decisions,
|
> [`../architecture.md`](../architecture.md). For the rationale behind key decisions,
|
||||||
> see [`decisions.md`](./decisions.md).
|
> see [`../decisions.md`](../decisions.md).
|
||||||
|
|
||||||
A lightweight, client-only pnpm monorepo for searching the Tabletop Simulator
|
A lightweight, client-only pnpm monorepo for searching the Tabletop Simulator
|
||||||
Steam Workshop, fetching full TTS save files, and analyzing their contents.
|
Steam Workshop, fetching full TTS save files, and analyzing their contents.
|
||||||
@@ -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
|
||||||
@@ -52,6 +55,15 @@ tts-workshop/
|
|||||||
├── .npmrc
|
├── .npmrc
|
||||||
├── .env.example # STEAM_API_KEY, PORT
|
├── .env.example # STEAM_API_KEY, PORT
|
||||||
├── docs/
|
├── docs/
|
||||||
|
│ ├── overview.md
|
||||||
|
│ ├── architecture.md
|
||||||
|
│ ├── decisions.md
|
||||||
|
│ ├── bgm/
|
||||||
|
│ │ ├── format.md
|
||||||
|
│ │ ├── engine.md
|
||||||
|
│ │ ├── commands.md
|
||||||
|
│ │ └── tabletop.md
|
||||||
|
│ └── status/
|
||||||
│ └── implementation-plan.md # this file
|
│ └── implementation-plan.md # this file
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── proxy/
|
│ ├── proxy/
|
||||||
@@ -107,6 +119,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
|
||||||
@@ -144,8 +164,8 @@ Low-level fetcher, extracted from the existing scraper.
|
|||||||
(no Steam API call).
|
(no Steam API call).
|
||||||
- `fetchModFile(id, apiKey)` / `fetchModFileFromUrl(fileUrl)` — raw save
|
- `fetchModFile(id, apiKey)` / `fetchModFileFromUrl(fileUrl)` — raw save
|
||||||
bytes + derived filename, with and without the Steam API.
|
bytes + derived filename, with and without the Steam API.
|
||||||
- `getFileName(url: string): Promise<string>` — derive filename from the
|
- `getFileName(url: string): string` — derive a filename from the save URL
|
||||||
`content-disposition` header.
|
path (the upstream `content-disposition` header is ignored).
|
||||||
- `errors.ts`
|
- `errors.ts`
|
||||||
- Typed errors: missing `file_url`, Steam API failure, rate limit, invalid key.
|
- Typed errors: missing `file_url`, Steam API failure, rate limit, invalid key.
|
||||||
- Notes
|
- Notes
|
||||||
@@ -183,6 +203,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.
|
||||||
@@ -197,24 +246,31 @@ Hono server exposing search + fetch.
|
|||||||
- `GET /items/:id` — full parsed `TTSMod`. Accepts an optional `fileUrl`
|
- `GET /items/:id` — full parsed `TTSMod`. Accepts an optional `fileUrl`
|
||||||
query param to download the save directly (no Steam API key needed);
|
query param to download the save directly (no Steam API key needed);
|
||||||
otherwise resolves via the Steam API.
|
otherwise resolves via the Steam API.
|
||||||
- `GET /items/:id/file` — raw save bytes, filename from `getFileName`. Also
|
- `GET /items/:id/file` — raw save bytes, filename from `getFileName` (the
|
||||||
accepts `fileUrl`.
|
URL path). Also accepts `fileUrl`.
|
||||||
- `routes/asset.ts`
|
- `routes/asset.ts`
|
||||||
- `GET /asset?url=...` — fetch an external asset (texture, model) and stream
|
- `GET /asset?url=...` — fetch an external asset (texture, model) and stream
|
||||||
it back with a `Content-Type` header. Workshop hosts often omit CORS
|
it back with a `Content-Type` header. Workshop hosts often omit CORS
|
||||||
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 +291,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, ...);
|
||||||
@@ -270,9 +334,9 @@ proxy API and `packages/extract` directly for analysis.
|
|||||||
| GET | `/health` | Liveness | — |
|
| GET | `/health` | Liveness | — |
|
||||||
| GET | `/search?q=&page=` | Scrape Workshop browse, return item list | — |
|
| GET | `/search?q=&page=` | Scrape Workshop browse, return item list | — |
|
||||||
| 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 URL path | 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.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user