diff --git a/apps/web/src/components/viewers/CustomModelViewer.tsx b/apps/web/src/components/viewers/CustomModelViewer.tsx index be35812..e6b44e4 100644 --- a/apps/web/src/components/viewers/CustomModelViewer.tsx +++ b/apps/web/src/components/viewers/CustomModelViewer.tsx @@ -1,16 +1,18 @@ import { Suspense, useLayoutEffect } from 'react'; import { useLoader } from '@react-three/fiber'; -import { useFBX, useGLTF, useTexture } from '@react-three/drei'; -import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'; +import { useTexture } from '@react-three/drei'; import type { TTSObject } from '@tts/shared'; import type * as THREE from 'three'; +import type { Object3D } from 'three'; import Scene from './Scene'; import { assetUrl } from './assetUrl'; +import { FlexibleModelLoader } from './flexibleModelLoader'; /** * A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ, - * and FBX by sniffing the URL extension; falls back to GLTF for unknown - * extensions. `DiffuseURL` is applied to the model's materials when present. + * and FBX, detecting the format from the file contents rather than the URL + * (TTS model URLs are often extension-less). `DiffuseURL` is applied to the + * model's materials when present. */ export default function CustomModelViewer({ object }: { object: TTSObject }) { const meshUrl = object.CustomMesh?.MeshURL; @@ -41,23 +43,23 @@ function Model({ meshUrl: string; diffuseUrl?: string; }) { - const ext = meshUrl.split('?')[0]!.split('.').pop()!.toLowerCase(); - const url = assetUrl(meshUrl); + const root = useLoader(FlexibleModelLoader, assetUrl(meshUrl)); - const diffuse = diffuseUrl ? useTexture(assetUrl(diffuseUrl)) : null; + return ( + <> + + {diffuseUrl && } + + ); +} - let root: THREE.Object3D; - if (ext === 'obj') { - root = useLoader(OBJLoader, url); - } else if (ext === 'fbx') { - root = useFBX(url); - } else { - root = useGLTF(url).scene; - } +// 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 }) { + const texture = useTexture(assetUrl(url)); // Apply the diffuse texture to every mesh material on the loaded model. useLayoutEffect(() => { - if (!diffuse) return; root.traverse((child) => { const mesh = child as THREE.Mesh; if (mesh.isMesh) { @@ -65,12 +67,12 @@ function Model({ ? mesh.material[0] : mesh.material; if (material && 'map' in material) { - material.map = diffuse; + material.map = texture; material.needsUpdate = true; } } }); - }, [root, diffuse]); + }, [root, texture]); - return ; + return null; } \ No newline at end of file diff --git a/apps/web/src/components/viewers/flexibleModelLoader.ts b/apps/web/src/components/viewers/flexibleModelLoader.ts new file mode 100644 index 0000000..4b6850d --- /dev/null +++ b/apps/web/src/components/viewers/flexibleModelLoader.ts @@ -0,0 +1,75 @@ +import { FileLoader, Loader, type LoadingManager, type Object3D } from 'three'; +import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'; + +const GLB_MAGIC = [0x67, 0x6c, 0x54, 0x46]; // "glTF" +const FBX_MAGIC = 'Kaydara FBX Binary'; +const OBJ_LINE = /^(v|vn|vt|f|o|g|s|usemtl|mtllib)\b/m; + +/** + * Loads a model regardless of format by sniffing the file contents rather than + * relying on the URL extension. TTS `CustomMesh.MeshURL` values often point at + * extension-less Steam URLs, so the format must be detected from the bytes. + * Resolves to a `THREE.Object3D` (a `Group` for OBJ/FBX, the scene for GLTF). + */ +export class FlexibleModelLoader extends Loader { + constructor(manager?: LoadingManager) { + super(manager); + } + + override load( + url: string, + onLoad: (object: Object3D) => void, + onProgress?: (event: ProgressEvent) => void, + onError?: (event: unknown) => void, + ) { + const file = new FileLoader(this.manager); + file.setPath(this.path); + file.setResponseType('arraybuffer'); + file.setRequestHeader(this.requestHeader); + file.setWithCredentials(this.withCredentials); + file.load( + url, + (data) => this.parse(data as ArrayBuffer, onLoad, onError), + onProgress, + onError, + ); + } + + parse( + data: ArrayBuffer, + onLoad: (object: Object3D) => void, + onError?: (event: unknown) => void, + ) { + const bytes = new Uint8Array(data); + const head = new TextDecoder().decode(bytes.slice(0, 1024)); + + try { + if (isGlb(bytes)) { + new GLTFLoader().parse(data, '', (gltf) => onLoad(gltf.scene), onError); + } else if (head.startsWith(FBX_MAGIC)) { + onLoad(new FBXLoader().parse(data, '')); + } else if (head.trimStart().startsWith('{')) { + new GLTFLoader().parse(data, '', (gltf) => onLoad(gltf.scene), onError); + } else if (OBJ_LINE.test(head)) { + onLoad(new OBJLoader().parse(new TextDecoder().decode(data))); + } else { + // Best-effort fallback for anything unrecognized. + new GLTFLoader().parse(data, '', (gltf) => onLoad(gltf.scene), onError); + } + } catch (err) { + onError?.(err); + } + } +} + +function isGlb(bytes: Uint8Array): boolean { + return ( + bytes.length >= 4 && + bytes[0] === GLB_MAGIC[0] && + bytes[1] === GLB_MAGIC[1] && + bytes[2] === GLB_MAGIC[2] && + bytes[3] === GLB_MAGIC[3] + ); +} \ No newline at end of file