feat(web): place full setup objects at their real transforms

Model TTSObjectTransform in shared types and convert each object's position, rotation, and scale into three.js placement, with per-class base-size corrections and a lay-flat rotation for extruded meshes.
This commit is contained in:
2026-08-08 18:41:42 +08:00
parent c3c7fe2445
commit dcba0ac4bd
4 changed files with 145 additions and 54 deletions
@@ -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<string, number> = {
// 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],
};
}
+24 -47
View File
@@ -9,6 +9,7 @@ import { TileObjectMesh } from '../components/viewers/TileViewer';
import { TokenObjectMesh } from '../components/viewers/TokenViewer'; import { TokenObjectMesh } from '../components/viewers/TokenViewer';
import { CardObjectMesh } from '../components/viewers/CardViewer'; import { CardObjectMesh } from '../components/viewers/CardViewer';
import { CustomModelMesh } from '../components/viewers/CustomModelViewer'; import { CustomModelMesh } from '../components/viewers/CustomModelViewer';
import { objectPlacement } from '../components/viewers/transform';
/** Object classes that carry renderable assets (mirrors `viewers/register.ts`). */ /** Object classes that carry renderable assets (mirrors `viewers/register.ts`). */
const RENDERABLE = new Set([ const RENDERABLE = new Set([
@@ -25,15 +26,11 @@ const RENDERABLE = new Set([
'Custom_Model_Infinite_Bag', '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 * 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 * shared scene, placed at its real position from the save's `Transform`.
* like objects sit together. Geometry and materials are shared across objects * Geometry and materials are shared across objects (see
* (see `components/viewers/sharedResources.ts`). * `components/viewers/sharedResources.ts`).
*/ */
export default function FullSetupPage() { export default function FullSetupPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -55,7 +52,16 @@ export default function FullSetupPage() {
); );
const skipped = objects.length - renderable.length; 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<ReturnType<typeof objectPlacement>> } =>
p.placement !== null,
),
[renderable],
);
if (loading) return <p className="text-sm text-zinc-400">Loading mod</p>; if (loading) return <p className="text-sm text-zinc-400">Loading mod</p>;
if (error) return <p className="text-sm text-red-400">{error}</p>; if (error) return <p className="text-sm text-red-400">{error}</p>;
@@ -74,9 +80,16 @@ export default function FullSetupPage() {
<Scene> <Scene>
<Suspense fallback={null}> <Suspense fallback={null}>
{/* Key by index, not GUID: cards in a deck share the deck's GUID. */} {/* Key by index, not GUID: cards in a deck share the deck's GUID. */}
{placed.map(({ object, position }, index) => ( {placed.map(({ object, placement }, index) => (
<group key={index} position={position}> <group
<RenderObject object={object} /> key={index}
position={placement.position}
rotation={placement.rotation}
scale={placement.scale}
>
<group rotation={placement.layFlat}>
<RenderObject object={object} />
</group>
</group> </group>
))} ))}
</Suspense> </Suspense>
@@ -106,40 +119,4 @@ function RenderObject({ object }: { object: TTSObject }) {
default: default:
return null; 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<string, TTSObject[]>();
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;
} }
+17 -7
View File
@@ -23,8 +23,9 @@ user can see the entire scene at a glance.
- `@tts/extract` provides `flattenObjects(mod)` / `traverseMod` to enumerate - `@tts/extract` provides `flattenObjects(mod)` / `traverseMod` to enumerate
every object in a save. every object in a save.
- `@tts/mesh` provides the shape + extrusion helpers used by the viewers. - `@tts/mesh` provides the shape + extrusion helpers used by the viewers.
- `TTSObject` carries no position data, so exact table placement is not - Every object in a save carries a `Transform` (position/rotation/scale in TTS
recoverable — the full setup lays objects out itself. world units), which the proxy passes through in the raw BSON. The shared
type now models it as `TTSObjectTransform`.
## Plan ## Plan
@@ -56,10 +57,15 @@ view and the full-setup view.
### 3. Layout ### 3. Layout
- Arrange objects in a grid on the "table" (fixed spacing, wrapping by row), - Place each object at its real position from the save's `Transform` instead of
grouping by type so cards sit together, tiles together, etc. a generated grid.
- `Scene`'s `Bounds fit` already auto-fits the camera to the full layout, so no - `components/viewers/transform.ts` converts a TTS transform to three.js:
camera work is needed. 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 - Non-renderable objects (bags, dice, boards without assets) are skipped and
counted, not dropped silently. counted, not dropped silently.
@@ -102,6 +108,9 @@ view and the full-setup view.
export object-facing mesh wrappers export object-facing mesh wrappers
- `apps/web/src/components/viewers/sharedResources.ts` — new geometry/material - `apps/web/src/components/viewers/sharedResources.ts` — new geometry/material
caches 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/pages/FullSetupPage.tsx` — new
- `apps/web/src/App.tsx` — route - `apps/web/src/App.tsx` — route
- `apps/web/src/pages/ModPage.tsx` — nav link - `apps/web/src/pages/ModPage.tsx` — nav link
@@ -110,6 +119,7 @@ view and the full-setup view.
## Open decisions (defaults in bold) ## 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** - **Geometry/material cache lifetime** — **session-level module cache**
(simplest, consistent with drei's global texture cache) vs dispose-on-unmount. (simplest, consistent with drei's global texture cache) vs dispose-on-unmount.
+19
View File
@@ -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. * A single object inside a Tabletop Simulator save file.
* Mirrors the structure produced by BSON-deserializing a TTS save. * Mirrors the structure produced by BSON-deserializing a TTS save.
@@ -11,6 +28,8 @@ export interface TTSObject {
Description: string; Description: string;
/** Set by `markParent` during traversal; not present in the raw save. */ /** Set by `markParent` during traversal; not present in the raw save. */
Parent?: TTSObject; Parent?: TTSObject;
/** Placement in the TTS world (position, rotation, scale). */
Transform?: TTSObjectTransform;
CustomPDF?: { CustomPDF?: {
PDFUrl: string; PDFUrl: string;