feat(all): 离线包迁移

This commit is contained in:
plum 2026-04-08 20:51:11 +08:00
parent 309bb0a5e6
commit 404e804143
3 changed files with 476 additions and 123 deletions

View File

@ -25,8 +25,10 @@ import {onMounted} from "vue";
import { DocumentImport, DocumentExport } from "@vicons/carbon"; import { DocumentImport, DocumentExport } from "@vicons/carbon";
import { t } from "@/language"; import { t } from "@/language";
import { App, Export, Loader } from "@astral3d/engine"; import { App, Export, Loader } from "@astral3d/engine";
import { useGlobalConfigStore } from "@/store/modules/globalConfig";
const exportClass = new Export(); const exportClass = new Export();
const globalConfigStore = useGlobalConfigStore();
const exportOptions = [ const exportOptions = [
{ {
@ -35,41 +37,41 @@ const exportOptions = [
children: [ children: [
{ {
label: "JSON", label: "JSON",
key: "exportObjectToJSON" key: "exportObjectToJSON",
}, },
{ {
label: "GLB", label: "GLB",
key: "exportObjectToGlb" key: "exportObjectToGlb",
}, },
{ {
label: "GLTF", label: "GLTF",
key: "exportObjectToGltf" key: "exportObjectToGltf",
}, },
{ {
label: "OBJ", label: "OBJ",
key: "exportObjectToObj" key: "exportObjectToObj",
}, },
{ {
label: "PLY", label: "PLY",
key: "exportObjectToPly" key: "exportObjectToPly",
}, },
{ {
label: t("layout.header['PLY (Binary)']"), label: t("layout.header['PLY (Binary)']"),
key: "exportObjectToPlyBinary" key: "exportObjectToPlyBinary",
}, },
{ {
label: "STL", label: "STL",
key: "exportObjectToStl" key: "exportObjectToStl",
}, },
{ {
label: t("layout.header['STL (Binary)']"), label: t("layout.header['STL (Binary)']"),
key: "exportObjectToStlBinary" key: "exportObjectToStlBinary",
}, },
{ {
label: "USDZ", label: "USDZ",
key: "exportObjectToUSDZ" key: "exportObjectToUSDZ",
} },
] ],
}, },
{ {
label: t("layout.header['Export Scene']"), label: t("layout.header['Export Scene']"),
@ -77,55 +79,82 @@ const exportOptions = [
children: [ children: [
{ {
label: "JSON", label: "JSON",
key: "exportSceneToJSON" key: "exportSceneToJSON",
}, },
{ {
label: "GLB", label: "GLB",
key: "exportSceneToGlb" key: "exportSceneToGlb",
}, },
{ {
label: "GLTF", label: "GLTF",
key: "exportSceneToGltf" key: "exportSceneToGltf",
}, },
{ {
label: "OBJ", label: "OBJ",
key: "exportSceneToObj" key: "exportSceneToObj",
}, },
{ {
label: "PLY", label: "PLY",
key: "exportSceneToPly" key: "exportSceneToPly",
}, },
{ {
label: t("layout.header['PLY (Binary)']"), label: t("layout.header['PLY (Binary)']"),
key: "exportSceneToPlyBinary" key: "exportSceneToPlyBinary",
}, },
{ {
label: "STL", label: "STL",
key: "exportSceneToStl" key: "exportSceneToStl",
}, },
{ {
label: t("layout.header['STL (Binary)']"), label: t("layout.header['STL (Binary)']"),
key: "exportSceneToStlBinary" key: "exportSceneToStlBinary",
}, },
{ {
label: "USDZ", label: "USDZ",
key: "exportSceneToUSDZ" key: "exportSceneToUSDZ",
} },
] ],
} },
] {
label: t("layout.header['Offline package']"),
key: "exportOffline",
children: [
{
label: t("layout.header['Single packet']"),
key: "exportOfflineSingle",
},
{
label: t("layout.header['Multiple packet']"),
key: "exportOfflineMultiple",
},
],
},
];
function handleImport() { function handleImport() {
const form = document.createElement('form'); const form = document.createElement("form");
form.style.display = 'none'; form.style.display = "none";
document.body.appendChild(form); document.body.appendChild(form);
const fileInput = document.createElement('input'); const fileInput = document.createElement("input");
fileInput.multiple = true; fileInput.multiple = true;
fileInput.type = 'file'; fileInput.type = "file";
fileInput.addEventListener('change', function () { fileInput.addEventListener("change", function () {
Loader.loadFiles(fileInput.files, undefined) const files = fileInput.files;
.catch((err) => { if (!files || files.length === 0) {
form.reset();
return;
}
const astralFiles = Array.from(files).filter(file => file.name.toLowerCase().endsWith(".astral"));
if (astralFiles.length > 0) {
handleOfflineFiles(files);
form.reset();
return;
}
Loader.loadFiles(files, undefined)
.catch(err => {
window.$message?.error(err); window.$message?.error(err);
}) })
.finally(() => { .finally(() => {
@ -138,6 +167,15 @@ function handleImport() {
} }
function handleExportSelect(key: string) { function handleExportSelect(key: string) {
if (key === "exportOfflineSingle") {
exportOffline("single");
return;
}
if (key === "exportOfflineMultiple") {
exportOffline("multiple");
return;
}
if (key.startsWith("exportObject")) { if (key.startsWith("exportObject")) {
if (App.selected === null) { if (App.selected === null) {
window.$message?.error(window.$t("prompt['No object selected.']")); window.$message?.error(window.$t("prompt['No object selected.']"));
@ -145,11 +183,121 @@ function handleExportSelect(key: string) {
} }
} }
exportClass[key](); exportClass[key] && exportClass[key]();
} }
onMounted(() => { function downloadFile(file: File) {
const url = URL.createObjectURL(file);
const link = document.createElement("a");
link.href = url;
link.download = file.name;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function downloadFilesInBatches(files: File[], batchSize = 9, delayMs = 1100) {
for (let i = 0; i < files.length; i++) {
downloadFile(files[i]);
if ((i + 1) % batchSize === 0 && i < files.length - 1) {
await sleep(delayMs);
}
}
}
function exportOffline(mode: "single" | "multiple") {
const packer = (window.viewer as any)?.package;
if (!packer || typeof packer.packOffline !== "function") {
window.$message?.error("当前SDK暂不支持离线包导出");
return;
}
const sceneInfo = App.project.getKey("sceneInfo");
const sceneName = sceneInfo?.sceneName || "scene";
globalConfigStore.loading = true;
globalConfigStore.loadingText = "正在导出离线包...";
packer
.packOffline({
name: sceneName,
layer: 2,
mode,
onProgress: (progress: number) => {
globalConfigStore.loadingText = `离线包导出 ${progress}%`;
},
}) })
.then(async (result: { files: File[] }) => {
await downloadFilesInBatches(result.files);
window.$message?.success("离线包导出完成");
})
.catch(err => {
console.error(err);
if (err?.code === "SDK_FEATURE_DISABLED") {
window.$message?.error("当前授权未开通离线包导出能力");
return;
}
if (typeof err?.message === "string" && err.message.trim()) {
window.$message?.error(err.message);
return;
}
window.$message?.error("离线包导出失败");
})
.finally(() => {
globalConfigStore.loading = false;
globalConfigStore.loadingText = "";
});
}
function handleOfflineFiles(files: FileList | null) {
if (!files || files.length === 0) return;
const astralFiles = Array.from(files).filter(file => file.name.toLowerCase().endsWith(".astral"));
if (astralFiles.length === 0) {
window.$message?.error("请选择离线包文件(.astral)");
return;
}
const packer = (window.viewer as any)?.package;
if (!packer || typeof packer.unpack !== "function") {
window.$message?.error("当前SDK暂不支持离线包导入");
return;
}
const entryFile = guessEntryFile(astralFiles);
globalConfigStore.loading = true;
globalConfigStore.loadingText = "离线包加载中...";
packer.unpack({
url: entryFile.name,
offlineFiles: astralFiles,
offlineEntry: entryFile.name,
onProgress: (progress: number) => {
globalConfigStore.loadingText = `离线包加载 ${progress.toFixed(2)}%`;
},
onComplete: () => {
globalConfigStore.loading = false;
globalConfigStore.loadingText = "";
window.$message?.success("离线包加载完成");
},
});
}
function guessEntryFile(files: File[]): File {
const uuidReg = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
const findBaseName = (name: string) => name.replace(/\.[^.]+$/, "");
const candidate = files.find(file => !uuidReg.test(findBaseName(file.name)));
return candidate || files[0];
}
onMounted(() => {});
</script> </script>
<style scoped lang="less"></style> <style scoped lang="less"></style>

View File

@ -77,6 +77,9 @@ export default {
Export: '导出', Export: '导出',
'Export Object': '导出物体', 'Export Object': '导出物体',
'Export Scene': '导出场景', 'Export Scene': '导出场景',
'Offline package': '离线包',
'Single packet': '单包',
'Multiple packet': '多包',
'PLY (Binary)': 'PLY(二进制)', 'PLY (Binary)': 'PLY(二进制)',
'STL (Binary)': 'STL(二进制)', 'STL (Binary)': 'STL(二进制)',
/* File 下的选项 End */ /* File 下的选项 End */

View File

@ -7,6 +7,7 @@
*/ */
import { Mesh, Group, Bone } from "three"; import { Mesh, Group, Bone } from "three";
import JSZip from "jszip"; import JSZip from "jszip";
import { strToU8 } from 'three/examples/jsm/libs/fflate.module.js';
import { BASE64_TYPES, TYPED_ARRAYS } from "@/constant"; import { BASE64_TYPES, TYPED_ARRAYS } from "@/constant";
import { unzip, zip, fetchController } from "@/utils"; import { unzip, zip, fetchController } from "@/utils";
import { PackageSkeleton } from "@/core/loader/Package.Skeleton"; import { PackageSkeleton } from "@/core/loader/Package.Skeleton";
@ -16,18 +17,70 @@ import App from "@/core/app/App";
import type Viewer from "@/core/viewer/Viewer"; import type Viewer from "@/core/viewer/Viewer";
interface IPackConfig { interface IPackConfig {
name: string; // 首包名称 // 首包名称
layer?: number; // 拆分的最深层级 0:拆分至最深层 name: string;
zipUploadFun: (zip: File) => Promise<any>; // 压缩包上传接口函数,多压缩包 // 拆分的最深层级 0:拆分至最深层
onProgress?: (progress: number) => void; // 打包进度回调 layer?: number;
onComplete?: (_: { firstUploadResult: any, totalSize: number, totalZipNumber: number }) => void; // 打包完成回调 // 压缩包上传接口函数,多压缩包
zipUploadFun: (zip: File) => Promise<any>;
// 原始包上传接口函数(不压缩)
rawUploadFun?: (raw: { name: string; files: Record<string, Uint8Array> }) => Promise<any>;
// 压缩进度回调0-100
onZipProgress?: (info: { name: string; progress: number }) => void;
// 图片处理并发
imageProcessLimit?: number;
// 打包进度回调
onProgress?: (progress: number) => void;
// 打包完成回调
onComplete?: (_: { firstUploadResult: any, totalSize: number, totalZipNumber: number }) => void;
}
interface IOfflinePackConfig {
// 离线包名称(作为首包 zip 名称)
name: string;
// 拆分的最深层级0 表示拆分到最深层
layer?: number;
// 导出模式single 为单 zipmultiple 为多 zip
mode?: "single" | "multiple";
// 单包导出时的包名(不含后缀)
bundleName?: string;
// 压缩进度回调0-100
onZipProgress?: (info: { name: string; progress: number }) => void;
// 图片处理并发
imageProcessLimit?: number;
// 进度回调
onProgress?: (progress: number) => void;
// 完成回调
onComplete?: (result: OfflinePackResult) => void;
}
interface OfflinePackResult {
mode: "single" | "multiple";
// 首包名称(含 .astral
entry: string;
// 导出的包文件列表
files: File[];
// 总大小
totalSize: number;
// 包数量(首包 + 组包)
totalZipNumber: number;
} }
interface IUnpackConfig { interface IUnpackConfig {
url: string, // 首包url // 首包url
onSceneLoad?: (sceneJson: ISceneJson, configJson: IAppProject.Config) => void, // 场景首包加载完成回调 url: string;
onProgress?: (progress: number) => void; // 场景加载进度回调 // 场景首包加载完成回调
onComplete?: () => void // 场景加载完成回调. onSceneLoad?: (sceneJson: ISceneJson, configJson: IAppProject.Config) => void;
// 场景加载进度回调
onProgress?: (progress: number) => void;
// 场景加载完成回调
onComplete?: () => void;
// 离线多包文件列表(可选)
offlineFiles?: File[];
// 离线单包(内部包含多个包)
offlineBundle?: Blob | ArrayBuffer | Uint8Array;
// 离线首包名称(可选,未提供则使用 url 的文件名)
offlineEntry?: string;
} }
interface SourceData { interface SourceData {
@ -71,6 +124,19 @@ export class Package {
private callFunNum: { value: number; }; private callFunNum: { value: number; };
private skeletonClass: PackageSkeleton; private skeletonClass: PackageSkeleton;
// 离线包相关属性
// @ts-ignore
private offlineZipMap: Map<string, Uint8Array> | null = null;
// @ts-ignore
private offlineEntryZip: string | null = null;
// @ts-ignore
private offlineFlatPackages: Map<string, Record<string, Uint8Array>> | null = null;
// @ts-ignore
private offlineFlatEntryBase: string | null = null;
private readonly offlinePackagesPrefix = "Packages/";
// @ts-ignore
private isOfflineImportContext = false;
constructor(viewer:Viewer) { constructor(viewer:Viewer) {
this.viewer = viewer; this.viewer = viewer;
@ -1199,4 +1265,140 @@ export class Package {
// 5. 释放 viewer 引用(注意:不销毁 viewer仅移除引用 // 5. 释放 viewer 引用(注意:不销毁 viewer仅移除引用
this.viewer = null as any; this.viewer = null as any;
} }
/**
* 线
*/
async packOffline(offlineConfig: IOfflinePackConfig) {
const mode = offlineConfig.mode || "multiple";
const packageItems: Array<{ baseName: string; data: Uint8Array }> = [];
const rawPackages: Array<{ baseName: string; files: Record<string, Uint8Array> }> = [];
const packResult = await this.pack({
name: offlineConfig.name,
layer: mode === "single" ? 1 : offlineConfig.layer,
imageProcessLimit: offlineConfig.imageProcessLimit,
zipUploadFun: async (zip: File) => {
if (mode === "single") {
return zip;
}
const data = new Uint8Array(await zip.arrayBuffer());
const baseName = this.getOfflinePackageBaseName(zip.name);
packageItems.push({ baseName, data });
return zip;
},
rawUploadFun: mode === "single"
? async (raw) => {
const baseName = this.getOfflinePackageBaseName(raw.name);
rawPackages.push({ baseName, files: raw.files });
return raw;
}
: undefined,
onZipProgress: offlineConfig.onZipProgress,
onProgress: offlineConfig.onProgress,
});
const entry = this.normalizeOfflinePackageName(offlineConfig.name);
const entryBase = this.getOfflinePackageBaseName(entry);
if (mode === "single") {
const filesMap: Record<string, Uint8Array> = {};
for (const item of rawPackages) {
for (const [fileName, fileData] of Object.entries(item.files)) {
const fullName = `${this.offlinePackagesPrefix}${item.baseName}/${fileName}`;
if (!filesMap[fullName]) {
filesMap[fullName] = fileData;
}
}
}
const manifest = {
mode: "single",
entry,
entryBase,
packagesPrefix: this.offlinePackagesPrefix,
totalZipNumber: packResult.totalZipNumber,
};
filesMap["offline.json"] = strToU8(JSON.stringify(manifest));
const bundleName = this.normalizeOfflinePackageName(offlineConfig.bundleName || `${offlineConfig.name}-offline`);
const bundleZip = await this.zipRawFiles(filesMap);
const bundleFile = new File([bundleZip as any], bundleName, { type: "application/octet-stream" });
const result: OfflinePackResult = {
mode: "single",
entry,
files: [bundleFile],
totalSize: bundleFile.size,
totalZipNumber: packResult.totalZipNumber,
};
offlineConfig.onComplete && offlineConfig.onComplete(result);
return result;
}
const files = packageItems.map(item => new File([item.data as any], this.normalizeOfflinePackageName(item.baseName), { type: "application/octet-stream" }));
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
const result: OfflinePackResult = {
mode: "multiple",
entry,
files,
totalSize,
totalZipNumber: packResult.totalZipNumber,
};
offlineConfig.onComplete && offlineConfig.onComplete(result);
return result;
}
/**
* zip
*/
private async zipRawFiles(files: Record<string, Uint8Array>): Promise<Uint8Array> {
const jszip = new JSZip();
for (const [name, data] of Object.entries(files)) {
jszip.file(name, data);
}
return await jszip.generateAsync({ type: "uint8array", compression: "DEFLATE" });
}
/**
* 线.astral
*/
private normalizeOfflinePackageName(name: string): string {
const baseName = name.split(/[\\/]/).pop() || name;
const cleanName = baseName.split(/[?#]/)[0];
const lower = cleanName.toLowerCase();
if (lower.endsWith(".zip")) {
return cleanName.slice(0, -4) + ".astral";
}
return cleanName + ".astral";
}
/**
* 线
*/
private getOfflinePackageBaseName(name: string): string {
const baseName = name.split(/[\\/]/).pop() || name;
const cleanName = baseName.split(/[?#]/)[0];
if (cleanName.toLowerCase().endsWith(".astral")) {
return cleanName.slice(0, -7);
}
if (cleanName.toLowerCase().endsWith(".zip")) {
return cleanName.slice(0, -4);
}
return cleanName;
}
/**
* UUID"xxx (1).astral"
*/
// @ts-ignore
private extractUuidFromName(name: string): string | null {
const match = name.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
return match ? match[0] : null;
}
} }