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:
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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<ReturnType<typeof objectPlacement>> } =>
|
||||
p.placement !== null,
|
||||
),
|
||||
[renderable],
|
||||
);
|
||||
|
||||
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>;
|
||||
@@ -74,9 +80,16 @@ export default function FullSetupPage() {
|
||||
<Scene>
|
||||
<Suspense fallback={null}>
|
||||
{/* Key by index, not GUID: cards in a deck share the deck's GUID. */}
|
||||
{placed.map(({ object, position }, index) => (
|
||||
<group key={index} position={position}>
|
||||
<RenderObject object={object} />
|
||||
{placed.map(({ object, placement }, index) => (
|
||||
<group
|
||||
key={index}
|
||||
position={placement.position}
|
||||
rotation={placement.rotation}
|
||||
scale={placement.scale}
|
||||
>
|
||||
<group rotation={placement.layFlat}>
|
||||
<RenderObject object={object} />
|
||||
</group>
|
||||
</group>
|
||||
))}
|
||||
</Suspense>
|
||||
@@ -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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user