Files
tts-workshop/docs/decisions.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

8.2 KiB

Decisions

Scope: The rationale behind key design decisions. For the system's architecture, see architecture.md. For the concrete build plan, see implementation-plan.md.

Each entry records the decision, the context, and the alternatives considered. New entries are appended; existing entries are updated only to correct facts, not to rewrite history.

D1 — Client-only, no caching

Decision: The system runs on the client only and has no caching layer.

Context: The tool is a personal, single-user utility. There is no shared state to protect and no multi-instance deployment.

Alternatives considered: In-memory TTL caching; Redis-backed caching. Both rejected as unnecessary complexity for a single client.

D2 — Separate packages/extract from packages/tts

Decision: Analysis lives in packages/extract; fetching lives in packages/tts. They are separate packages with a clean fetch-vs-analyze boundary.

Context: The user wants to analyze saves and extract objects for a future frontend. Keeping analysis separate lets it be reused independently of the fetcher.

Alternatives considered: Folding analysis into packages/tts. Rejected — couples fetch and analysis and would force the frontend to depend on the fetcher.

D3 — Traversal lives in packages/extract

Decision: traverseMod / markParent move from the fetcher into packages/extract.

Context: Traversal is analysis, not fetching. packages/tts should be fetch-only.

Alternatives considered: Keeping traversal in packages/tts. Rejected — blurs the fetch/analyze boundary.

D4 — packages/extract is isomorphic with zero runtime deps

Decision: packages/extract runs in browser and Node, using only fetch, Blob, and typed arrays. It has no external runtime dependencies.

Context: The frontend (deferred) will consume it directly, so it must be portable. Node 18+ provides global fetch and Blob.

Alternatives considered: Using Buffer and Node-only packages. Rejected — breaks browser use.

D5 — No backend traversal endpoints

Decision: The proxy exposes search and fetch only. Traversal is not exposed as HTTP endpoints.

Context: The frontend will use packages/extract directly. Exposing traversal on the backend would duplicate that logic and add endpoints with no current consumer.

Alternatives considered: Adding /items/:id/objects and similar endpoints. Deferred until a consumer exists.

D6 — Search scrapes the Workshop browse page

Decision: Search is implemented by scraping steamcommunity.com/workshop/browse/?appid=286160 with cheerio.

Context: Steam has no official Web API for searching the Workshop; the API only fetches details for known IDs.

Alternatives considered: None viable — there is no official search API. Accepted risk: scraping is fragile and may break if Steam changes its HTML.

D7 — bson npm package replaces the browser BSON global

Decision: The existing scraper's BSON.deserialize global is replaced with the bson npm package.

Context: The scraper was written for the browser; the proxy runs on Node.

Alternatives considered: Keeping a browser-only global. Rejected — not available in Node.

D8 — Download output type is Blob

Decision: packages/extract's downloadAsset / downloadAll return Blob.

Context: The primary consumer is a frontend that renders assets in <img> / <object> elements.

Alternatives considered: Raw ArrayBuffer. Rejected — less convenient for frontend rendering.

D9 — Frontend stack: React + React Router + Tailwind v4 + Zustand

Decision: The frontend (apps/web) uses React with React Router for routing, Tailwind CSS v4 for styling, and Zustand for state, built with Vite.

Context: The frontend consumes the proxy API for search/fetch and packages/extract directly for analysis. React is the de-facto standard for this kind of tool; React Router provides declarative routes (/ and /mod/:id); Tailwind v4 is the current major version and integrates via @tailwindcss/vite; Zustand is a minimal, unopinionated store that fits the small amount of client state (search + mod).

Alternatives considered: Next.js (heavier than needed for a client-only tool); Redux Toolkit (more boilerplate than warranted); CSS Modules (no utility styling).

D10 — Object inspection via a viewer registry

Decision: The mod page shows a containment-tree sidebar (mirroring how TTS nests objects) and an inspector pane. The inspector resolves a per-class viewer from a registry (registerViewer / resolveViewer), falling back to a DefaultViewer that renders an object's raw fields.

Context: The user wants to visualize objects in a save. A flat list of all objects and assets is hard to navigate, so the UI was reworked around a tree and a selected-object inspector. Custom viewers per object class (Card, Bag, ...) are expected later; a registry keeps that extension point explicit without coupling the page to any one viewer.

Alternatives considered: A single monolithic inspector component. Rejected — would grow unboundedly as per-class viewers are added; the registry keeps each viewer isolated and swappable.

D11 — 3D viewers on the r3f/drei/postprocessing stack

Decision: Per-class 3D viewers for tiles, tokens, cards, and custom models are built on @react-three/fiber, @react-three/drei, and @react-three/postprocessing, registered through the existing viewer registry and lazy-loaded so the three.js stack is code-split out of the main bundle.

Context: The user wants to visualize objects in a save in 3D. The viewer registry (D10) already provides the extension point. Custom models load from CustomMesh.MeshURL (GLTF/OBJ/FBX); tiles/tokens/cards use their CustomImage/CustomDeck textures. Unity asset bundles are out of scope.

Alternatives considered: A single monolithic 3D inspector; a WebGL library other than three.js. Rejected — the registry keeps viewers isolated, and r3f is the de-facto React/three.js integration.

D12 — CORS-safe asset proxy

Decision: The proxy exposes GET /asset?url=..., which fetches an external asset and streams it back with a Content-Type header. Only http(s) URLs are allowed.

Context: Workshop asset hosts (steamusercontent.com, etc.) often omit CORS headers, which would block three.js TextureLoader / GLTFLoader in the browser. Routing asset fetches through the proxy makes them loadable.

Alternatives considered: Loading assets directly in the browser. Rejected — CORS failures on common Workshop hosts would break the viewers.

D13 — Server-side image tracing to a mesh-ready shape

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 configurable: mode (alpha default, bw, color) selects how the region is derived, format (shape default, svg) selects the response, and offset (optional, in pixels) insets (negative) or outsets (positive) the resulting 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 art) using @tts/mesh. vtracer only returns SVG, but the mesh package consumes { outline, holes }. Tracing on the server keeps the SVG-to-shape parsing out of the browser and lets the BSON response carry a ready-to-extrude shape. sharp decodes the image to RGBA so alpha/luminance masks can be built before feeding vtracer's convertPixels.

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 parsing yields a shape the mesh package can consume directly, and alpha-based 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.