90 lines
4.6 KiB
TypeScript
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;
|
|
}
|