diff --git a/apps/web/src/components/viewers/transform.ts b/apps/web/src/components/viewers/transform.ts new file mode 100644 index 0000000..f3c579b --- /dev/null +++ b/apps/web/src/components/viewers/transform.ts @@ -0,0 +1,85 @@ +import type { TTSObject } from '@tts/shared'; + +/** + * Convert a TTS object's `Transform` (position/rotation/scale in TTS world + * units) into three.js props so the full-setup view can place objects at their + * real table positions. + * + * TTS is left-handed with Y up and +Z toward the player; three.js is + * right-handed with Y up. Converting by reflecting the Z axis (negating Z in + * position and every rotation angle) maps a TTS placement onto the three.js + * scene while preserving the physical layout. + * + * Our viewer meshes are authored for inspection (standing up, extruded along + * Z, sized in arbitrary units), so each class also gets a base-size correction + * and a "lay flat" rotation to match TTS world units and orientation. + */ + +/** Degrees → radians. */ +const rad = (d: number) => (d * Math.PI) / 180; + +/** + * Base-size correction: our viewer mesh size → TTS world units at scale 1. + * `scale` in the transform is multiplied by this so objects end up the right + * relative size on the table. + */ +const CORRECTION: Record = { + // Card long axis is 2 units in the viewer; 1.0 in TTS. + Card: 0.5, + CardCustom: 0.5, + Deck: 0.5, + DeckCustom: 0.5, + Custom_Deck: 0.5, + // Tile is 2 units in the viewer; 1.0 in TTS. + Tile: 0.5, + Custom_Tile: 0.5, + // Token is 1.8 units in the viewer; 0.7 in TTS. + Custom_Token: 0.7 / 1.8, + // Custom models are loaded at 0.5 scale in the viewer; 1.0 in TTS. + Custom_Model: 2, + Custom_Model_Bag: 2, + Custom_Model_Infinite_Bag: 2, +}; + +/** + * Rotation that lays an extruded mesh flat. The tile/token/card meshes extrude + * along +Z with their face in the XY plane (standing up); rotating +90° about + * X puts the face up (+Y), matching how objects lie on a TTS table. Custom + * models are authored standing up already, so they need no correction. + */ +const LAY_FLAT: [number, number, number] = [Math.PI / 2, 0, 0]; + +const FLAT_CLASSES = new Set([ + 'Card', + 'CardCustom', + 'Deck', + 'DeckCustom', + 'Custom_Deck', + 'Tile', + 'Custom_Tile', + 'Custom_Token', +]); + +export interface ObjectPlacement { + position: [number, number, number]; + rotation: [number, number, number]; + scale: [number, number, number]; + /** Rotation applied to the mesh before the TTS rotation (lay flat). */ + layFlat: [number, number, number]; +} + +/** + * Derive the three.js placement for an object from its `Transform`. Returns + * null when the object has no transform (shouldn't happen in a real save). + */ +export function objectPlacement(object: TTSObject): ObjectPlacement | null { + const t = object.Transform; + if (!t) return null; + const corr = CORRECTION[object.Name] ?? 1; + return { + position: [t.posX, t.posY, -t.posZ], + rotation: [-rad(t.rotX), -rad(t.rotY), -rad(t.rotZ)], + scale: [t.scaleX * corr, t.scaleY * corr, t.scaleZ * corr], + layFlat: FLAT_CLASSES.has(object.Name) ? LAY_FLAT : [0, 0, 0], + }; +} \ No newline at end of file diff --git a/apps/web/src/pages/FullSetupPage.tsx b/apps/web/src/pages/FullSetupPage.tsx index ec344b3..ebee470 100644 --- a/apps/web/src/pages/FullSetupPage.tsx +++ b/apps/web/src/pages/FullSetupPage.tsx @@ -9,6 +9,7 @@ import { TileObjectMesh } from '../components/viewers/TileViewer'; import { TokenObjectMesh } from '../components/viewers/TokenViewer'; import { CardObjectMesh } from '../components/viewers/CardViewer'; import { CustomModelMesh } from '../components/viewers/CustomModelViewer'; +import { objectPlacement } from '../components/viewers/transform'; /** Object classes that carry renderable assets (mirrors `viewers/register.ts`). */ const RENDERABLE = new Set([ @@ -25,15 +26,11 @@ const RENDERABLE = new Set([ 'Custom_Model_Infinite_Bag', ]); -/** Grid cell spacing (world units) and columns per row. */ -const CELL = 2.5; -const COLS = 8; - /** * A "full setup" view: renders every loadable object in a save into a single - * shared scene, laid out on a virtual table. Objects are grouped by type so - * like objects sit together. Geometry and materials are shared across objects - * (see `components/viewers/sharedResources.ts`). + * shared scene, placed at its real position from the save's `Transform`. + * Geometry and materials are shared across objects (see + * `components/viewers/sharedResources.ts`). */ export default function FullSetupPage() { const { id } = useParams<{ id: string }>(); @@ -55,7 +52,16 @@ export default function FullSetupPage() { ); const skipped = objects.length - renderable.length; - const placed = useMemo(() => layoutObjects(renderable), [renderable]); + const placed = useMemo( + () => + renderable + .map((object) => ({ object, placement: objectPlacement(object) })) + .filter( + (p): p is { object: TTSObject; placement: NonNullable> } => + p.placement !== null, + ), + [renderable], + ); if (loading) return

Loading mod…

; if (error) return

{error}

; @@ -74,9 +80,16 @@ export default function FullSetupPage() { {/* Key by index, not GUID: cards in a deck share the deck's GUID. */} - {placed.map(({ object, position }, index) => ( - - + {placed.map(({ object, placement }, index) => ( + + + + ))} @@ -106,40 +119,4 @@ function RenderObject({ object }: { object: TTSObject }) { default: return null; } -} - -/** - * Assign each renderable object a grid position, grouping by class so like - * objects sit together. Returns the object plus its `[x, y, z]` position. - */ -function layoutObjects( - objects: TTSObject[], -): { object: TTSObject; position: [number, number, number] }[] { - // Group by type so like objects sit together. - const groups = new Map(); - for (const o of objects) { - const arr = groups.get(o.Name) ?? []; - arr.push(o); - groups.set(o.Name, arr); - } - - const result: { object: TTSObject; position: [number, number, number] }[] = []; - let row = 0; - let col = 0; - for (const objs of groups.values()) { - for (const o of objs) { - result.push({ object: o, position: [col * CELL, 0, row * CELL] }); - col++; - if (col >= COLS) { - col = 0; - row++; - } - } - // Start a fresh row after each group. - if (col !== 0) { - col = 0; - row++; - } - } - return result; } \ No newline at end of file diff --git a/docs/full-setup-view.md b/docs/full-setup-view.md index 28bd1fb..a436a3c 100644 --- a/docs/full-setup-view.md +++ b/docs/full-setup-view.md @@ -23,8 +23,9 @@ user can see the entire scene at a glance. - `@tts/extract` provides `flattenObjects(mod)` / `traverseMod` to enumerate every object in a save. - `@tts/mesh` provides the shape + extrusion helpers used by the viewers. -- `TTSObject` carries no position data, so exact table placement is not - recoverable — the full setup lays objects out itself. +- Every object in a save carries a `Transform` (position/rotation/scale in TTS + world units), which the proxy passes through in the raw BSON. The shared + type now models it as `TTSObjectTransform`. ## Plan @@ -56,10 +57,15 @@ view and the full-setup view. ### 3. Layout -- Arrange objects in a grid on the "table" (fixed spacing, wrapping by row), - grouping by type so cards sit together, tiles together, etc. -- `Scene`'s `Bounds fit` already auto-fits the camera to the full layout, so no - camera work is needed. +- Place each object at its real position from the save's `Transform` instead of + a generated grid. +- `components/viewers/transform.ts` converts a TTS transform to three.js: + TTS is left-handed (Y up, +Z toward the player), three.js is right-handed, + so Z is reflected in position and rotation. Each class also gets a base-size + correction (our viewer meshes are authored for inspection, in arbitrary + units) and a "lay flat" rotation for the extruded tile/token/card meshes. +- `Scene`'s `Bounds fit` auto-fits the camera to the full layout, so no camera + work is needed. - Non-renderable objects (bags, dice, boards without assets) are skipped and counted, not dropped silently. @@ -102,6 +108,9 @@ view and the full-setup view. export object-facing mesh wrappers - `apps/web/src/components/viewers/sharedResources.ts` — new geometry/material caches +- `apps/web/src/components/viewers/transform.ts` — new TTS→three.js placement + conversion +- `packages/shared/src/types.ts` — add `TTSObjectTransform` - `apps/web/src/pages/FullSetupPage.tsx` — new - `apps/web/src/App.tsx` — route - `apps/web/src/pages/ModPage.tsx` — nav link @@ -110,6 +119,7 @@ view and the full-setup view. ## Open decisions (defaults in bold) -- **Layout style** — **grouped grid** vs a fan/stack for cards. +- **Layout style** — **real `Transform` placement** (was a grouped grid before + the transform data was wired in). - **Geometry/material cache lifetime** — **session-level module cache** (simplest, consistent with drei's global texture cache) vs dispose-on-unmount. \ No newline at end of file diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 8886b4a..11ad83b 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1,3 +1,20 @@ +/** + * An object's placement in the TTS world: position, rotation (degrees), and + * scale. TTS uses a left-handed system with Y up; `posZ` points toward the + * player. Present on every object in a save. + */ +export interface TTSObjectTransform { + posX: number; + posY: number; + posZ: number; + rotX: number; + rotY: number; + rotZ: number; + scaleX: number; + scaleY: number; + scaleZ: number; +} + /** * A single object inside a Tabletop Simulator save file. * Mirrors the structure produced by BSON-deserializing a TTS save. @@ -11,6 +28,8 @@ export interface TTSObject { Description: string; /** Set by `markParent` during traversal; not present in the raw save. */ Parent?: TTSObject; + /** Placement in the TTS world (position, rotation, scale). */ + Transform?: TTSObjectTransform; CustomPDF?: { PDFUrl: string;