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