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, ): Promise<{ bytes: Uint8Array; name: string; metadata: ReturnType }> { 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)[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) { 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>) { 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; }