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;