2026-05-19 13:26:13 +02:00
|
|
|
import {
|
|
|
|
|
Box3,
|
|
|
|
|
Cache,
|
|
|
|
|
Group,
|
|
|
|
|
Mesh,
|
|
|
|
|
type AnimationClip,
|
|
|
|
|
type Material,
|
|
|
|
|
type Object3D,
|
|
|
|
|
type Texture
|
|
|
|
|
} from "three";
|
2026-04-02 20:28:07 +02:00
|
|
|
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
|
2026-05-19 13:26:13 +02:00
|
|
|
import {
|
|
|
|
|
GLTFLoader,
|
|
|
|
|
type GLTF
|
|
|
|
|
} from "three/examples/jsm/loaders/GLTFLoader.js";
|
2026-05-19 14:37:20 +02:00
|
|
|
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
|
2026-03-31 18:44:18 +02:00
|
|
|
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
|
|
|
|
|
|
|
|
|
import { createModelInstance, type ModelInstance } from "./model-instances";
|
|
|
|
|
import {
|
|
|
|
|
createProjectAssetStorageKey,
|
|
|
|
|
type ModelAssetMetadata,
|
|
|
|
|
type ModelAssetRecord
|
|
|
|
|
} from "./project-assets";
|
2026-05-19 13:24:46 +02:00
|
|
|
import {
|
|
|
|
|
ensureSharedKtx2LoaderInitialized,
|
|
|
|
|
getSharedKtx2LoaderIfInitialized
|
|
|
|
|
} from "./ktx2-texture-support";
|
2026-03-31 18:44:18 +02:00
|
|
|
import { createOpaqueId } from "../core/ids";
|
|
|
|
|
import type {
|
|
|
|
|
ProjectAssetStorage,
|
|
|
|
|
ProjectAssetStorageFileRecord,
|
|
|
|
|
ProjectAssetStoragePackageRecord
|
|
|
|
|
} from "./project-asset-storage";
|
|
|
|
|
|
|
|
|
|
interface ImportedModelFileEntry {
|
|
|
|
|
bytes: ArrayBuffer;
|
|
|
|
|
mimeType: string;
|
|
|
|
|
path: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ImportedModelFileSet {
|
|
|
|
|
fileEntries: ImportedModelFileEntry[];
|
|
|
|
|
packageRecord: ProjectAssetStoragePackageRecord;
|
|
|
|
|
rootFile: ImportedModelFileEntry;
|
|
|
|
|
totalByteLength: number;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:28:07 +02:00
|
|
|
const DRACO_DECODER_PATH = "/draco/gltf/";
|
2026-05-19 13:24:46 +02:00
|
|
|
const GLTF_KTX2_TEXTURE_EXTENSION = "KHR_texture_basisu";
|
|
|
|
|
const GLB_MAGIC = 0x46546c67;
|
|
|
|
|
const GLB_JSON_CHUNK_TYPE = 0x4e4f534a;
|
2026-04-02 20:28:07 +02:00
|
|
|
|
|
|
|
|
let sharedDracoLoader: DRACOLoader | null = null;
|
|
|
|
|
|
2026-05-19 13:24:46 +02:00
|
|
|
type GltfKtx2Mode = "if-initialized" | "required";
|
|
|
|
|
|
2026-03-31 18:44:18 +02:00
|
|
|
export interface LoadedModelAsset {
|
|
|
|
|
assetId: string;
|
|
|
|
|
storageKey: string;
|
|
|
|
|
metadata: ModelAssetMetadata;
|
|
|
|
|
template: Group;
|
2026-03-31 23:57:05 +02:00
|
|
|
animations: AnimationClip[];
|
2026-03-31 18:44:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ImportedModelAssetResult {
|
|
|
|
|
asset: ModelAssetRecord;
|
|
|
|
|
modelInstance: ModelInstance;
|
|
|
|
|
loadedAsset: LoadedModelAsset;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:46:34 +02:00
|
|
|
function getErrorDetail(error: unknown): string {
|
|
|
|
|
if (error instanceof Error && error.message.trim().length > 0) {
|
|
|
|
|
return error.message.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "Unknown error.";
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:44:18 +02:00
|
|
|
function getFileExtension(sourceName: string): string {
|
|
|
|
|
const match = /\.([^.]+)$/u.exec(sourceName.trim());
|
|
|
|
|
return match === null ? "" : match[1].toLowerCase();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
function inferFileMimeType(
|
|
|
|
|
sourceName: string,
|
|
|
|
|
fallbackMimeType: string
|
|
|
|
|
): string {
|
|
|
|
|
if (
|
|
|
|
|
fallbackMimeType.trim().length > 0 &&
|
|
|
|
|
fallbackMimeType !== "application/octet-stream"
|
|
|
|
|
) {
|
2026-03-31 18:44:18 +02:00
|
|
|
return fallbackMimeType;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (getFileExtension(sourceName)) {
|
|
|
|
|
case "bin":
|
|
|
|
|
return "application/octet-stream";
|
|
|
|
|
case "png":
|
|
|
|
|
return "image/png";
|
|
|
|
|
case "jpg":
|
|
|
|
|
case "jpeg":
|
|
|
|
|
return "image/jpeg";
|
|
|
|
|
case "gif":
|
|
|
|
|
return "image/gif";
|
|
|
|
|
case "webp":
|
|
|
|
|
return "image/webp";
|
|
|
|
|
case "avif":
|
|
|
|
|
return "image/avif";
|
|
|
|
|
case "ktx2":
|
|
|
|
|
return "image/ktx2";
|
|
|
|
|
case "wav":
|
|
|
|
|
return "audio/wav";
|
|
|
|
|
case "mp3":
|
|
|
|
|
return "audio/mpeg";
|
|
|
|
|
case "ogg":
|
|
|
|
|
return "audio/ogg";
|
|
|
|
|
case "glb":
|
|
|
|
|
return "model/gltf-binary";
|
|
|
|
|
case "gltf":
|
|
|
|
|
return "model/gltf+json";
|
|
|
|
|
default:
|
2026-05-19 13:26:13 +02:00
|
|
|
return fallbackMimeType.trim().length > 0
|
|
|
|
|
? fallbackMimeType
|
|
|
|
|
: "application/octet-stream";
|
2026-03-31 18:44:18 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
function inferModelAssetFormat(
|
|
|
|
|
sourceName: string,
|
|
|
|
|
mimeType: string
|
|
|
|
|
): "glb" | "gltf" {
|
2026-03-31 18:44:18 +02:00
|
|
|
const extension = getFileExtension(sourceName);
|
|
|
|
|
|
|
|
|
|
if (mimeType === "model/gltf-binary" || extension === "glb") {
|
|
|
|
|
return "glb";
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
if (
|
|
|
|
|
mimeType === "model/gltf+json" ||
|
|
|
|
|
mimeType === "application/json" ||
|
|
|
|
|
extension === "gltf"
|
|
|
|
|
) {
|
2026-03-31 18:44:18 +02:00
|
|
|
return "gltf";
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
throw new Error(
|
|
|
|
|
`Unsupported model asset format for ${sourceName}. Use .glb or .gltf.`
|
|
|
|
|
);
|
2026-03-31 18:44:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function inferModelMimeType(format: "glb" | "gltf"): string {
|
|
|
|
|
return format === "glb" ? "model/gltf-binary" : "model/gltf+json";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stripUrlQueryAndHash(path: string): string {
|
|
|
|
|
const queryIndex = path.search(/[?#]/u);
|
|
|
|
|
return queryIndex === -1 ? path : path.slice(0, queryIndex);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeRelativePath(path: string): string {
|
|
|
|
|
const normalizedPath = stripUrlQueryAndHash(path.trim()).replace(/\\/gu, "/");
|
|
|
|
|
const segments = normalizedPath.split("/");
|
|
|
|
|
const resolvedSegments: string[] = [];
|
|
|
|
|
|
|
|
|
|
for (const segment of segments) {
|
|
|
|
|
if (segment === "" || segment === ".") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (segment === "..") {
|
|
|
|
|
const previousSegment = resolvedSegments.at(-1);
|
|
|
|
|
|
|
|
|
|
if (previousSegment !== undefined && previousSegment !== "..") {
|
|
|
|
|
resolvedSegments.pop();
|
|
|
|
|
} else {
|
|
|
|
|
resolvedSegments.push("..");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resolvedSegments.push(segment);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return resolvedSegments.join("/");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getPathDirectory(path: string): string {
|
|
|
|
|
const normalizedPath = normalizeRelativePath(path);
|
|
|
|
|
const lastSlashIndex = normalizedPath.lastIndexOf("/");
|
|
|
|
|
|
|
|
|
|
return lastSlashIndex === -1 ? "" : normalizedPath.slice(0, lastSlashIndex);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getRelativePath(fromDirectory: string, targetPath: string): string {
|
2026-05-19 13:26:13 +02:00
|
|
|
const normalizedFromSegments = normalizeRelativePath(fromDirectory)
|
|
|
|
|
.split("/")
|
|
|
|
|
.filter((segment) => segment.length > 0);
|
|
|
|
|
const normalizedTargetSegments = normalizeRelativePath(targetPath)
|
|
|
|
|
.split("/")
|
|
|
|
|
.filter((segment) => segment.length > 0);
|
2026-03-31 18:44:18 +02:00
|
|
|
|
|
|
|
|
while (
|
|
|
|
|
normalizedFromSegments.length > 0 &&
|
|
|
|
|
normalizedTargetSegments.length > 0 &&
|
|
|
|
|
normalizedFromSegments[0] === normalizedTargetSegments[0]
|
|
|
|
|
) {
|
|
|
|
|
normalizedFromSegments.shift();
|
|
|
|
|
normalizedTargetSegments.shift();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
return [
|
|
|
|
|
...new Array(normalizedFromSegments.length).fill(".."),
|
|
|
|
|
...normalizedTargetSegments
|
|
|
|
|
].join("/");
|
2026-03-31 18:44:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getImportedFilePath(file: File): string {
|
2026-05-19 13:26:13 +02:00
|
|
|
const relativePath =
|
|
|
|
|
typeof file.webkitRelativePath === "string"
|
|
|
|
|
? file.webkitRelativePath.trim()
|
|
|
|
|
: "";
|
|
|
|
|
return normalizeRelativePath(
|
|
|
|
|
relativePath.length > 0 ? relativePath : file.name.trim()
|
|
|
|
|
);
|
2026-03-31 18:44:18 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:24:46 +02:00
|
|
|
function stringArrayIncludes(value: unknown, item: string): boolean {
|
|
|
|
|
return Array.isArray(value) && value.some((entry) => entry === item);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function recordHasKtx2TextureExtension(value: unknown): boolean {
|
|
|
|
|
return (
|
|
|
|
|
value !== null &&
|
|
|
|
|
typeof value === "object" &&
|
|
|
|
|
GLTF_KTX2_TEXTURE_EXTENSION in value
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function gltfJsonUsesKtx2Textures(gltfJson: Record<string, unknown>): boolean {
|
|
|
|
|
if (
|
|
|
|
|
stringArrayIncludes(gltfJson.extensionsUsed, GLTF_KTX2_TEXTURE_EXTENSION) ||
|
2026-05-19 13:26:13 +02:00
|
|
|
stringArrayIncludes(
|
|
|
|
|
gltfJson.extensionsRequired,
|
|
|
|
|
GLTF_KTX2_TEXTURE_EXTENSION
|
|
|
|
|
)
|
2026-05-19 13:24:46 +02:00
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const textures = Array.isArray(gltfJson.textures) ? gltfJson.textures : [];
|
|
|
|
|
|
|
|
|
|
return textures.some((texture) => {
|
|
|
|
|
if (texture === null || typeof texture !== "object") {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
return recordHasKtx2TextureExtension(
|
|
|
|
|
(texture as Record<string, unknown>).extensions
|
|
|
|
|
);
|
2026-05-19 13:24:46 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
function parseGlbJsonChunk(
|
|
|
|
|
arrayBuffer: ArrayBuffer
|
|
|
|
|
): Record<string, unknown> | null {
|
2026-05-19 13:24:46 +02:00
|
|
|
if (arrayBuffer.byteLength < 20) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dataView = new DataView(arrayBuffer);
|
|
|
|
|
|
|
|
|
|
if (dataView.getUint32(0, true) !== GLB_MAGIC) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const version = dataView.getUint32(4, true);
|
|
|
|
|
|
|
|
|
|
if (version !== 2) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const declaredLength = dataView.getUint32(8, true);
|
|
|
|
|
const byteLength = Math.min(declaredLength, arrayBuffer.byteLength);
|
|
|
|
|
let offset = 12;
|
|
|
|
|
|
|
|
|
|
while (offset + 8 <= byteLength) {
|
|
|
|
|
const chunkLength = dataView.getUint32(offset, true);
|
|
|
|
|
const chunkType = dataView.getUint32(offset + 4, true);
|
|
|
|
|
const chunkStart = offset + 8;
|
|
|
|
|
const chunkEnd = chunkStart + chunkLength;
|
|
|
|
|
|
|
|
|
|
if (chunkEnd > byteLength) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (chunkType === GLB_JSON_CHUNK_TYPE) {
|
2026-05-19 13:26:13 +02:00
|
|
|
const jsonText = new TextDecoder()
|
|
|
|
|
.decode(arrayBuffer.slice(chunkStart, chunkEnd))
|
|
|
|
|
.trim();
|
2026-05-19 13:24:46 +02:00
|
|
|
const parsedJson = JSON.parse(jsonText) as unknown;
|
2026-05-19 13:26:13 +02:00
|
|
|
return parsedJson !== null && typeof parsedJson === "object"
|
|
|
|
|
? (parsedJson as Record<string, unknown>)
|
|
|
|
|
: null;
|
2026-05-19 13:24:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
offset = chunkEnd + ((4 - (chunkLength % 4)) % 4);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function glbUsesKtx2Textures(arrayBuffer: ArrayBuffer): boolean {
|
|
|
|
|
try {
|
|
|
|
|
const gltfJson = parseGlbJsonChunk(arrayBuffer);
|
|
|
|
|
return gltfJson === null ? false : gltfJsonUsesKtx2Textures(gltfJson);
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
function createBoundingBoxFromObject(
|
|
|
|
|
object: Object3D
|
|
|
|
|
): ModelAssetMetadata["boundingBox"] {
|
2026-03-31 18:44:18 +02:00
|
|
|
const box = new Box3().setFromObject(object);
|
|
|
|
|
|
|
|
|
|
if (box.isEmpty()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const min = {
|
|
|
|
|
x: box.min.x,
|
|
|
|
|
y: box.min.y,
|
|
|
|
|
z: box.min.z
|
|
|
|
|
};
|
|
|
|
|
const max = {
|
|
|
|
|
x: box.max.x,
|
|
|
|
|
y: box.max.y,
|
|
|
|
|
z: box.max.z
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
min,
|
|
|
|
|
max,
|
|
|
|
|
size: {
|
|
|
|
|
x: max.x - min.x,
|
|
|
|
|
y: max.y - min.y,
|
|
|
|
|
z: max.z - min.z
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectMaterialNames(scene: Group): string[] {
|
|
|
|
|
const names = new Set<string>();
|
|
|
|
|
|
|
|
|
|
scene.traverse((object) => {
|
|
|
|
|
const maybeMesh = object as Mesh & { isMesh?: boolean };
|
|
|
|
|
|
|
|
|
|
if (maybeMesh.isMesh !== true) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
const materials = Array.isArray(maybeMesh.material)
|
|
|
|
|
? maybeMesh.material
|
|
|
|
|
: [maybeMesh.material];
|
2026-03-31 18:44:18 +02:00
|
|
|
|
|
|
|
|
for (const material of materials) {
|
|
|
|
|
if (material.name.trim().length > 0) {
|
|
|
|
|
names.add(material.name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return [...names].sort((left, right) => left.localeCompare(right));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
function collectTextureNames(parserJson: {
|
|
|
|
|
textures?: Array<{ name?: string }>;
|
|
|
|
|
}): string[] {
|
2026-03-31 18:44:18 +02:00
|
|
|
const textures = parserJson.textures ?? [];
|
|
|
|
|
const names = new Set<string>();
|
|
|
|
|
|
|
|
|
|
for (const texture of textures) {
|
|
|
|
|
if (texture.name !== undefined && texture.name.trim().length > 0) {
|
|
|
|
|
names.add(texture.name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [...names].sort((left, right) => left.localeCompare(right));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectAnimationNames(gltf: GLTF): string[] {
|
|
|
|
|
return gltf.animations
|
2026-05-19 13:26:13 +02:00
|
|
|
.map((animation, index) =>
|
|
|
|
|
animation.name.trim().length > 0
|
|
|
|
|
? animation.name
|
|
|
|
|
: `Animation ${index + 1}`
|
|
|
|
|
)
|
2026-03-31 18:44:18 +02:00
|
|
|
.sort((left, right) => left.localeCompare(right));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function countNodes(scene: Group): number {
|
|
|
|
|
let count = 0;
|
|
|
|
|
|
|
|
|
|
scene.traverse(() => {
|
|
|
|
|
count += 1;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return count;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
export function extractModelAssetMetadata(
|
|
|
|
|
gltf: GLTF,
|
|
|
|
|
format: "glb" | "gltf"
|
|
|
|
|
): ModelAssetMetadata {
|
2026-03-31 18:44:18 +02:00
|
|
|
gltf.scene.updateMatrixWorld(true);
|
|
|
|
|
const boundingBox = createBoundingBoxFromObject(gltf.scene);
|
|
|
|
|
|
|
|
|
|
let actualMeshCount = 0;
|
|
|
|
|
|
|
|
|
|
gltf.scene.traverse((object) => {
|
|
|
|
|
if ((object as Mesh).isMesh === true) {
|
|
|
|
|
actualMeshCount += 1;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
const parserJson = gltf.parser.json as {
|
|
|
|
|
materials?: Array<{ name?: string }>;
|
|
|
|
|
textures?: Array<{ name?: string }>;
|
|
|
|
|
};
|
2026-03-31 18:44:18 +02:00
|
|
|
const materialNames = collectMaterialNames(gltf.scene);
|
|
|
|
|
const textureNames = collectTextureNames(parserJson);
|
|
|
|
|
const animationNames = collectAnimationNames(gltf);
|
|
|
|
|
const warnings: string[] = [];
|
|
|
|
|
|
|
|
|
|
if (boundingBox === null) {
|
|
|
|
|
warnings.push("The imported model does not contain measurable geometry.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (actualMeshCount === 0) {
|
|
|
|
|
warnings.push("The imported model does not contain any meshes.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (materialNames.length === 0 && (parserJson.materials?.length ?? 0) > 0) {
|
|
|
|
|
for (const material of parserJson.materials ?? []) {
|
|
|
|
|
if (material.name !== undefined && material.name.trim().length > 0) {
|
|
|
|
|
materialNames.push(material.name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
kind: "model",
|
|
|
|
|
format,
|
|
|
|
|
sceneName: gltf.scene.name.trim().length > 0 ? gltf.scene.name : null,
|
|
|
|
|
nodeCount: countNodes(gltf.scene),
|
|
|
|
|
meshCount: actualMeshCount,
|
2026-05-19 13:26:13 +02:00
|
|
|
materialNames: [...new Set(materialNames)].sort((left, right) =>
|
|
|
|
|
left.localeCompare(right)
|
|
|
|
|
),
|
2026-03-31 18:44:18 +02:00
|
|
|
textureNames,
|
|
|
|
|
animationNames,
|
|
|
|
|
boundingBox,
|
|
|
|
|
warnings
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
function createLoadedModelAsset(
|
|
|
|
|
asset: ModelAssetRecord,
|
|
|
|
|
template: Group,
|
|
|
|
|
animations: AnimationClip[]
|
|
|
|
|
): LoadedModelAsset {
|
2026-03-31 18:44:18 +02:00
|
|
|
return {
|
|
|
|
|
assetId: asset.id,
|
|
|
|
|
storageKey: asset.storageKey,
|
|
|
|
|
metadata: asset.metadata,
|
2026-03-31 23:57:10 +02:00
|
|
|
template,
|
|
|
|
|
animations
|
2026-03-31 18:44:18 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createModelAssetRecord(
|
|
|
|
|
sourceName: string,
|
|
|
|
|
mimeType: string,
|
|
|
|
|
byteLength: number,
|
|
|
|
|
metadata: ModelAssetMetadata
|
|
|
|
|
): ModelAssetRecord {
|
|
|
|
|
const assetId = createOpaqueId("asset-model");
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: assetId,
|
|
|
|
|
kind: "model",
|
|
|
|
|
sourceName,
|
|
|
|
|
mimeType,
|
|
|
|
|
storageKey: createProjectAssetStorageKey(assetId),
|
|
|
|
|
byteLength,
|
|
|
|
|
metadata
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
async function loadGltfFromArrayBuffer(
|
|
|
|
|
arrayBuffer: ArrayBuffer
|
|
|
|
|
): Promise<GLTF> {
|
2026-05-19 13:24:46 +02:00
|
|
|
const loader = createConfiguredGltfLoader({
|
|
|
|
|
ktx2: glbUsesKtx2Textures(arrayBuffer) ? "required" : "if-initialized"
|
|
|
|
|
});
|
2026-03-31 18:44:18 +02:00
|
|
|
return loader.parseAsync(arrayBuffer, "");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
export function createConfiguredGltfLoader(
|
|
|
|
|
options: { ktx2?: GltfKtx2Mode } = {}
|
|
|
|
|
): GLTFLoader {
|
2026-04-02 20:28:07 +02:00
|
|
|
const loader = new GLTFLoader();
|
|
|
|
|
loader.setDRACOLoader(getSharedDracoLoader());
|
2026-05-19 14:37:20 +02:00
|
|
|
loader.setMeshoptDecoder(MeshoptDecoder);
|
2026-05-19 13:24:46 +02:00
|
|
|
|
|
|
|
|
const ktx2Mode = options.ktx2 ?? "if-initialized";
|
|
|
|
|
const ktx2Loader =
|
|
|
|
|
ktx2Mode === "required"
|
|
|
|
|
? ensureSharedKtx2LoaderInitialized()
|
|
|
|
|
: getSharedKtx2LoaderIfInitialized();
|
|
|
|
|
|
|
|
|
|
if (ktx2Loader !== null) {
|
|
|
|
|
loader.setKTX2Loader(ktx2Loader);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:28:07 +02:00
|
|
|
return loader;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getSharedDracoLoader(): DRACOLoader {
|
|
|
|
|
if (sharedDracoLoader === null) {
|
|
|
|
|
sharedDracoLoader = new DRACOLoader();
|
|
|
|
|
sharedDracoLoader.setDecoderPath(DRACO_DECODER_PATH);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sharedDracoLoader;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
function createDataUrlForStoredFile(
|
|
|
|
|
file: ProjectAssetStorageFileRecord
|
|
|
|
|
): string {
|
2026-03-31 18:49:42 +02:00
|
|
|
const byteArray = new Uint8Array(file.bytes);
|
|
|
|
|
let binary = "";
|
|
|
|
|
const chunkSize = 0x8000;
|
|
|
|
|
|
|
|
|
|
for (let index = 0; index < byteArray.length; index += chunkSize) {
|
2026-05-19 13:26:13 +02:00
|
|
|
binary += String.fromCharCode(
|
|
|
|
|
...byteArray.subarray(index, index + chunkSize)
|
|
|
|
|
);
|
2026-03-31 18:49:42 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
const base64 =
|
|
|
|
|
typeof btoa === "function"
|
|
|
|
|
? btoa(binary)
|
|
|
|
|
: Buffer.from(file.bytes).toString("base64");
|
2026-03-31 18:49:42 +02:00
|
|
|
return `data:${file.mimeType};base64,${base64}`;
|
2026-03-31 18:44:18 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
function createTransientResourceUrl(file: ProjectAssetStorageFileRecord): {
|
|
|
|
|
revoke: () => void;
|
|
|
|
|
url: string;
|
|
|
|
|
} {
|
|
|
|
|
if (
|
|
|
|
|
typeof URL.createObjectURL === "function" &&
|
|
|
|
|
typeof Blob !== "undefined"
|
|
|
|
|
) {
|
|
|
|
|
const objectUrl = URL.createObjectURL(
|
|
|
|
|
new Blob([file.bytes], { type: file.mimeType })
|
|
|
|
|
);
|
2026-03-31 18:51:35 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
url: objectUrl,
|
|
|
|
|
revoke: () => {
|
|
|
|
|
if (typeof URL.revokeObjectURL === "function") {
|
|
|
|
|
URL.revokeObjectURL(objectUrl);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 04:32:30 +02:00
|
|
|
const dataUrl = createDataUrlForStoredFile(file);
|
|
|
|
|
const previousCacheEnabled = Cache.enabled;
|
|
|
|
|
Cache.enabled = true;
|
|
|
|
|
Cache.add(dataUrl, file.bytes.slice(0));
|
|
|
|
|
|
2026-03-31 18:51:35 +02:00
|
|
|
return {
|
2026-04-25 04:32:30 +02:00
|
|
|
url: dataUrl,
|
|
|
|
|
revoke: () => {
|
|
|
|
|
Cache.remove(dataUrl);
|
|
|
|
|
Cache.enabled = previousCacheEnabled;
|
|
|
|
|
}
|
2026-03-31 18:51:35 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:44:18 +02:00
|
|
|
function rewriteGltfResourceUris(
|
|
|
|
|
gltfJson: Record<string, unknown>,
|
|
|
|
|
files: Record<string, ProjectAssetStorageFileRecord>
|
2026-03-31 18:51:35 +02:00
|
|
|
): { missingUris: string[]; revokeUrls: Array<() => void> } {
|
2026-03-31 18:49:42 +02:00
|
|
|
const dataUrlsByPath = new Map<string, string>();
|
2026-03-31 18:51:35 +02:00
|
|
|
const revokeUrls: Array<() => void> = [];
|
2026-03-31 18:44:18 +02:00
|
|
|
const missingUris = new Set<string>();
|
|
|
|
|
|
|
|
|
|
const resolveUri = (uri: string): string | null => {
|
|
|
|
|
if (uri.startsWith("data:") || uri.startsWith("blob:")) {
|
|
|
|
|
return uri;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const normalizedUri = normalizeRelativePath(uri);
|
|
|
|
|
const storedFile = files[normalizedUri];
|
|
|
|
|
|
|
|
|
|
if (storedFile === undefined) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:49:50 +02:00
|
|
|
const cachedDataUrl = dataUrlsByPath.get(normalizedUri);
|
2026-03-31 18:44:18 +02:00
|
|
|
|
2026-03-31 18:49:50 +02:00
|
|
|
if (cachedDataUrl !== undefined) {
|
|
|
|
|
return cachedDataUrl;
|
2026-03-31 18:44:18 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:51:35 +02:00
|
|
|
const transientResourceUrl = createTransientResourceUrl(storedFile);
|
|
|
|
|
dataUrlsByPath.set(normalizedUri, transientResourceUrl.url);
|
|
|
|
|
revokeUrls.push(transientResourceUrl.revoke);
|
|
|
|
|
return transientResourceUrl.url;
|
2026-03-31 18:44:18 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const rewriteUri = (value: unknown): unknown => {
|
|
|
|
|
if (typeof value !== "string") {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resolvedUri = resolveUri(stripUrlQueryAndHash(value));
|
|
|
|
|
|
|
|
|
|
if (resolvedUri === null) {
|
|
|
|
|
missingUris.add(normalizeRelativePath(value));
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return resolvedUri;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
const buffers = Array.isArray(gltfJson.buffers)
|
|
|
|
|
? (gltfJson.buffers as Array<Record<string, unknown>>)
|
|
|
|
|
: [];
|
2026-03-31 18:44:18 +02:00
|
|
|
for (const buffer of buffers) {
|
|
|
|
|
if (typeof buffer.uri === "string") {
|
|
|
|
|
buffer.uri = rewriteUri(buffer.uri);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
const images = Array.isArray(gltfJson.images)
|
|
|
|
|
? (gltfJson.images as Array<Record<string, unknown>>)
|
|
|
|
|
: [];
|
2026-03-31 18:44:18 +02:00
|
|
|
for (const image of images) {
|
|
|
|
|
if (typeof image.uri === "string") {
|
|
|
|
|
image.uri = rewriteUri(image.uri);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
2026-03-31 18:51:35 +02:00
|
|
|
missingUris: [...missingUris],
|
|
|
|
|
revokeUrls
|
2026-03-31 18:44:18 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cloneTemplateScene(scene: Group): Group {
|
2026-04-05 06:38:07 +02:00
|
|
|
// Use SkeletonUtils.clone so that SkinnedMesh.skeleton.bones are remapped
|
|
|
|
|
// to the cloned hierarchy. A plain scene.clone(true) leaves the bones array
|
|
|
|
|
// pointing at the original loader's nodes, which are gone after parsing,
|
|
|
|
|
// making every skinned mesh invisible at runtime.
|
|
|
|
|
return cloneSkeleton(scene) as Group;
|
2026-03-31 18:44:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cloneMaterial(material: Material): Material {
|
|
|
|
|
return material.clone();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cloneMeshResources(object: Object3D) {
|
|
|
|
|
const maybeMesh = object as Mesh & { isMesh?: boolean };
|
|
|
|
|
|
|
|
|
|
if (maybeMesh.isMesh !== true) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
maybeMesh.geometry = maybeMesh.geometry.clone();
|
|
|
|
|
maybeMesh.material = Array.isArray(maybeMesh.material)
|
|
|
|
|
? maybeMesh.material.map((material) => cloneMaterial(material))
|
|
|
|
|
: cloneMaterial(maybeMesh.material);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function disposeTexture(texture: Texture, seenTextures: Set<Texture>) {
|
|
|
|
|
if (seenTextures.has(texture)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
seenTextures.add(texture);
|
|
|
|
|
texture.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
function disposeMaterialResources(
|
|
|
|
|
material: Material,
|
|
|
|
|
disposeTextures: boolean,
|
|
|
|
|
seenTextures: Set<Texture>
|
|
|
|
|
) {
|
2026-03-31 18:44:18 +02:00
|
|
|
if (disposeTextures) {
|
2026-05-19 13:26:13 +02:00
|
|
|
for (const value of Object.values(
|
|
|
|
|
material as unknown as Record<string, unknown>
|
|
|
|
|
)) {
|
2026-03-31 18:44:18 +02:00
|
|
|
if (value === null || value === undefined) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Array.isArray(value)) {
|
|
|
|
|
for (const entry of value) {
|
2026-05-19 13:26:13 +02:00
|
|
|
if (
|
|
|
|
|
entry !== null &&
|
|
|
|
|
typeof entry === "object" &&
|
|
|
|
|
"isTexture" in entry
|
|
|
|
|
) {
|
2026-03-31 18:44:18 +02:00
|
|
|
disposeTexture(entry as Texture, seenTextures);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof value === "object" && "isTexture" in value) {
|
|
|
|
|
disposeTexture(value as Texture, seenTextures);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
material.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
function disposeMeshResources(
|
|
|
|
|
object: Object3D,
|
|
|
|
|
disposeTextures: boolean,
|
|
|
|
|
seenTextures: Set<Texture>
|
|
|
|
|
) {
|
2026-03-31 18:44:18 +02:00
|
|
|
const maybeMesh = object as Mesh & { isMesh?: boolean };
|
|
|
|
|
|
|
|
|
|
if (maybeMesh.isMesh !== true) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
maybeMesh.geometry.dispose();
|
|
|
|
|
|
|
|
|
|
if (Array.isArray(maybeMesh.material)) {
|
|
|
|
|
for (const material of maybeMesh.material) {
|
|
|
|
|
disposeMaterialResources(material, disposeTextures, seenTextures);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
disposeMaterialResources(maybeMesh.material, disposeTextures, seenTextures);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function instantiateModelTemplate(template: Group): Group {
|
|
|
|
|
const clone = cloneSkeleton(template) as Group;
|
|
|
|
|
|
|
|
|
|
clone.traverse(cloneMeshResources);
|
|
|
|
|
|
|
|
|
|
return clone;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function disposeModelTemplate(template: Group) {
|
|
|
|
|
const seenTextures = new Set<Texture>();
|
|
|
|
|
|
|
|
|
|
template.traverse((object) => {
|
|
|
|
|
disposeMeshResources(object, true, seenTextures);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function disposeModelInstance(instance: Group) {
|
|
|
|
|
const seenTextures = new Set<Texture>();
|
|
|
|
|
|
|
|
|
|
instance.traverse((object) => {
|
|
|
|
|
disposeMeshResources(object, false, seenTextures);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadModelFileSet(files: File[]): Promise<ImportedModelFileSet> {
|
|
|
|
|
if (files.length === 0) {
|
|
|
|
|
throw new Error("Select a .glb or .gltf file to import.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const modelFiles = files.filter((file) => {
|
|
|
|
|
try {
|
|
|
|
|
inferModelAssetFormat(file.name, file.type);
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (modelFiles.length === 0) {
|
|
|
|
|
throw new Error("Select a .glb or .gltf file to import.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (modelFiles.length > 1) {
|
2026-05-19 13:26:13 +02:00
|
|
|
throw new Error(
|
|
|
|
|
"Select exactly one .glb or .gltf file and any matching sidecar resources."
|
|
|
|
|
);
|
2026-03-31 18:44:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rootFile = modelFiles[0];
|
|
|
|
|
const rootSourcePath = getImportedFilePath(rootFile);
|
|
|
|
|
const rootDirectory = getPathDirectory(rootSourcePath);
|
|
|
|
|
const importedFiles = await Promise.all(
|
|
|
|
|
files.map(async (file) => ({
|
|
|
|
|
file,
|
|
|
|
|
bytes: await file.arrayBuffer()
|
|
|
|
|
}))
|
|
|
|
|
);
|
|
|
|
|
const fileEntries: ImportedModelFileEntry[] = [];
|
|
|
|
|
const packageFiles: Record<string, ProjectAssetStorageFileRecord> = {};
|
|
|
|
|
|
|
|
|
|
for (const { file, bytes } of importedFiles) {
|
2026-05-19 13:26:13 +02:00
|
|
|
const sourcePath =
|
|
|
|
|
file === rootFile
|
|
|
|
|
? normalizeRelativePath(rootFile.name.trim())
|
|
|
|
|
: getRelativePath(rootDirectory, getImportedFilePath(file));
|
2026-03-31 18:44:18 +02:00
|
|
|
const mimeType = inferFileMimeType(file.name, file.type);
|
|
|
|
|
|
2026-03-31 18:44:42 +02:00
|
|
|
if (packageFiles[sourcePath] !== undefined) {
|
2026-03-31 18:44:18 +02:00
|
|
|
throw new Error(`Duplicate imported file path ${sourcePath}.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const entry = {
|
|
|
|
|
bytes,
|
|
|
|
|
mimeType,
|
|
|
|
|
path: sourcePath
|
|
|
|
|
} satisfies ImportedModelFileEntry;
|
|
|
|
|
|
|
|
|
|
fileEntries.push(entry);
|
|
|
|
|
packageFiles[sourcePath] = {
|
|
|
|
|
bytes,
|
|
|
|
|
mimeType
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
const rootEntry = fileEntries.find(
|
|
|
|
|
(entry) => entry.path === normalizeRelativePath(rootFile.name.trim())
|
|
|
|
|
);
|
2026-03-31 18:44:18 +02:00
|
|
|
|
|
|
|
|
if (rootEntry === undefined) {
|
|
|
|
|
throw new Error(`Unable to locate the root model file ${rootFile.name}.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Keep the root file's canonical storage path equal to its source name so reloads can find it directly.
|
|
|
|
|
const packageRecord: ProjectAssetStoragePackageRecord = {
|
|
|
|
|
files: packageFiles
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
fileEntries,
|
|
|
|
|
packageRecord,
|
|
|
|
|
rootFile: rootEntry,
|
2026-05-19 13:26:13 +02:00
|
|
|
totalByteLength: fileEntries.reduce(
|
|
|
|
|
(total, entry) => total + entry.bytes.byteLength,
|
|
|
|
|
0
|
|
|
|
|
)
|
2026-03-31 18:44:18 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
async function loadGltfFromImportedModelFileSet(
|
|
|
|
|
fileSet: ImportedModelFileSet
|
|
|
|
|
): Promise<GLTF> {
|
|
|
|
|
const rootFormat = inferModelAssetFormat(
|
|
|
|
|
fileSet.rootFile.path,
|
|
|
|
|
fileSet.rootFile.mimeType
|
|
|
|
|
);
|
2026-03-31 18:44:18 +02:00
|
|
|
|
|
|
|
|
if (rootFormat === "glb") {
|
|
|
|
|
return loadGltfFromArrayBuffer(fileSet.rootFile.bytes);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const text = new TextDecoder().decode(fileSet.rootFile.bytes);
|
|
|
|
|
const gltfJson = JSON.parse(text) as Record<string, unknown>;
|
2026-05-19 13:26:13 +02:00
|
|
|
const { missingUris, revokeUrls } = rewriteGltfResourceUris(
|
|
|
|
|
gltfJson,
|
|
|
|
|
fileSet.packageRecord.files
|
|
|
|
|
);
|
2026-03-31 18:44:18 +02:00
|
|
|
|
|
|
|
|
if (missingUris.length > 0) {
|
2026-03-31 18:51:35 +02:00
|
|
|
for (const revokeUrl of revokeUrls) {
|
|
|
|
|
revokeUrl();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
throw new Error(
|
|
|
|
|
`Missing external model resource(s): ${missingUris.join(", ")}.`
|
|
|
|
|
);
|
2026-03-31 18:44:18 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:24:46 +02:00
|
|
|
const loader = createConfiguredGltfLoader({
|
|
|
|
|
ktx2: gltfJsonUsesKtx2Textures(gltfJson) ? "required" : "if-initialized"
|
|
|
|
|
});
|
2026-03-31 18:44:18 +02:00
|
|
|
|
2026-03-31 18:51:35 +02:00
|
|
|
try {
|
|
|
|
|
return await loader.parseAsync(JSON.stringify(gltfJson), "");
|
|
|
|
|
} finally {
|
|
|
|
|
for (const revokeUrl of revokeUrls) {
|
|
|
|
|
revokeUrl();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-31 18:44:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createModelAssetRecordFromFileSet(
|
|
|
|
|
sourceName: string,
|
|
|
|
|
mimeType: string,
|
|
|
|
|
byteLength: number,
|
|
|
|
|
metadata: ModelAssetMetadata
|
|
|
|
|
): ModelAssetRecord {
|
|
|
|
|
return createModelAssetRecord(sourceName, mimeType, byteLength, metadata);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function importModelAssetFromFiles(
|
|
|
|
|
files: File[],
|
|
|
|
|
storage: ProjectAssetStorage
|
|
|
|
|
): Promise<ImportedModelAssetResult> {
|
2026-03-31 18:46:34 +02:00
|
|
|
let fileSet: ImportedModelFileSet;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
fileSet = await loadModelFileSet(files);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
throw new Error(`Model import failed: ${getErrorDetail(error)}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:44:18 +02:00
|
|
|
const sourceName = fileSet.rootFile.path;
|
|
|
|
|
const format = inferModelAssetFormat(sourceName, fileSet.rootFile.mimeType);
|
|
|
|
|
const mimeType = inferModelMimeType(format);
|
2026-03-31 18:46:34 +02:00
|
|
|
let gltf: GLTF;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
gltf = await loadGltfFromImportedModelFileSet(fileSet);
|
|
|
|
|
} catch (error) {
|
2026-05-19 13:26:13 +02:00
|
|
|
throw new Error(
|
|
|
|
|
`Model import failed for ${sourceName}: ${getErrorDetail(error)}`
|
|
|
|
|
);
|
2026-03-31 18:46:34 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:44:18 +02:00
|
|
|
const metadata = extractModelAssetMetadata(gltf, format);
|
2026-05-19 13:26:13 +02:00
|
|
|
const asset = createModelAssetRecordFromFileSet(
|
|
|
|
|
sourceName,
|
|
|
|
|
mimeType,
|
|
|
|
|
fileSet.totalByteLength,
|
|
|
|
|
metadata
|
|
|
|
|
);
|
2026-03-31 18:44:18 +02:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await storage.putAsset(asset.storageKey, fileSet.packageRecord);
|
|
|
|
|
|
|
|
|
|
const modelInstance = createModelInstance({
|
|
|
|
|
assetId: asset.id,
|
|
|
|
|
name: undefined
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
asset,
|
|
|
|
|
modelInstance,
|
2026-05-19 13:26:13 +02:00
|
|
|
loadedAsset: createLoadedModelAsset(
|
|
|
|
|
asset,
|
|
|
|
|
cloneTemplateScene(gltf.scene),
|
|
|
|
|
gltf.animations
|
|
|
|
|
)
|
2026-03-31 18:44:18 +02:00
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
await storage.deleteAsset(asset.storageKey).catch(() => undefined);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function importModelAssetFromFile(
|
|
|
|
|
file: File,
|
|
|
|
|
storage: ProjectAssetStorage
|
|
|
|
|
): Promise<ImportedModelAssetResult> {
|
|
|
|
|
return importModelAssetFromFiles([file], storage);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getStoredModelAssetFile(
|
|
|
|
|
asset: ModelAssetRecord,
|
|
|
|
|
storedAsset: ProjectAssetStoragePackageRecord
|
|
|
|
|
): ProjectAssetStorageFileRecord | null {
|
|
|
|
|
const directFile = storedAsset.files[asset.sourceName];
|
|
|
|
|
|
|
|
|
|
if (directFile !== undefined) {
|
|
|
|
|
return directFile;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const storedFiles = Object.values(storedAsset.files);
|
|
|
|
|
|
|
|
|
|
if (storedFiles.length === 1) {
|
|
|
|
|
return storedFiles[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function loadModelAssetFromStorage(
|
|
|
|
|
storage: ProjectAssetStorage,
|
|
|
|
|
asset: ModelAssetRecord
|
|
|
|
|
): Promise<LoadedModelAsset> {
|
2026-03-31 18:46:34 +02:00
|
|
|
let storedAsset: ProjectAssetStoragePackageRecord | null;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
storedAsset = await storage.getAsset(asset.storageKey);
|
|
|
|
|
} catch (error) {
|
2026-05-19 13:26:13 +02:00
|
|
|
throw new Error(
|
|
|
|
|
`Model asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`
|
|
|
|
|
);
|
2026-03-31 18:46:34 +02:00
|
|
|
}
|
2026-03-31 18:44:18 +02:00
|
|
|
|
|
|
|
|
if (storedAsset === null) {
|
2026-05-19 13:26:13 +02:00
|
|
|
throw new Error(
|
|
|
|
|
`Missing stored binary data for imported model asset ${asset.sourceName}.`
|
|
|
|
|
);
|
2026-03-31 18:44:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const storedModelFile = getStoredModelAssetFile(asset, storedAsset);
|
|
|
|
|
|
|
|
|
|
if (storedModelFile === null) {
|
2026-05-19 13:26:13 +02:00
|
|
|
throw new Error(
|
|
|
|
|
`Missing stored root file for imported model asset ${asset.sourceName}.`
|
|
|
|
|
);
|
2026-03-31 18:44:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (asset.metadata.format === "glb") {
|
2026-03-31 18:46:34 +02:00
|
|
|
try {
|
|
|
|
|
const gltf = await loadGltfFromArrayBuffer(storedModelFile.bytes);
|
2026-05-19 13:26:13 +02:00
|
|
|
return createLoadedModelAsset(
|
|
|
|
|
asset,
|
|
|
|
|
cloneTemplateScene(gltf.scene),
|
|
|
|
|
gltf.animations
|
|
|
|
|
);
|
2026-03-31 18:46:34 +02:00
|
|
|
} catch (error) {
|
2026-05-19 13:26:13 +02:00
|
|
|
throw new Error(
|
|
|
|
|
`Model asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`
|
|
|
|
|
);
|
2026-03-31 18:46:34 +02:00
|
|
|
}
|
2026-03-31 18:44:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fileEntries = storedAsset.files;
|
|
|
|
|
const rootFileBytes = storedModelFile.bytes;
|
2026-05-19 13:26:13 +02:00
|
|
|
const gltfJson = JSON.parse(
|
|
|
|
|
new TextDecoder().decode(rootFileBytes)
|
|
|
|
|
) as Record<string, unknown>;
|
|
|
|
|
const { missingUris, revokeUrls } = rewriteGltfResourceUris(
|
|
|
|
|
gltfJson,
|
|
|
|
|
fileEntries
|
|
|
|
|
);
|
2026-03-31 18:44:18 +02:00
|
|
|
|
|
|
|
|
if (missingUris.length > 0) {
|
2026-03-31 18:51:35 +02:00
|
|
|
for (const revokeUrl of revokeUrls) {
|
|
|
|
|
revokeUrl();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:26:13 +02:00
|
|
|
throw new Error(
|
|
|
|
|
`Missing stored external model resource(s): ${missingUris.join(", ")}.`
|
|
|
|
|
);
|
2026-03-31 18:44:18 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 13:24:46 +02:00
|
|
|
const loader = createConfiguredGltfLoader({
|
|
|
|
|
ktx2: gltfJsonUsesKtx2Textures(gltfJson) ? "required" : "if-initialized"
|
|
|
|
|
});
|
2026-03-31 18:44:18 +02:00
|
|
|
|
2026-03-31 18:51:35 +02:00
|
|
|
try {
|
|
|
|
|
const gltf = await loader.parseAsync(JSON.stringify(gltfJson), "");
|
2026-05-19 13:26:13 +02:00
|
|
|
return createLoadedModelAsset(
|
|
|
|
|
asset,
|
|
|
|
|
cloneTemplateScene(gltf.scene),
|
|
|
|
|
gltf.animations
|
|
|
|
|
);
|
2026-03-31 18:51:35 +02:00
|
|
|
} finally {
|
|
|
|
|
for (const revokeUrl of revokeUrls) {
|
|
|
|
|
revokeUrl();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-31 18:44:18 +02:00
|
|
|
}
|