feat(web): add full setup view rendering the whole save
Add a /mod/:id/setup page that lays out every renderable object in a single shared scene. Export object-facing mesh wrappers from the viewers and share geometry and materials across objects via module-level caches.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { Suspense, useEffect, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { flattenObjects } from '@tts/extract';
|
||||
import type { TTSObject } from '@tts/shared';
|
||||
import { useModStore } from '../stores/modStore';
|
||||
import { useSearchStore } from '../stores/searchStore';
|
||||
import Scene from '../components/viewers/Scene';
|
||||
import { TileObjectMesh } from '../components/viewers/TileViewer';
|
||||
import { TokenObjectMesh } from '../components/viewers/TokenViewer';
|
||||
import { CardObjectMesh } from '../components/viewers/CardViewer';
|
||||
import { CustomModelMesh } from '../components/viewers/CustomModelViewer';
|
||||
|
||||
/** Object classes that carry renderable assets (mirrors `viewers/register.ts`). */
|
||||
const RENDERABLE = new Set([
|
||||
'Tile',
|
||||
'Custom_Tile',
|
||||
'Custom_Token',
|
||||
'Card',
|
||||
'CardCustom',
|
||||
'Deck',
|
||||
'DeckCustom',
|
||||
'Custom_Deck',
|
||||
'Custom_Model',
|
||||
'Custom_Model_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
|
||||
* 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`).
|
||||
*/
|
||||
export default function FullSetupPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { mod, loading, error, load } = useModStore();
|
||||
const item = useSearchStore((s) => s.items.find((i) => i.id === id));
|
||||
|
||||
useEffect(() => {
|
||||
if (id) load(id, item?.fileUrl);
|
||||
}, [id, item?.fileUrl, load]);
|
||||
|
||||
const objects = useMemo(
|
||||
() => (mod ? flattenObjects(mod) : []),
|
||||
[mod],
|
||||
);
|
||||
|
||||
const renderable = useMemo(
|
||||
() => objects.filter((o) => RENDERABLE.has(o.Name)),
|
||||
[objects],
|
||||
);
|
||||
const skipped = objects.length - renderable.length;
|
||||
|
||||
const placed = useMemo(() => layoutObjects(renderable), [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>;
|
||||
if (!mod) return <p className="text-sm text-zinc-500">No mod loaded.</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Full setup</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
{objects.length} objects · {renderable.length} rendered
|
||||
{skipped > 0 ? ` · ${skipped} skipped` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
</group>
|
||||
))}
|
||||
</Suspense>
|
||||
</Scene>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dispatch a renderable object to the mesh component for its class. */
|
||||
function RenderObject({ object }: { object: TTSObject }) {
|
||||
switch (object.Name) {
|
||||
case 'Tile':
|
||||
case 'Custom_Tile':
|
||||
return <TileObjectMesh object={object} />;
|
||||
case 'Custom_Token':
|
||||
return <TokenObjectMesh object={object} />;
|
||||
case 'Card':
|
||||
case 'CardCustom':
|
||||
case 'Deck':
|
||||
case 'DeckCustom':
|
||||
case 'Custom_Deck':
|
||||
return <CardObjectMesh object={object} />;
|
||||
case 'Custom_Model':
|
||||
case 'Custom_Model_Bag':
|
||||
case 'Custom_Model_Infinite_Bag':
|
||||
return <CustomModelMesh object={object} />;
|
||||
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