Files
tts-workshop/docs/implementation-plan.md
T
hypercross 9094ad58e0 feat(proxy): add offset param to inset or outset traced shapes
Trace results can now be inset (negative) or outset (positive) by a
pixel amount. Offset the outline and holes in opposite directions with
clipper-lib's miter joins, then recombine with a boolean difference so
holes grow on inset and shrink on outset. Collapsed shapes return an
empty outline; a split outline keeps the largest ring. Document the
parameter and the new dependency.
2026-08-08 15:37:27 +08:00

395 lines
18 KiB
Markdown

# TTS Workshop — Implementation Plan
> **Scope:** The concrete build plan — files, endpoints, dependencies, build
> order. For the system's architecture and dependency graph, see
> [`architecture.md`](./architecture.md). For the rationale behind key decisions,
> see [`decisions.md`](./decisions.md).
A lightweight, client-only pnpm monorepo for searching the Tabletop Simulator
Steam Workshop, fetching full TTS save files, and analyzing their contents.
## Goals
- Search the TTS Workshop to discover item IDs (Steam has no official search API,
so this scrapes the browse page).
- Fetch a full TTS save (`TTSMod`) for a given item ID via the Steam Web API +
BSON deserialization.
- Extract and inspect objects and asset references from a save, in an isomorphic
package reusable from a future frontend.
- Client-only, lightweight, no caching layer.
## Non-goals (for now)
- Backend traversal endpoints (deferred by design).
- Caching / Redis / multi-instance concerns.
## Architecture
```
apps/web ──► apps/proxy ──► packages/tts ──► packages/shared
│ │ │
│ │ └──► (fetchMod → TTSMod)
│ ▼
├──► packages/mesh ──► packages/shared (types)
└──► packages/extract ──► packages/shared (types)
(isomorphic, used by the frontend)
```
- **`apps/web`** — React frontend (search + mod pages).
- **`apps/proxy`** — Hono server. Search + fetch only. No traversal endpoints.
- **`packages/tts`** — low-level fetcher: Steam API call, BSON parse, filename
derivation. Returns raw parsed `TTSMod`.
- **`packages/extract`** — analysis: flatten/filter objects, extract asset refs,
download assets. Isomorphic (browser + Node).
- **`packages/mesh`** — 2D shapes + extrusion into 3D geometry for the
frontend viewers. Isomorphic (browser + Node).
- **`packages/shared`** — shared types + zod schemas.
## Repository layout
```
tts-workshop/
├── pnpm-workspace.yaml
├── package.json # root scripts (dev, build, lint)
├── tsconfig.base.json
├── .npmrc
├── .env.example # STEAM_API_KEY, PORT
├── docs/
│ └── implementation-plan.md # this file
├── apps/
│ ├── proxy/
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ └── src/
│ │ ├── index.ts # Hono app + @hono/node-server
│ │ ├── routes/
│ │ │ ├── search.ts # GET /search?q=...&page=1
│ │ │ ├── items.ts # GET /items/:id, /items/:id/file
│ │ │ ├── asset.ts # GET /asset?url=... (CORS-safe asset proxy)
│ │ │ ├── trace.ts # GET /trace?url=... (image → vector shape)
│ │ │ ├── svgShape.ts # parse vtracer SVG into { outline, holes }
│ │ │ └── health.ts # GET /health
│ │ └── env.ts # zod env validation
│ └── web/
│ ├── package.json
│ ├── tsconfig.json
│ ├── vite.config.ts # dev proxy → localhost:3000
│ ├── index.html
│ └── src/
│ ├── main.tsx # React root + router
│ ├── App.tsx # layout + routes
│ ├── api.ts # fetch wrappers for /search, /items
│ ├── index.css # tailwind v4 entry
│ ├── pages/
│ │ ├── SearchPage.tsx
│ │ └── ModPage.tsx
│ ├── components/
│ │ ├── SearchResults.tsx
│ │ ├── ObjectTree.tsx # containment-tree sidebar
│ │ ├── viewers.tsx # viewer registry + default viewer
│ │ ├── viewers/ # 3D viewers (r3f/drei/postprocessing)
│ │ │ ├── register.ts # registers per-class 3D viewers (lazy)
│ │ │ ├── Scene.tsx # shared Canvas: lights, controls, post
│ │ │ ├── TileViewer.tsx
│ │ │ ├── TokenViewer.tsx
│ │ │ ├── CardViewer.tsx
│ │ │ ├── CustomModelViewer.tsx
│ │ │ └── assetUrl.ts # route asset URLs through the proxy
│ │ ├── objectIcons.tsx # class → icon mapping
│ │ ├── objectIconsData.ts # generated icon subset (do not edit)
│ │ └── objectIcons.test.ts
│ └── stores/
│ ├── searchStore.ts # zustand
│ └── modStore.ts # zustand
└── packages/
├── shared/
│ └── src/
│ ├── types.ts # TTSMod, TTSObject, WorkshopItem, SearchResult
│ └── schemas.ts
├── tts/
│ └── src/
│ ├── index.ts # fetchMod, getFileName
│ └── errors.ts
├── mesh/
│ └── src/
│ ├── index.ts # public API barrel
│ ├── types.ts # FaceGeometry, ExtrudedGeometry, UVBounds
│ ├── shapes.ts # Shape + shape generators (rect, circle, ...)
│ ├── tessellate.ts # triangulate + cap faces
│ ├── walls.ts # side walls
│ └── extrude.ts # extrudeShape / extrudeShapeParts
└── extract/
├── package.json
├── tsconfig.json
└── src/
├── index.ts # public API barrel
├── objects.ts # flatten/filter/traverse helpers
├── refs.ts # extract asset references
├── download.ts # fetch + decode referenced assets
└── types.ts # extracted-object / asset types
```
## Packages
### `packages/shared`
Shared types and validation schemas used across the monorepo.
- `types.ts`
- `TTSMod`, `TTSObject` (from the existing scraper code)
- `WorkshopItem` — metadata from the Steam API (`id`, `title`, `author`,
`previewImageUrl`, `fileUrl`, ...)
- `SearchResult``{ items: WorkshopItem[], page, hasMore }`
- `schemas.ts`
- zod schemas for env vars, query params, and response shapes.
### `packages/tts`
Low-level fetcher, extracted from the existing scraper.
- `index.ts`
- `fetchMod(id: string): Promise<TTSMod>` — Steam API call to
`ISteamRemoteStorage/GetPublishedFileDetails/v1` to get `file_url`, then
download + BSON-deserialize into `TTSMod`.
- `fetchModFromUrl(fileUrl)` — download + BSON-deserialize from a direct URL
(no Steam API call).
- `fetchModFile(id, apiKey)` / `fetchModFileFromUrl(fileUrl)` — raw save
bytes + derived filename, with and without the Steam API.
- `getFileName(url: string): Promise<string>` — derive filename from the
`content-disposition` header.
- `errors.ts`
- Typed errors: missing `file_url`, Steam API failure, rate limit, invalid key.
- Notes
- Swap the browser `BSON` global for the `bson` npm package.
- `traverseMod` / `markParent` move to `packages/extract` (traversal is
analysis, not fetching).
### `packages/extract`
Isomorphic analysis of a parsed `TTSMod`. No Node-specific APIs.
- `objects.ts`
- `flattenObjects(mod)` — all objects in the tree.
- `filterObjects(mod, predicate)` — filter by name, GUID, type, etc.
- `findObject(mod, guid)` — lookup by GUID.
- `buildTree(mod)` — containment tree (`{ object, label, children }`),
mirroring how TTS nests objects; `label` is `Nickname` when present,
else the class `Name`.
- `traverseMod` / `markParent` (moved from `tts`).
- Returns lightweight graph shapes: `{ guid, name, type, parentGuid,
childrenGuids, refs }`.
- `refs.ts`
- `extractRefs(object)` — walk one object, pull every external URL:
`CustomPDF.PDFUrl`, `CustomDeck[*].FaceURL`/`BackURL`,
`CustomImage.ImageURL`/`ImageSecondaryURL`.
- `collectRefs(mod)` — all refs across the save, deduped by URL.
- `AssetRef` type: `{ kind, url, ownerGuid }`.
- `download.ts`
- `downloadAsset(url)` — `fetch` → `Blob`.
- `downloadAll(refs, { concurrency, onProgress })` — batched downloads with
progress callback.
- `guessMimeType(url)` — infer mime from extension.
- Design constraints
- No `Buffer` — use `ArrayBuffer` / `Uint8Array` / `Blob` (Node 18+).
- No Node-only packages (`cheerio` stays in the backend search only).
- Pure, deterministic functions where possible.
### `packages/mesh`
2D shape + extrusion library used by the frontend viewers to build 3D geometry.
- `shapes.ts`
- `Shape` — `{ outline, holes }`, the minimal interface the tessellator and
wall generator need. Shape generators: `rectShape`, `polygonShape`,
`hexShape`, `circleShape`, `roundedRectShape`, `frameShape`, plus
`scaleShape` and `signedArea`.
- `tessellate.ts`
- `triangulate(shape)` — earcut triangulation (same as three.js).
- `capFaces(shape, height, uvScale, uvBounds)` — top/bottom faces. UVs map
the shape's bounding box (or `uvBounds` framing) to the unit square; the
bottom face uses the same planar xy mapping as the top (no mirror).
- `walls.ts`
- `wallFaces(shape, height, uvScale, uvBounds)` — side walls with outward
normals and planar xy UVs (z-independent).
- `extrude.ts`
- `extrudeShape(shape, options)` — merged caps + walls as one geometry.
- `extrudeShapeParts(shape, options)` — caps and walls as separate
geometries (for distinct materials).
- `ExtrudeOptions` — `height`, `capUvScale`, `wallUvScale`, `uvBounds`.
- `types.ts`
- `FaceGeometry`, `ExtrudedGeometry`, `UVBounds`.
### `apps/proxy`
Hono server exposing search + fetch.
- `index.ts` — Hono app + `@hono/node-server` bootstrap, CORS via `@hono/cors`.
- `routes/search.ts`
- `GET /search?q=...&page=1` — scrape
`steamcommunity.com/workshop/browse/?appid=286160&searchtext=...` with
`cheerio`, extract `{ id, title, author, previewImageUrl }` from the result
grid. Supports `pagenum` pagination. No API key required.
- `routes/items.ts`
- `GET /items/:id` — full parsed `TTSMod`. Accepts an optional `fileUrl`
query param to download the save directly (no Steam API key needed);
otherwise resolves via the Steam API.
- `GET /items/:id/file` — raw save bytes, filename from `getFileName`. Also
accepts `fileUrl`.
- `routes/asset.ts`
- `GET /asset?url=...` — fetch an external asset (texture, model) and stream
it back with a `Content-Type` header. Workshop hosts often omit CORS
headers, which would block three.js loaders in the browser; routing through
the proxy makes those assets loadable. Only `http(s)` URLs are allowed.
- `routes/trace.ts`
- `GET /trace?url=...&mode=alpha&threshold=128&format=shape&offset=...` —
fetch an image, trace it into a vector shape, and return the result
BSON-encoded. `mode` is `alpha` (default), `bw`, or `color`; `format` is
`shape` (default) or `svg`; `offset` (optional) insets (negative) or
outsets (positive) the shape in pixels. `shape` returns a parsed
`{ outline, holes }` polygon matching `@tts/mesh`'s `Shape` interface,
ready to extrude.
- `routes/svgShape.ts`
- `parseSvgShape(svg)` — parse a vtracer SVG into a `TracedShape`
(`{ outline, holes }`): flatten beziers to polylines, split subpaths into
rings, classify by winding (CCW outline / CW hole), and assign holes to
their containing outline.
- `offsetShape(shape, delta)` — inset/outset a `TracedShape` via
`clipper-lib` (Clipper miter joins); outline and holes offset in opposite
directions and are recombined with a boolean difference, so holes grow on
inset and shrink on outset. Collapsed shapes return an empty outline; a
split outline keeps the largest ring.
- `routes/health.ts`
- `GET /health` — liveness.
- `env.ts` — zod validation of `STEAM_API_KEY`, `PORT`.
### `apps/web`
React frontend (Vite + React Router + Tailwind v4 + Zustand). Consumes the
proxy API and `packages/extract` directly for analysis.
- `main.tsx` — React root, `BrowserRouter`.
- `App.tsx` — app shell (header) + routes: `/` (search), `/mod/:id` (mod).
- `api.ts` — typed `fetch` wrappers for `/search` and `/items/:id`, plus a
`modFileUrl` helper for the raw-file download.
- `pages/SearchPage.tsx` — search form, drives `searchStore`.
- `pages/ModPage.tsx` — loads the mod via `modStore`, uses `@tts/extract`
(`buildTree`, `collectRefs`) to render a containment-tree sidebar and an
inspector pane for the selected object, and links to the raw save file.
- `components/SearchResults.tsx` — result grid + pagination.
- `components/ObjectTree.tsx` — recursive tree sidebar; each entry shows a
class icon (hover for the class name) + display label, indented by depth.
Clicking selects an object.
- `components/viewers.tsx` — viewer registry (`registerViewer` /
`resolveViewer`) plus a `DefaultViewer` that renders an object's fields;
custom per-class viewers can be registered later.
- `components/viewers/` — 3D viewers built on `@react-three/fiber`,
`@react-three/drei`, and `@react-three/postprocessing`. `register.ts`
registers lazy-loaded viewers for `Tile`/`Custom_Tile` (flat box),
`Custom_Token` (shape traced from the image's alpha channel via `/trace`,
extruded with `@tts/mesh`), `Card`/`Deck`/`Custom_Deck` (thin box with
face/back textures), and `Custom_Model`/`Custom_Model_Bag`/
`Custom_Model_Infinite_Bag` (GLTF/OBJ/FBX from `CustomMesh.MeshURL`).
`Scene.tsx` is a shared canvas with lighting, orbit controls, contact
shadows, and subtle bloom/vignette. `assetUrl.ts` routes asset URLs through
the proxy for CORS-safe loading. The viewers are lazy-loaded so the three.js
stack is code-split out of the main bundle.
- `components/objectIcons.tsx` — maps TTS object classes to one or more
Iconify icons (`iconsForObject`); unknown classes fall back to a help icon.
Icons may come from multiple sets (mdi, material-symbols, file-icons, ...);
names not bundled are fetched from the Iconify CDN at runtime.
- `components/objectIconsData.ts` — generated by
`scripts/generate-object-icons.mjs`; registers a small mdi subset with
Iconify's offline storage so those render without a network round-trip.
- `stores/searchStore.ts` / `stores/modStore.ts` — Zustand stores for search
and mod state.
- `vite.config.ts` — dev proxy for `/search`, `/items`, `/health` →
`http://localhost:3000`.
- Styling: Tailwind v4 via `@tailwindcss/vite`; `index.css` imports
`tailwindcss`.
## Endpoints
| Method | Path | Description | Auth |
| ------ | ------------------- | -------------------------------------------- | ---- |
| GET | `/health` | Liveness | — |
| GET | `/search?q=&page=` | Scrape Workshop browse, return item list | — |
| GET | `/items/:id` | Full parsed `TTSMod` (`?fileUrl=` skips key) | key* |
| GET | `/items/:id/file` | Raw save bytes, filename from header | key* |
| GET | `/asset?url=` | CORS-safe proxy for external assets | — |
| GET | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON) | — |
\* `STEAM_API_KEY` is optional; `/items/*` works without it when a `fileUrl`
query param is supplied.
## Data flow
```
/search?q=wingspan → scrape browse page → list of {id, title, fileUrl, ...}
/items/:id → Steam API (needs key) → file_url
└─ /items/:id?fileUrl=... → download directly (no key)
download BSON → parse → TTSMod
packages/extract → flatten objects / extract refs / download assets
/trace?url=... → sharp decode → vtracer → parse SVG → BSON shape
```
## Dependencies
- `hono`, `@hono/node-server`, `@hono/cors` — server.
- `bson` — BSON deserialization (save files + trace results).
- `@visioncortex/vtracer` — raster-to-SVG vectorization (wasm).
- `sharp` — image decoding to RGBA for tracing.
- `svgpath` — SVG path parsing for traced shapes.
- `cheerio` — Workshop browse page scraping (backend only).
- `zod` — validation.
- `react`, `react-dom`, `react-router-dom`, `zustand` — frontend.
- `three`, `@react-three/fiber`, `@react-three/drei`,
`@react-three/postprocessing` — 3D object viewers.
- `@iconify/react`, `@iconify-json/mdi`, `@iconify/utils` — iconify icons for
object class tags (subset bundled via `scripts/generate-object-icons.mjs`).
- `vite`, `@vitejs/plugin-react`, `tailwindcss`, `@tailwindcss/vite` — frontend tooling.
- `tsx`, `typescript`, `eslint`, `prettier` — tooling.
## Tooling
- TypeScript strict mode.
- `tsx` for dev, `tsc` for build; `vite` for the frontend dev server/build.
- `vitest` for unit tests, colocated as `*.test.ts` next to sources.
- Root scripts: `pnpm dev`, `pnpm dev:web`, `pnpm build`, `pnpm test`, `pnpm lint`.
## Build order
1. Scaffold workspace (`pnpm-workspace.yaml`, root `package.json`, base tsconfig,
`.npmrc`).
2. `packages/shared` — types + zod schemas.
3. `packages/tts` — fetch + parse (existing scraper code).
4. `packages/extract` — objects, refs, download.
5. `apps/proxy` — search + items + health routes, env validation.
6. `apps/web` — React frontend (search + mod pages), consuming the proxy and
`packages/extract`.
7. Wire up root scripts, `.env.example`, README.
## Risks / caveats
- **Search scraping is fragile** — Steam can change their HTML or rate-limit.
No official search API exists, so this is the standard approach.
- **Members-only items** won't appear in scraped search results.
- **`file_url` may be missing/expired** — handle cleanly with a 404-style error.
- **BSON parsing is the expensive part** — large saves; no caching by design
(client-only).
- **`STEAM_API_KEY` is exposed in the client process** — acceptable for a
personal tool; the key is only needed for the metadata call that yields
`file_url`.
## Open decisions (defaults in bold)
- **`packages/extract` vs folding into `packages/tts`** — **separate package**
(clean fetch vs analyze boundary).
- **Download output type** — **`Blob`** (easier for `<img>`/`<object>` in a
frontend) vs raw `ArrayBuffer`.
- **Move `traverseMod`/`markParent` into `extract`** — **yes** (traversal is
analysis, not fetching).