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
+24 -47
View File
@@ -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;
}