Compare commits
3
Commits
c04d99fe38
...
a6e9d2dd07
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e9d2dd07 | ||
|
|
dcba0ac4bd | ||
|
|
c3c7fe2445 |
@@ -1,6 +1,6 @@
|
|||||||
import { Suspense, type ReactNode } from 'react';
|
import { Suspense, type ReactNode } from 'react';
|
||||||
import { Canvas } from '@react-three/fiber';
|
import { Canvas } from '@react-three/fiber';
|
||||||
import { Bounds, ContactShadows, OrbitControls } from '@react-three/drei';
|
import { Bounds, ContactShadows, OrbitControls, useProgress } from '@react-three/drei';
|
||||||
import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing';
|
import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,7 +15,8 @@ import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing';
|
|||||||
*/
|
*/
|
||||||
export default function Scene({ children }: { children: ReactNode }) {
|
export default function Scene({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="h-80 w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-900 to-zinc-950">
|
<div className="relative h-80 w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-900 to-zinc-950">
|
||||||
|
<LoadingOverlay />
|
||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [2.2, 1.8, 2.6], fov: 40 }}
|
camera={{ position: [2.2, 1.8, 2.6], fov: 40 }}
|
||||||
dpr={[1, 2]}
|
dpr={[1, 2]}
|
||||||
@@ -54,3 +55,24 @@ export default function Scene({ children }: { children: ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A loading overlay shown while assets (textures, models, traces) are being
|
||||||
|
* fetched. Reads drei's global progress store, which tracks every loader in
|
||||||
|
* the scene, so it works outside the Canvas. Hidden once loading completes.
|
||||||
|
*/
|
||||||
|
function LoadingOverlay() {
|
||||||
|
const { active, progress, item, loaded, total } = useProgress();
|
||||||
|
if (!active) return null;
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-zinc-950/70 text-sm text-zinc-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-zinc-600 border-t-zinc-200" />
|
||||||
|
<span>Loading assets… {Math.round(progress)}%</span>
|
||||||
|
</div>
|
||||||
|
<span className="max-w-80 truncate text-xs text-zinc-500">
|
||||||
|
{loaded}/{total} {item}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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) maps a TTS placement onto the three.js scene while preserving the
|
||||||
|
* physical layout. The rotation is conjugated by the reflection: `R_three =
|
||||||
|
* M·R_tts·M` with `M = diag(1,1,-1)`, which negates `rotX`/`rotY` but leaves
|
||||||
|
* `rotZ` unchanged.
|
||||||
|
*
|
||||||
|
* 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 maps the +Z face normal to +Y, so the face points up like an object lying
|
||||||
|
* 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],
|
||||||
|
// Reflection conjugates the rotation: Rx/Ry negate, Rz keeps its sign.
|
||||||
|
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 { 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>
|
||||||
@@ -107,39 +120,3 @@ function RenderObject({ object }: { object: TTSObject }) {
|
|||||||
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
@@ -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.
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user