Spaces:
Running
Running
Download src/lib/models.ts from Xenova/kinetic-depth-effect: direct link, hf CLI and curl.
- Browser
- Download file 29.2 kB
-
https://huggingface.co/spaces/Xenova/kinetic-depth-effect/resolve/main/src/lib/models.ts
- Command line
-
hf download hf://spaces/Xenova/kinetic-depth-effect/src/lib/models.ts
-
curl -L -o models.ts https://huggingface.co/spaces/Xenova/kinetic-depth-effect/resolve/main/src/lib/models.ts
29.2 kB
| 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<Texture, PixelMap | null>, | |
| 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<string, number[]>(); | |
| 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<string, Model>(); | |
| class HelixCurve extends Curve<Vector3> { | |
| 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<BufferGeometry>(), | |
| materials = new Set<Material>(), | |
| textures = new Set<Texture>(); | |
| 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>([file]), | |
| urls = new Map<File, string>(), | |
| embeddedUrls = new Set<string>(); | |
| 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<Texture<ImageBitmap>> { | |
| override load( | |
| url: string, | |
| onLoad?: (texture: Texture<ImageBitmap>) => void, | |
| onProgress?: (event: ProgressEvent) => void, | |
| onError?: (error: unknown) => void, | |
| ) { | |
| const texture = new Texture<ImageBitmap>(); | |
| 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<ImportedAsset> { | |
| 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<void>((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<Texture, PixelMap | null>(); | |
| 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<void>((resolve) => setTimeout(resolve, 0)); | |
| if (disposed) throw Error("This model is no longer active."); | |
| return buildModel(scene, name, pixels); | |
| }, | |
| }; | |
| } | |