Xenova's picture
Xenova HF Staff
Upload 63 files
f323fc1 verified
Raw History Blame Contribute Delete
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);
},
};
}