diff --git a/apps/web/src/components/viewers/CardViewer.tsx b/apps/web/src/components/viewers/CardViewer.tsx index bb7f034..4158e46 100644 --- a/apps/web/src/components/viewers/CardViewer.tsx +++ b/apps/web/src/components/viewers/CardViewer.tsx @@ -11,7 +11,7 @@ import Scene from './Scene'; import { assetUrl } from './assetUrl'; import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution'; import { flipTexture } from './flipTexture'; -import { getSharedGeometry } from './sharedResources'; +import { getSharedGeometry, objectTint, tintedColor } from './sharedResources'; /** Longer card dimension, in world units. */ const CARD_LENGTH = 2; @@ -73,6 +73,7 @@ export function CardObjectMesh({ object }: { object: TTSObject }) { numHeight={numHeight} uniqueBack={uniqueBack} cardId={cardId} + tint={objectTint(object)} /> ); } @@ -86,6 +87,7 @@ export function CardMesh({ numHeight, uniqueBack, cardId, + tint, }: { faceUrl?: string; backUrl?: string; @@ -93,6 +95,7 @@ export function CardMesh({ numHeight?: number; uniqueBack: boolean; cardId?: number; + tint: THREE.Color; }) { // Always call both hooks so the hook count is stable across renders. The // placeholder is used only when a URL is absent; presence is checked via the @@ -154,20 +157,20 @@ export function CardMesh({ - + ); diff --git a/apps/web/src/components/viewers/CustomModelViewer.tsx b/apps/web/src/components/viewers/CustomModelViewer.tsx index bd08f2e..dd653f3 100644 --- a/apps/web/src/components/viewers/CustomModelViewer.tsx +++ b/apps/web/src/components/viewers/CustomModelViewer.tsx @@ -2,11 +2,12 @@ import { Suspense, useLayoutEffect } from 'react'; import { useLoader } from '@react-three/fiber'; import { useTexture } from '@react-three/drei'; import type { TTSObject } from '@tts/shared'; -import type * as THREE from 'three'; +import * as THREE from 'three'; import type { Object3D } from 'three'; import Scene from './Scene'; import { assetUrl } from './assetUrl'; import { FlexibleModelLoader } from './flexibleModelLoader'; +import { objectTint, tintedColor } from './sharedResources'; /** * A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ, @@ -29,18 +30,23 @@ export default function CustomModelViewer({ object }: { object: TTSObject }) { */ export function CustomModelMesh({ object }: { object: TTSObject }) { const meshUrl = object.CustomMesh?.MeshURL; + const tint = objectTint(object); if (!meshUrl) { return ( - + ); } return ( - + ); } @@ -48,9 +54,11 @@ export function CustomModelMesh({ object }: { object: TTSObject }) { function Model({ meshUrl, diffuseUrl, + tint, }: { meshUrl: string; diffuseUrl?: string; + tint: THREE.Color; }) { const root = useLoader(FlexibleModelLoader, assetUrl(meshUrl)); @@ -58,10 +66,33 @@ function Model({ <> {diffuseUrl && } + ); } +// Apply the object's tint to every material on the loaded model, multiplying +// the existing color. Rendered after the model so it runs once the materials +// exist. +function Tint({ root, tint }: { root: Object3D; tint: THREE.Color }) { + useLayoutEffect(() => { + root.traverse((child) => { + const mesh = child as THREE.Mesh; + if (mesh.isMesh) { + const material = Array.isArray(mesh.material) + ? mesh.material[0] + : mesh.material; + if (material && 'color' in material) { + (material as THREE.MeshStandardMaterial).color.multiply(tint); + material.needsUpdate = true; + } + } + }); + }, [root, tint]); + + return null; +} + // Rendered inside the Canvas so `useTexture` can access the R3F store. Only // mounted when a diffuse URL exists, so the hook count stays consistent. function DiffuseTexture({ root, url }: { root: Object3D; url: string }) { diff --git a/apps/web/src/components/viewers/TileViewer.tsx b/apps/web/src/components/viewers/TileViewer.tsx index 606bb5e..704dd2f 100644 --- a/apps/web/src/components/viewers/TileViewer.tsx +++ b/apps/web/src/components/viewers/TileViewer.tsx @@ -14,7 +14,13 @@ import { import Scene from './Scene'; import { assetUrl } from './assetUrl'; import { flipTexture } from './flipTexture'; -import { getSharedGeometry, getSharedMaterial } from './sharedResources'; +import { + getSharedGeometry, + getSharedMaterial, + objectTint, + tintKey, + tintedColor, +} from './sharedResources'; /** `CustomTile.Type` enum from Tabletop Simulator. */ const TileType = { @@ -52,7 +58,15 @@ export function TileObjectMesh({ object }: { object: TTSObject }) { const type = object.CustomImage?.CustomTile?.Type ?? TileType.Box; const stretch = object.CustomImage?.CustomTile?.Stretch ?? true; - return ; + return ( + + ); } // Rendered inside the Canvas so `useTexture` can access the R3F store. @@ -62,11 +76,13 @@ export function TileMesh({ thickness, type, stretch, + tint, }: { url?: string; thickness: number; type: number; stretch: boolean; + tint: THREE.Color; }) { const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null; @@ -93,19 +109,22 @@ export function TileMesh({ // Shared materials: the front/back carry the tile texture (or a neutral // color when absent); the walls are a solid white, matching TTS tinting. - const faceKey = `tile-face:${url ?? 'none'}`; + // The tint is baked into the color and the cache key so tinted variants + // don't collide. + const tintK = tintKey(tint); + const faceKey = `tile-face:${url ?? 'none'}:${tintK}`; const faceMat = getSharedMaterial(faceKey, { - color: texture ? '#ffffff' : '#52525b', + color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint), map: texture ?? undefined, roughness: 0.8, }); const backMat = getSharedMaterial(faceKey + ':back', { - color: texture ? '#ffffff' : '#52525b', + color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint), map: backMap ?? undefined, roughness: 0.8, }); - const wallMat = getSharedMaterial('tile-wall', { - color: '#ffffff', + const wallMat = getSharedMaterial(`tile-wall:${tintK}`, { + color: tintedColor(new THREE.Color('#ffffff'), tint), roughness: 0.8, }); diff --git a/apps/web/src/components/viewers/TokenViewer.tsx b/apps/web/src/components/viewers/TokenViewer.tsx index deed3ca..1e0ddb6 100644 --- a/apps/web/src/components/viewers/TokenViewer.tsx +++ b/apps/web/src/components/viewers/TokenViewer.tsx @@ -11,7 +11,13 @@ import { import { traceImage } from '../../api'; import Scene from './Scene'; import { assetUrl } from './assetUrl'; -import { getSharedGeometry, getSharedMaterial } from './sharedResources'; +import { + getSharedGeometry, + getSharedMaterial, + objectTint, + tintKey, + tintedColor, +} from './sharedResources'; const TOKEN_SIZE = 1.8; @@ -40,12 +46,20 @@ export function TokenObjectMesh({ object }: { object: TTSObject }) { const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL; const thickness = object.CustomImage?.CustomToken?.Thickness ?? 0.1; - return ; + return ; } // Rendered inside the Canvas so `useTexture` can access the R3F store. // Exported so the full-setup view can compose it into a shared scene. -export function TokenMesh({ url, thickness }: { url?: string; thickness: number }) { +export function TokenMesh({ + url, + thickness, + tint, +}: { + url?: string; + thickness: number; + tint: THREE.Color; +}) { const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null; // Trace the image's alpha channel into a shape. Suspends until the trace @@ -72,9 +86,10 @@ export function TokenMesh({ url, thickness }: { url?: string; thickness: number }, [trace, thickness, url]); // A token is solid: front, back, and walls all carry the texture (projected - // UV), unlike tiles/cards where only the faces are textured. - const material = getSharedMaterial(`token:${url ?? 'none'}`, { - color: texture ? '#ffffff' : '#52525b', + // UV), unlike tiles/cards where only the faces are textured. The tint is + // baked into the color and cache key so tinted variants don't collide. + const material = getSharedMaterial(`token:${url ?? 'none'}:${tintKey(tint)}`, { + color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint), map: texture ?? undefined, roughness: 0.8, }); diff --git a/apps/web/src/components/viewers/sharedResources.ts b/apps/web/src/components/viewers/sharedResources.ts index 434d8f6..5307288 100644 --- a/apps/web/src/components/viewers/sharedResources.ts +++ b/apps/web/src/components/viewers/sharedResources.ts @@ -1,4 +1,25 @@ import * as THREE from 'three'; +import type { TTSObject } from '@tts/shared'; + +/** + * The per-object tint (`ColorDiffuse`, 0–1 per channel) as a three.js color, + * defaulting to white when absent. TTS multiplies this by the object's base + * color, so textured faces are tinted too. + */ +export function objectTint(object: TTSObject): THREE.Color { + const c = object.ColorDiffuse; + return c ? new THREE.Color(c.r, c.g, c.b) : new THREE.Color(1, 1, 1); +} + +/** A stable cache key fragment for a tint, so tinted variants don't collide. */ +export function tintKey(color: THREE.Color): string { + return `${color.r},${color.g},${color.b}`; +} + +/** Multiply a base color by the object's tint. */ +export function tintedColor(base: THREE.Color, tint: THREE.Color): THREE.Color { + return base.clone().multiply(tint); +} /** * Module-level caches so the full-setup view can share geometry and materials diff --git a/docs/full-setup-view.md b/docs/full-setup-view.md index a436a3c..73b979a 100644 --- a/docs/full-setup-view.md +++ b/docs/full-setup-view.md @@ -110,7 +110,9 @@ view and the full-setup view. caches - `apps/web/src/components/viewers/transform.ts` — new TTS→three.js placement conversion -- `packages/shared/src/types.ts` — add `TTSObjectTransform` +- `apps/web/src/components/viewers/sharedResources.ts` — `objectTint`/ + `tintedColor` helpers for the per-object `ColorDiffuse` tint +- `packages/shared/src/types.ts` — add `TTSObjectTransform` and `ColorDiffuse` - `apps/web/src/pages/FullSetupPage.tsx` — new - `apps/web/src/App.tsx` — route - `apps/web/src/pages/ModPage.tsx` — nav link diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 11ad83b..4b602df 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -30,6 +30,8 @@ export interface TTSObject { Parent?: TTSObject; /** Placement in the TTS world (position, rotation, scale). */ Transform?: TTSObjectTransform; + /** Tint color (0–1 per channel) applied to the object's base color. */ + ColorDiffuse?: { r: number; g: number; b: number }; CustomPDF?: { PDFUrl: string;