import { AnimationMixer, Box3, BoxGeometry, BufferGeometry, Color, ConeGeometry, Curve, ExtrudeGeometry, Float32BufferAttribute, Group, LoadingManager, Matrix4, Mesh, MeshBasicMaterial, OctahedronGeometry, Shape, SphereGeometry, TorusGeometry, TorusKnotGeometry, TubeGeometry, Vector2, Vector3, type AnimationClip, type Object3D, type Material, type SkinnedMesh, type InstancedMesh, } from "three"; import type { DRACOLoader } from "three/addons/loaders/DRACOLoader.js"; import { ImageBitmapLoader, Texture, Loader } from "three"; import type { ImportedAsset, Model } from "./types"; let nextId = 1; const MAX_TRIANGLES = 300000, MAX_POINTS = 100000; type PixelMap = { data: Uint8ClampedArray; width: number; height: number }; type SourceTriangle = { mesh: Mesh; indices: [number, number, number]; material: Material; matrix: Matrix4; }; const vA = new Vector3(), vB = new Vector3(), vC = new Vector3(); function seededRandom(seed = 2026) { return () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; }; } /** Area-weighted sampling in normalized world space, with a stable sequence as density changes. */ export function sampleSurface( triangles: Float32Array, count = MAX_POINTS, seed = 2026, ) { const n = triangles.length / 18, cdf = new Float64Array(n); let sum = 0; for (let i = 0; i < n; i++) { const j = i * 18; vA.fromArray(triangles, j); vB.fromArray(triangles, j + 6); vC.fromArray(triangles, j + 12); sum += vB.sub(vA).cross(vC.sub(vA)).length() * 0.5; cdf[i] = sum; } if (!Number.isFinite(sum) || sum <= 1e-12) throw Error("This model has no usable surface area."); const result = new Float32Array(count * 6), random = seededRandom(seed); for (let k = 0; k < count; k++) { const target = random() * sum; let low = 0, high = n - 1; while (low < high) { const mid = (low + high) >>> 1; if (cdf[mid] > target) high = mid; else low = mid + 1; } const root = Math.sqrt(random()), u = 1 - root, v = root * (1 - random()), w = 1 - u - v, j = low * 18; for (let d = 0; d < 6; d++) result[k * 6 + d] = triangles[j + d] * u + triangles[j + 6 + d] * v + triangles[j + 12 + d] * w; } return result; } function collect(root: Object3D) { root.updateMatrixWorld(true); const list: SourceTriangle[] = []; root.traverseVisible((object) => { if (!(object as Mesh).isMesh) return; const mesh = object as Mesh; if ((mesh as SkinnedMesh).isSkinnedMesh) (mesh as SkinnedMesh).skeleton.update(); const geometry = mesh.geometry, position = geometry.getAttribute("position"); if (!position) return; const index = geometry.index, materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; const total = index ? index.count : position.count; const groups = Array.isArray(mesh.material) && geometry.groups.length ? geometry.groups : [{ start: 0, count: total, materialIndex: 0 }]; const instances = (mesh as InstancedMesh).isInstancedMesh ? (mesh as InstancedMesh).count : 1; const drawStart = geometry.drawRange.start, drawEnd = Math.min(total, drawStart + geometry.drawRange.count); if ((instances * total) / 3 > MAX_TRIANGLES) throw Error( "This model is too detailed. Use a model with fewer than 300,000 triangles.", ); for (let instance = 0; instance < instances; instance++) { const matrix = mesh.matrixWorld.clone(); if ((mesh as InstancedMesh).isInstancedMesh) { const m = new Matrix4(); (mesh as InstancedMesh).getMatrixAt(instance, m); matrix.multiply(m); } for (const group of groups) { const material = materials[group.materialIndex ?? 0]; if (!material || !material.visible) continue; for ( let i = Math.max(group.start, drawStart); i + 2 < Math.min(group.start + group.count, drawEnd); i += 3 ) { if (list.length >= MAX_TRIANGLES) throw Error( "This model is too detailed. Use a model with fewer than 300,000 triangles.", ); list.push({ mesh, indices: [ index ? index.getX(i) : i, index ? index.getX(i + 1) : i + 1, index ? index.getX(i + 2) : i + 2, ], material, matrix, }); } } } }); if (!list.length) throw Error( "No triangle meshes were found. Import a surface mesh rather than a point cloud.", ); return list; } function readTexture(texture: Texture): PixelMap | null { const image = texture.image as CanvasImageSource & { width: number; height: number; }; if (!image?.width || !image?.height) return null; // One bounded CPU read per source texture; sampling never requires a graphics renderer. const scale = Math.min(1, 2048 / Math.max(image.width, image.height)); const width = Math.max(1, Math.round(image.width * scale)), height = Math.max(1, Math.round(image.height * scale)); const canvas = typeof document === "undefined" ? new OffscreenCanvas(width, height) : Object.assign(document.createElement("canvas"), { width, height }); const ctx = canvas.getContext("2d", { willReadFrequently: true }) as | CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null; if (!ctx) return null; try { ctx.drawImage(image, 0, 0, canvas.width, canvas.height); return { data: ctx.getImageData(0, 0, canvas.width, canvas.height).data, width: canvas.width, height: canvas.height, }; } catch { return null; } } function srgbToLinear(x: number) { return x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); } function linearToSrgb(x: number) { return x <= 0.0031308 ? x * 12.92 : 1.055 * Math.pow(Math.max(0, x), 1 / 2.4) - 0.055; } function texel(texture: Texture, pixels: PixelMap, u: number, v: number) { const uv = new Vector2(u, v); texture.transformUv(uv); const x = Math.max( 0, Math.min(pixels.width - 1, Math.floor(uv.x * pixels.width)), ), y = Math.max( 0, Math.min(pixels.height - 1, Math.floor(uv.y * pixels.height)), ), j = (y * pixels.width + x) * 4; return [ srgbToLinear(pixels.data[j] / 255), srgbToLinear(pixels.data[j + 1] / 255), srgbToLinear(pixels.data[j + 2] / 255), ]; } function buildModel( root: Object3D, name: string, texturePixels?: Map, pointCount = MAX_POINTS, ): Model { const sources = collect(root), triangles = new Float32Array(sources.length * 18), uvs = new Float32Array(sources.length * 6); const textures: (Texture | null)[] = new Array(sources.length); const box = new Box3(); for (let i = 0; i < sources.length; i++) { const { mesh, indices, material, matrix } = sources[i]; const color = (material as MeshBasicMaterial).color ?? new Color(1, 1, 1); const colors = mesh.geometry.getAttribute("color"), map = (material as MeshBasicMaterial).map ?? null; const uv = mesh.geometry.getAttribute(map?.channel === 1 ? "uv1" : "uv"); textures[i] = uv ? map : null; for (let k = 0; k < 3; k++) { const index = indices[k], j = i * 18 + k * 6, p = mesh.getVertexPosition(index, new Vector3()).applyMatrix4(matrix); if (![p.x, p.y, p.z].every(Number.isFinite)) throw Error("This model contains invalid vertex coordinates."); box.expandByPoint(p); triangles.set([p.x, p.y, p.z], j); triangles[j + 3] = color.r * (colors ? colors.getX(index) : 1); triangles[j + 4] = color.g * (colors ? colors.getY(index) : 1); triangles[j + 5] = color.b * (colors ? colors.getZ(index) : 1); if (uv) { uvs[i * 6 + k * 2] = uv.getX(index); uvs[i * 6 + k * 2 + 1] = uv.getY(index); } } } const center = box.getCenter(new Vector3()); let radius = 0; for (let i = 0; i < triangles.length; i += 6) { triangles[i] -= center.x; triangles[i + 1] -= center.y; triangles[i + 2] -= center.z; radius = Math.max( radius, Math.hypot(triangles[i], triangles[i + 1], triangles[i + 2]), ); } if (radius <= 1e-10) throw Error("The model is too small or has no usable geometry."); const rotationBounds: Model["rotationBounds"] = { axial: [0, 0, 0], radial: [0, 0, 0], }; for (let i = 0; i < triangles.length; i += 6) { triangles[i] /= radius; triangles[i + 1] /= radius; triangles[i + 2] /= radius; const x = triangles[i], y = triangles[i + 1], z = triangles[i + 2]; const { axial, radial } = rotationBounds; axial[0] = Math.max(axial[0], Math.abs(x)); axial[1] = Math.max(axial[1], Math.abs(y)); axial[2] = Math.max(axial[2], Math.abs(z)); radial[0] = Math.max(radial[0], Math.hypot(y, z)); radial[1] = Math.max(radial[1], Math.hypot(x, z)); radial[2] = Math.max(radial[2], Math.hypot(x, y)); } const particles = sampleSurface(triangles, pointCount); // Reproduce the sample sequence to interpolate UVs exactly at particle positions. if (texturePixels && textures.some(Boolean)) { const cdf = new Float64Array(sources.length); let sum = 0; for (let i = 0; i < sources.length; i++) { const j = i * 18; vA.fromArray(triangles, j); vB.fromArray(triangles, j + 6); vC.fromArray(triangles, j + 12); sum += vB.sub(vA).cross(vC.sub(vA)).length() * 0.5; cdf[i] = sum; } const random = seededRandom(); for (let k = 0; k < pointCount; k++) { const target = random() * sum; let low = 0, high = sources.length - 1; while (low < high) { const mid = (low + high) >>> 1; if (cdf[mid] > target) high = mid; else low = mid + 1; } const root = Math.sqrt(random()), a = 1 - root, b = root * (1 - random()), c = 1 - a - b; const texture = textures[low], pixels = texture ? texturePixels.get(texture) : null; if (texture && pixels) { const j = low * 6; const rgb = texel( texture, pixels, uvs[j] * a + uvs[j + 2] * b + uvs[j + 4] * c, uvs[j + 1] * a + uvs[j + 3] * b + uvs[j + 5] * c, ); for (let d = 0; d < 3; d++) particles[k * 6 + 3 + d] *= rgb[d]; } } } for (let i = 0; i < particles.length; i += 6) for (let d = 3; d < 6; d++) particles[i + d] = linearToSrgb(particles[i + d]); for (let i = 0; i < triangles.length; i += 6) for (let d = 3; d < 6; d++) triangles[i + d] = linearToSrgb(triangles[i + d]); // Deduplicate edges. Wire mode exposes all edges, including the rear surface. const edges = new Map(); for (let i = 0; i < triangles.length; i += 18) for (const [a, b] of [ [0, 6], [6, 12], [12, 0], ]) { const va = Array.from(triangles.subarray(i + a, i + a + 6)), vb = Array.from(triangles.subarray(i + b, i + b + 6)); const ka = va .slice(0, 3) .map((x) => Math.round(x * 1e5)) .join(","), kb = vb .slice(0, 3) .map((x) => Math.round(x * 1e5)) .join(","); const key = ka < kb ? ka + "/" + kb : kb + "/" + ka; if (!edges.has(key)) edges.set(key, [...va, ...vb]); } const wires = new Float32Array(edges.size * 12); let offset = 0; for (const edge of edges.values()) { wires.set(edge, offset); offset += 12; } return { name, triangles, wires, particles, id: nextId++, rotationDirection: 1, rotationBounds, }; } function arrowGeometry() { const data: number[] = []; const segments = 100, end = 5.45; const point = (t: number, side: number, radial: number) => { const u = t / end; const half = u < 0.78 ? 0.115 : (0.38 * (1 - u)) / 0.22; return [ (1 + radial * 0.022) * Math.cos(t), side * half, (1 + radial * 0.022) * Math.sin(t), ]; }; const quad = (a: number[], b: number[], c: number[], d: number[]) => data.push(...a, ...b, ...c, ...a, ...c, ...d); for (let i = 0; i < segments; i++) { const a = (end * i) / segments, b = (end * (i + 1)) / segments; quad(point(a, -1, 1), point(b, -1, 1), point(b, 1, 1), point(a, 1, 1)); quad(point(a, 1, -1), point(b, 1, -1), point(b, -1, -1), point(a, -1, -1)); quad(point(a, 1, 1), point(b, 1, 1), point(b, 1, -1), point(a, 1, -1)); quad(point(a, -1, -1), point(b, -1, -1), point(b, -1, 1), point(a, -1, 1)); } quad(point(0, 1, 1), point(0, 1, -1), point(0, -1, -1), point(0, -1, 1)); const geometry = new BufferGeometry(); geometry.setAttribute("position", new Float32BufferAttribute(data, 3)); return geometry; } const cache = new Map(); class HelixCurve extends Curve { constructor() { super(); } getPoint(t: number, target = new Vector3()) { const angle = t * Math.PI * 4.5; return target.set( 0.62 * Math.cos(angle), (t - 0.5) * 1.6, 0.62 * Math.sin(angle), ); } } function starGeometry() { const shape = new Shape(); for (let i = 0; i < 10; i++) { const angle = Math.PI / 2 + (i * Math.PI) / 5, radius = i % 2 ? 0.38 : 0.85; const x = Math.cos(angle) * radius, y = Math.sin(angle) * radius; if (i === 0) shape.moveTo(x, y); else shape.lineTo(x, y); } shape.closePath(); return new ExtrudeGeometry(shape, { depth: 0.35, bevelEnabled: true, bevelThickness: 0.06, bevelSize: 0.06, bevelSegments: 3, steps: 1, }).center(); } export function createPreset(key: string, preview = false): Model { const cacheKey = `${key}:${preview ? "preview" : "scene"}`; const cached = cache.get(cacheKey); if (cached) return cached; const group = new Group(); const add = ( geometry: BufferGeometry, color: string, x = 0, y = 0, z = 0, sx = 1, sy = 1, sz = 1, ) => { const mesh = new Mesh(geometry, new MeshBasicMaterial({ color })); mesh.position.set(x, y, z); mesh.scale.set(sx, sy, sz); group.add(mesh); return mesh; }; let name = "Arrow"; if (key === "sphere") { name = "Sphere"; add(new SphereGeometry(1, 40, 24), "#a9e3ef"); } else if (key === "torus") { name = "Knot"; add(new TorusKnotGeometry(0.72, 0.22, 180, 16, 2, 3), "#d3b8ff"); } else if (key === "cube") { name = "Cube"; add(new BoxGeometry(1.5, 1.5, 1.5, 6, 6, 6), "#cfedab"); } else if (key === "arrow") { add(arrowGeometry(), "#bdd9ff"); } else if (key === "ring") { name = "Ring"; add(new TorusGeometry(0.7, 0.24, 20, 64), "#a9e3ef"); } else if (key === "helix") { name = "Helix"; add(new TubeGeometry(new HelixCurve(), 160, 0.11, 12, false), "#d3b8ff"); } else if (key === "star") { name = "Star"; add(starGeometry(), "#f2cc9a"); } else if (key === "diamond") { name = "Diamond"; add(new OctahedronGeometry(1), "#a9e3ef"); } else if (key === "cone") { name = "Cone"; add(new ConeGeometry(0.72, 1.7, 48), "#efa3c5").rotation.z = 0.25; } else if (key === "pyramid") { name = "Pyramid"; add(new ConeGeometry(1, 1.6, 4), "#cfedab").rotation.y = Math.PI / 4; } else throw Error("Unknown built-in model."); const model = buildModel(group, name, undefined, preview ? 4200 : MAX_POINTS); if (key === "arrow") model.rotationDirection = -1; cache.set(cacheKey, model); group.traverse((o) => { const m = o as Mesh; if (m.isMesh) { m.geometry.dispose(); (m.material as Material).dispose(); } }); return model; } const MODEL_EXTENSIONS = ["glb", "gltf", "obj", "stl", "ply", "fbx"]; export const MODEL_FORMATS = "GLB, glTF, OBJ, STL, PLY, FBX"; export const IMPORT_ACCEPT = ".glb,.gltf,.obj,.stl,.ply,.fbx,.bin,.mtl,.png,.jpg,.jpeg,.webp"; const modelExtension = (name: string) => name.split(".").pop()?.toLowerCase() ?? ""; export const isModelFile = (file: File) => MODEL_EXTENSIONS.includes(modelExtension(file.name)); function disposeRoot(root: Object3D) { const geometries = new Set(), materials = new Set(), textures = new Set(); root.traverse((o) => { const mesh = o as Mesh; if (!mesh.isMesh) return; geometries.add(mesh.geometry); for (const m of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) materials.add(m); }); for (const m of materials) for (const value of Object.values(m)) { if ((value as Texture)?.isTexture) textures.add(value as Texture); } for (const t of textures) { const image = t.image as { close?: () => void } | undefined; image?.close?.(); t.dispose(); } for (const m of materials) m.dispose(); for (const g of geometries) g.dispose(); } /** Resolve only files the user selected. Models never fetch arbitrary URLs. */ function localResources(file: File, companions: File[]) { const used = new Set([file]), urls = new Map(), embeddedUrls = new Set(); const normalize = (name: string) => { let decoded = name; try { decoded = decodeURIComponent(name); } catch {} return decoded.replace(/\\/g, "/").replace(/^\.\//, ""); }; const find = (uri: string, optional = false) => { if (/^(?:[a-z][a-z\d+.-]*:|\/)/i.test(uri)) throw Error( "This model references a remote or absolute URL. Include local companion files instead.", ); const name = normalize(uri), base = name.split("/").pop(); const exact = companions.filter( (f) => normalize(f.webkitRelativePath || f.name) === name, ); const matches = exact.length ? exact : companions.filter((f) => normalize(f.name).split("/").pop() === base); if (matches.length > 1) throw Error( `More than one file matches ${base}. Import this model with only its own companion files.`, ); if (!matches.length) { if (optional) return undefined; throw Error( `Missing ${base}. Select it together with ${file.name}, or drop all the model's files together.`, ); } used.add(matches[0]); if ([...used].reduce((sum, f) => sum + f.size, 0) > 50 * 1024 * 1024) throw Error("This model and its companion files exceed 50 MB."); return matches[0]; }; return { used, find, resolve: (uri: string) => { if (uri.startsWith("data:")) return uri; if (uri.startsWith("blob:")) { embeddedUrls.add(uri); return uri; } const match = find(uri)!; let url = urls.get(match); if (!url) { url = URL.createObjectURL(match); urls.set(match, url); } return url; }, dispose: () => { for (const url of urls.values()) URL.revokeObjectURL(url); for (const url of embeddedUrls) URL.revokeObjectURL(url); }, }; } // OBJ and FBX loaders normally rely on HTML images. Decode their textures without // the DOM in workers; UV flipping remains in our CPU sampler, not the bitmap. class BitmapTextureLoader extends Loader> { override load( url: string, onLoad?: (texture: Texture) => void, onProgress?: (event: ProgressEvent) => void, onError?: (error: unknown) => void, ) { const texture = new Texture(); new ImageBitmapLoader(this.manager) .setOptions({ imageOrientation: "none", premultiplyAlpha: "none" }) .load( url, (image) => { texture.image = image; texture.needsUpdate = true; onLoad?.(texture); }, onProgress, onError, ); return texture; } } export async function loadModel( file: File, companions: File[] = [], ): Promise { const extension = modelExtension(file.name); if (!isModelFile(file)) throw Error(`Choose a ${MODEL_FORMATS} model.`); if (file.size > 50 * 1024 * 1024) throw Error("This model is larger than 50 MB. Choose a smaller file."); const buffer = await file.arrayBuffer(), resources = localResources(file, companions); const manager = new LoadingManager(); manager.setURLModifier(resources.resolve); if ( typeof document === "undefined" && typeof createImageBitmap !== "undefined" ) manager.addHandler(/.*/, new BitmapTextureLoader(manager)); let settleLoads!: () => void; const loaded = new Promise((resolve) => (settleLoads = resolve)); let failedResource = false; manager.onLoad = settleLoads; manager.onError = () => { failedResource = true; }; manager.itemStart("local-model-import"); let root: Object3D | undefined, clips: AnimationClip[] = [], draco: DRACOLoader | undefined; const warnings: string[] = []; try { if (extension === "glb" || extension === "gltf") { let json; if (extension === "glb") { const view = new DataView(buffer); if (buffer.byteLength < 20 || view.getUint32(0, true) !== 0x46546c67) throw Error("This file is not a valid GLB model."); if (view.getUint32(4, true) !== 2) throw Error("Only glTF 2.0 models are supported."); const length = view.getUint32(12, true); if ( view.getUint32(16, true) !== 0x4e4f534a || 20 + length > buffer.byteLength ) throw Error("The GLB file has an invalid header."); json = JSON.parse( new TextDecoder() .decode(buffer.slice(20, 20 + length)) .replace(/\0+$/, ""), ); } else json = JSON.parse(new TextDecoder().decode(buffer)); if (json.asset?.version !== "2.0") throw Error("Only glTF 2.0 models are supported."); // Check dependencies before loaders start so missing files cannot become silent gaps. for (const entry of [...(json.buffers ?? []), ...(json.images ?? [])]) if (entry.uri && !entry.uri.startsWith("data:")) resources.find(entry.uri); const [{ GLTFLoader }, { DRACOLoader }, { MeshoptDecoder }] = await Promise.all([ import("three/addons/loaders/GLTFLoader.js"), import("three/addons/loaders/DRACOLoader.js"), import("three/addons/libs/meshopt_decoder.module.js"), ]); // Vite bundles Three's decoders. Keep their URLs separate from user files. draco = new DRACOLoader(); const loader = new GLTFLoader(manager) .setDRACOLoader(draco) .setMeshoptDecoder(MeshoptDecoder); const gltf = await loader.parseAsync( extension === "glb" ? buffer : JSON.stringify(json), "", ); root = gltf.scene; clips = gltf.animations; } else if (extension === "fbx") { const { FBXLoader } = await import("three/addons/loaders/FBXLoader.js"); // FBXLoader uses window only for embedded-image URLs and camera dimensions. // Supply those during its synchronous parse when running in a worker. const scope = globalThis as unknown as { window?: { URL: typeof URL; innerWidth: number; innerHeight: number }; }; const needsWindow = typeof window === "undefined"; if (needsWindow) scope.window = { URL, innerWidth: 1, innerHeight: 1 }; try { root = new FBXLoader(manager).parse(buffer, ""); } finally { if (needsWindow) delete scope.window; } clips = root.animations; } else if (extension === "obj") { const { OBJLoader } = await import("three/addons/loaders/OBJLoader.js"); const text = new TextDecoder().decode(buffer), loader = new OBJLoader(manager); const libraries = [...text.matchAll(/^\s*mtllib\s+(.+)$/gm)].map((m) => m[1].trim(), ); const materialFiles = libraries .map((name) => resources.find(name, true)) .filter((f): f is File => !!f); if (materialFiles.length) { const { MTLLoader } = await import("three/addons/loaders/MTLLoader.js"); const definitions = []; for (const materialFile of materialFiles) definitions.push( new MTLLoader(manager).parse(await materialFile.text(), ""), ); const materials = definitions[0]; materials.setMaterials( Object.assign({}, ...definitions.map((m) => m.materialsInfo)), ); loader.setMaterials(materials); } if (materialFiles.length < libraries.length) warnings.push( "Some OBJ materials were not included; geometry and available colors were imported.", ); root = loader.parse(text); } else { let geometry: BufferGeometry; if (extension === "stl") { const ascii = /^\s*solid(?:\s|$)/i.test( new TextDecoder().decode(buffer.slice(0, 256)), ); const faces = buffer.byteLength >= 84 ? new DataView(buffer).getUint32(80, true) : 0; if (!ascii || 84 + faces * 50 === buffer.byteLength) { if (faces > MAX_TRIANGLES) throw Error("Use an STL with fewer than 300,000 triangles."); if (buffer.byteLength < 84 || 84 + faces * 50 !== buffer.byteLength) throw Error("The binary STL file has an invalid length."); } const { STLLoader } = await import("three/addons/loaders/STLLoader.js"); geometry = new STLLoader().parse(buffer); } else { const { PLYLoader } = await import("three/addons/loaders/PLYLoader.js"); geometry = new PLYLoader().parse(buffer); } root = new Mesh( geometry, new MeshBasicMaterial({ color: 0xffffff, vertexColors: geometry.hasAttribute("color"), }), ); } manager.itemEnd("local-model-import"); await loaded; if (failedResource) throw Error( "A texture or buffer could not be decoded. Check that all companion files are included.", ); if ( [...resources.used].reduce((sum, f) => sum + f.size, 0) > 50 * 1024 * 1024 ) throw Error("This model and its companion files exceed 50 MB."); } catch (e) { if (root) disposeRoot(root); const message = e instanceof Error ? e.message : String(e); if (/ktx|basis/i.test(message)) throw Error( "This model uses KTX2 textures. Re-export with PNG or JPEG textures.", ); throw Error(`Could not import ${file.name}. ${message}`); } finally { draco?.dispose(); resources.dispose(); } if (!root) throw Error("No model was found in this file."); const scene = root, mixer = new AnimationMixer(scene), pixels = new Map(); const rest: { object: Object3D; position: Vector3; quaternion: Object3D["quaternion"]; scale: Vector3; morph?: number[]; }[] = []; let missingTextures = 0, disposed = false; scene.traverse((o) => { rest.push({ object: o, position: o.position.clone(), quaternion: o.quaternion.clone(), scale: o.scale.clone(), morph: (o as Mesh).morphTargetInfluences?.slice(), }); const mesh = o as Mesh; if (!mesh.isMesh) return; for (const m of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) { const texture = (m as MeshBasicMaterial).map; if (texture && !pixels.has(texture)) { texture.updateMatrix(); const p = readTexture(texture); pixels.set(texture, p); if (!p) missingTextures++; } } }); if (missingTextures) warnings.push( "Some texture colors could not be read; their material colors are used instead.", ); const name = file.name.replace(/\.[^.]+$/, ""); return { name, sourceFiles: [...resources.used], clips: clips.map((c, i) => ({ name: c.name || `Pose ${i + 1}`, duration: c.duration, })), warning: warnings.join(" ") || undefined, dispose() { if (disposed) return; disposed = true; mixer.stopAllAction(); mixer.uncacheRoot(scene); pixels.clear(); disposeRoot(scene); }, async build(index, time) { if (disposed) throw Error("This model is no longer active."); mixer.stopAllAction(); for (const r of rest) { r.object.position.copy(r.position); r.object.quaternion.copy(r.quaternion); r.object.scale.copy(r.scale); if (r.morph) (r.object as Mesh).morphTargetInfluences = r.morph.slice(); } if (index >= 0 && clips[index]) { mixer.clipAction(clips[index]).play(); mixer.setTime( Math.min(time, Math.max(0, clips[index].duration - 1e-6)), ); } await new Promise((resolve) => setTimeout(resolve, 0)); if (disposed) throw Error("This model is no longer active."); return buildModel(scene, name, pixels); }, }; }