Document the separated front/back/walls extrusion, material-level back flips, CardID sprite selection from the parent deck, index-path tree selection, and the Bounds-inside-Suspense camera fit.
19 KiB
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. For the rationale behind key decisions, seedecisions.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 parsedTTSMod.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.tsTTSMod,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.tsfetchMod(id: string): Promise<TTSMod>— Steam API call toISteamRemoteStorage/GetPublishedFileDetails/v1to getfile_url, then download + BSON-deserialize intoTTSMod.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 thecontent-dispositionheader.
errors.ts- Typed errors: missing
file_url, Steam API failure, rate limit, invalid key.
- Typed errors: missing
- Notes
- Swap the browser
BSONglobal for thebsonnpm package. traverseMod/markParentmove topackages/extract(traversal is analysis, not fetching).
- Swap the browser
packages/extract
Isomorphic analysis of a parsed TTSMod. No Node-specific APIs.
objects.tsflattenObjects(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;labelisNicknamewhen present, else the className.traverseMod/markParent(moved fromtts).- Returns lightweight graph shapes:
{ guid, name, type, parentGuid, childrenGuids, refs }.
refs.tsextractRefs(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.AssetReftype:{ kind, url, ownerGuid }.
download.tsdownloadAsset(url)—fetch→Blob.downloadAll(refs, { concurrency, onProgress })— batched downloads with progress callback.guessMimeType(url)— infer mime from extension.
- Design constraints
- No
Buffer— useArrayBuffer/Uint8Array/Blob(Node 18+). - No Node-only packages (
cheeriostays in the backend search only). - Pure, deterministic functions where possible.
- No
packages/mesh
2D shape + extrusion library used by the frontend viewers to build 3D geometry.
shapes.tsShape—{ outline, holes }, the minimal interface the tessellator and wall generator need. Shape generators:rectShape,polygonShape,hexShape,circleShape,roundedRectShape,frameShape, plusscaleShapeandsignedArea.
tessellate.tstriangulate(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 (oruvBoundsframing) 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.tswallFaces(shape, height, uvScale, uvBounds)— side walls with outward normals and planar xy UVs (z-independent).
extrude.tsextrudeShape(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.tsFaceGeometry,ExtrudedGeometry,UVBounds.
apps/proxy
Hono server exposing search + fetch.
index.ts— Hono app +@hono/node-serverbootstrap, CORS via@hono/cors.routes/search.tsGET /search?q=...&page=1— scrapesteamcommunity.com/workshop/browse/?appid=286160&searchtext=...withcheerio, extract{ id, title, author, previewImageUrl }from the result grid. Supportspagenumpagination. No API key required.
routes/items.tsGET /items/:id— full parsedTTSMod. Accepts an optionalfileUrlquery 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 fromgetFileName. Also acceptsfileUrl.
routes/asset.tsGET /asset?url=...— fetch an external asset (texture, model) and stream it back with aContent-Typeheader. Workshop hosts often omit CORS headers, which would block three.js loaders in the browser; routing through the proxy makes those assets loadable. Onlyhttp(s)URLs are allowed.
routes/trace.tsGET /trace?url=...&mode=alpha&threshold=128&format=shape&offset=...— fetch an image, trace it into a vector shape, and return the result BSON-encoded.modeisalpha(default),bw, orcolor;formatisshape(default) orsvg;offset(optional) insets (negative) or outsets (positive) the shape in pixels.shapereturns a parsed{ outline, holes }polygon matching@tts/mesh'sShapeinterface, ready to extrude.
routes/svgShape.tsparseSvgShape(svg)— parse a vtracer SVG into aTracedShape({ 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 aTracedShapeviaclipper-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.tsGET /health— liveness.
env.ts— zod validation ofSTEAM_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— typedfetchwrappers for/searchand/items/:id, plus amodFileUrlhelper for the raw-file download.pages/SearchPage.tsx— search form, drivessearchStore.pages/ModPage.tsx— loads the mod viamodStore, 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 by its unique index path (not GUID — cards in a deck share the deck's GUID).components/viewers.tsx— viewer registry (registerViewer/resolveViewer) plus aDefaultViewerthat 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.tsregisters lazy-loaded viewers forTile/Custom_Tile(flat box),Custom_Token(shape traced from the image's alpha channel via/trace, extruded with@tts/mesh),Card/CardCustom/Deck/DeckCustom/Custom_Deck(thin rounded rect with face/back textures), andCustom_Model/Custom_Model_Bag/Custom_Model_Infinite_Bag(GLTF/OBJ/FBX fromCustomMesh.MeshURL). Viewers build{ front, back, walls }geometry viaextrudeShapeParts; back faces are flipped left/right on the material (flipTexture.ts) so they aren't mirrored, and card faces slice the deck sprite sheet viaCardID(cardResolution.ts).Scene.tsxis a shared canvas with lighting, orbit controls, contact shadows, and subtle bloom/vignette; it fits the camera to the object's bounds via drei'sBoundsinside the Suspense boundary, so it frames the loaded content.assetUrl.tsroutes 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 byscripts/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.cssimportstailwindcss.
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 viascripts/generate-object-icons.mjs).vite,@vitejs/plugin-react,tailwindcss,@tailwindcss/vite— frontend tooling.tsx,typescript,eslint,prettier— tooling.
Tooling
- TypeScript strict mode.
tsxfor dev,tscfor build;vitefor the frontend dev server/build.vitestfor unit tests, colocated as*.test.tsnext to sources.- Root scripts:
pnpm dev,pnpm dev:web,pnpm build,pnpm test,pnpm lint.
Build order
- Scaffold workspace (
pnpm-workspace.yaml, rootpackage.json, base tsconfig,.npmrc). packages/shared— types + zod schemas.packages/tts— fetch + parse (existing scraper code).packages/extract— objects, refs, download.apps/proxy— search + items + health routes, env validation.apps/web— React frontend (search + mod pages), consuming the proxy andpackages/extract.- 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_urlmay 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_KEYis exposed in the client process — acceptable for a personal tool; the key is only needed for the metadata call that yieldsfile_url.
Open decisions (defaults in bold)
packages/extractvs folding intopackages/tts— separate package (clean fetch vs analyze boundary).- Download output type —
Blob(easier for<img>/<object>in a frontend) vs rawArrayBuffer. - Move
traverseMod/markParentintoextract— yes (traversal is analysis, not fetching).