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.
This commit is contained in:
2026-08-08 18:18:22 +08:00
parent ead2ba8c57
commit 12418898d0
3 changed files with 105 additions and 18 deletions
+77 -1
View File
@@ -183,4 +183,80 @@ 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.
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.