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:
2026-08-08 18:28:03 +08:00
parent 12418898d0
commit e88dd03fac
9 changed files with 469 additions and 98 deletions
+32 -19
View File
@@ -11,6 +11,7 @@ import Scene from './Scene';
import { assetUrl } from './assetUrl';
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
import { flipTexture } from './flipTexture';
import { getSharedGeometry } from './sharedResources';
/** Longer card dimension, in world units. */
const CARD_LENGTH = 2;
@@ -49,25 +50,36 @@ const FALLBACK_URL =
* the card.
*/
export default function CardViewer({ object }: { object: TTSObject }) {
const { cardId, faceUrl, backUrl, numWidth, numHeight, uniqueBack } =
resolveCardConfig(object);
return (
<Scene>
<CardMesh
faceUrl={faceUrl}
backUrl={backUrl}
numWidth={numWidth}
numHeight={numHeight}
uniqueBack={uniqueBack}
cardId={cardId}
/>
<CardObjectMesh object={object} />
</Scene>
);
}
/**
* The card mesh for an object, exported so the full-setup view can compose it
* into a shared scene.
*/
export function CardObjectMesh({ object }: { object: TTSObject }) {
const { cardId, faceUrl, backUrl, numWidth, numHeight, uniqueBack } =
resolveCardConfig(object);
return (
<CardMesh
faceUrl={faceUrl}
backUrl={backUrl}
numWidth={numWidth}
numHeight={numHeight}
uniqueBack={uniqueBack}
cardId={cardId}
/>
);
}
// Rendered inside the Canvas so `useTexture` can access the R3F store.
function CardMesh({
// Exported so the full-setup view can compose it into a shared scene.
export function CardMesh({
faceUrl,
backUrl,
numWidth,
@@ -116,7 +128,9 @@ function CardMesh({
// Build the rounded-rect geometry from the card sprite's aspect ratio. The
// front and back faces each get their own material; the walls are a solid
// white, matching TTS card tinting.
// white, matching TTS card tinting. Geometry is shared across cards of the
// same size so the full-setup view reuses it; the face/back materials stay
// per-card because each card clones its texture for sprite UVs.
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as
| HTMLImageElement
@@ -127,13 +141,12 @@ function CardMesh({
// Radius scales with the shorter edge so corners look proportional and
// stay circular (no scaling distortion).
const shape = roundedRectShape(width, height, CORNER_RADIUS * Math.min(width, height));
const { front: frontGeo, back: backGeo, walls: wallsGeo } = extrudeShapeParts(shape, {
height: CARD_THICKNESS,
});
const parts = extrudeShapeParts(shape, { height: CARD_THICKNESS });
const key = `card:${width}:${height}:${CARD_THICKNESS}`;
return {
frontGeo: toGeometry(frontGeo),
backGeo: toGeometry(backGeo),
wallsGeo: toGeometry(wallsGeo),
frontGeo: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
backGeo: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
wallsGeo: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
};
}, [faceUrl, face, backUrl, back, numWidth, numHeight]);