Files

90 lines
4.6 KiB
TypeScript

import { unzipSync } from "fflate";
import { base64, mimeFor } from "./archive.ts";
import { inspectModel, modelDocument } from "./model.ts";
import { emptyProject, entity, uid, activeScene } from "./schema.ts";
/** Resolve inside a supplied bundle. Never fetch network or arbitrary local files. */
export function modelResourcePath(model: string, uri: string) {
const decoded = decodeURIComponent(uri);
if (/^[a-z][a-z\d+.-]*:/i.test(decoded) || /^[\\/]/.test(decoded) || /[\\?#\0]/.test(decoded))
throw Error("Недопустимый путь ресурса: " + uri);
const parts = model.split("/").slice(0, -1);
for (const part of decoded.split("/")) {
if (part === "..") { if (!parts.length) throw Error("Ресурс выходит за каталог импорта"); parts.pop(); }
else if (part && part !== ".") parts.push(part);
}
return parts.join("/");
}
export async function portableModel(
bytes: Uint8Array,
name: string,
read?: (path: string) => Promise<Uint8Array>,
): Promise<{ bytes: Uint8Array; name: string; metadata: ReturnType<typeof inspectModel> }> {
if (/\.zip$/i.test(name)) {
if (bytes.byteLength > 25 * 1024 * 1024) throw Error("Лимит ZIP модели: 25 МБ");
let size = 0;
const files = unzipSync(bytes, { filter: (file) => {
size += file.originalSize;
if (size > 80 * 1024 * 1024 || file.originalSize > 25 * 1024 * 1024)
throw Error("Слишком большой архив модели");
if (file.name.startsWith("/") || file.name.includes("\\") || file.name.split("/").includes(".."))
throw Error("Небезопасный путь в ZIP");
return true;
} });
const models = Object.keys(files).filter(path => /\.(glb|gltf)$/i.test(path) && !path.startsWith("__MACOSX/"));
if (models.length !== 1) throw Error("ZIP должен содержать ровно одну GLB/glTF-сцену и её ресурсы");
const model = models[0];
return portableModel(files[model], model, async path => {
if (!files[path]) throw Error("Отсутствует ресурс: " + path);
return files[path];
});
}
if (!/\.(glb|gltf)$/i.test(name)) throw Error("Ожидался GLB, glTF или ZIP модели");
if (/\.gltf$/i.test(name)) {
const doc = modelDocument(bytes, name);
let total = bytes.byteLength;
for (const [kind, list] of [["buffer", doc.buffers || []], ["image", doc.images || []]] as const) {
for (const item of list) {
if (!item.uri || item.uri.startsWith("data:")) continue;
const path = modelResourcePath(name, item.uri);
if (!read) throw Error("Добавь связанные файлы или импортируй ZIP: " + path);
const resource = await read(path);
total += resource.byteLength;
if (total > 25 * 1024 * 1024) throw Error("Лимит ресурсов модели: 25 МБ");
const ext = path.split(".").pop()?.toLowerCase();
const mime = kind === "buffer" ? "application/octet-stream" : ({ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" } as Record<string, string>)[ext || ""];
if (!mime) throw Error("Неподдерживаемая текстура: " + path);
item.uri = `data:${mime};base64,${base64(resource)}`;
}
}
bytes = new TextEncoder().encode(JSON.stringify(doc));
}
name = name.split("/").pop()!;
return { bytes, name, metadata: inspectModel(bytes, name) };
}
export function modelComponents(assetId: string, metadata: ReturnType<typeof inspectModel>) {
return {
mesh: { type: "model", assetId },
...(metadata.clips.length ? { animator: { autoplay: metadata.clips[0], loop: true, speed: 1 } } : {}),
};
}
export const modelDataUri = (model: { name: string; bytes: Uint8Array }) =>
`data:${mimeFor(model.name)};base64,${base64(model.bytes)}`;
/** Open a complete Blender scene as an undoable project replacement. */
export function modelProject(model: Awaited<ReturnType<typeof portableModel>>) {
const project = emptyProject(model.name.replace(/\.(glb|gltf)$/i, ""));
const id = uid("asset");
project.assets = [{ id, name: model.name, kind: "model", uri: modelDataUri(model), metadata: model.metadata }];
const components = { ...modelComponents(id, model.metadata), ...(model.metadata.cameras.length ? { camera: { mode: "imported", ...(model.metadata.activeCamera ? { cameraName: model.metadata.activeCamera } : {}) } } : {}) };
activeScene(project).entities = [entity(project.name, components)];
if (model.metadata.lights) {
project.settings.ambient = 0;
project.settings.rendering = { defaultLights: false };
}
return project;
}