feat(web): tint objects by their ColorDiffuse
Model ColorDiffuse in shared types and multiply each viewer's material color by the per-object tint, including textured faces and loaded custom models.
This commit is contained in:
@@ -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({
|
||||
<group>
|
||||
<mesh geometry={frontGeo}>
|
||||
<meshStandardMaterial
|
||||
color={faceMap ? '#ffffff' : '#52525b'}
|
||||
color={tintedColor(faceMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
|
||||
map={faceMap ?? undefined}
|
||||
roughness={0.6}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh geometry={backGeo}>
|
||||
<meshStandardMaterial
|
||||
color={backMap ? '#ffffff' : '#52525b'}
|
||||
color={tintedColor(backMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
|
||||
map={backMap ?? undefined}
|
||||
roughness={0.6}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh geometry={wallsGeo}>
|
||||
<meshStandardMaterial color="#ffffff" roughness={0.6} />
|
||||
<meshStandardMaterial color={tintedColor(new THREE.Color('#ffffff'), tint)} roughness={0.6} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<mesh>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color="#52525b" />
|
||||
<meshStandardMaterial color={tintedColor(new THREE.Color('#52525b'), tint)} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Model meshUrl={meshUrl} diffuseUrl={object.CustomMesh?.DiffuseURL} />
|
||||
<Model
|
||||
meshUrl={meshUrl}
|
||||
diffuseUrl={object.CustomMesh?.DiffuseURL}
|
||||
tint={tint}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<>
|
||||
<primitive object={root} scale={0.5} />
|
||||
{diffuseUrl && <DiffuseTexture root={root} url={diffuseUrl} />}
|
||||
<Tint root={root} tint={tint} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 }) {
|
||||
|
||||
@@ -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 <TileMesh url={url} thickness={thickness} type={type} stretch={stretch} />;
|
||||
return (
|
||||
<TileMesh
|
||||
url={url}
|
||||
thickness={thickness}
|
||||
type={type}
|
||||
stretch={stretch}
|
||||
tint={objectTint(object)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 <TokenMesh url={url} thickness={thickness} />;
|
||||
return <TokenMesh url={url} thickness={thickness} tint={objectTint(object)} />;
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user