Files
tts-workshop/docs/decisions.md
T
hypercross 12418898d0 docs: update mesh, viewers, and selection decisions
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.
2026-08-08 18:18:22 +08:00

262 lines
12 KiB
Markdown

# Decisions
> **Scope:** The rationale behind key design decisions. For the system's
> architecture, see [`architecture.md`](./architecture.md). For the concrete
> build plan, see [`implementation-plan.md`](./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.
## 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.