diff --git a/packages/editor/src/components/header/navigation/ImportExport.vue b/packages/editor/src/components/header/navigation/ImportExport.vue index ff1a9f9..06130ec 100644 --- a/packages/editor/src/components/header/navigation/ImportExport.vue +++ b/packages/editor/src/components/header/navigation/ImportExport.vue @@ -3,7 +3,7 @@ {{ t("layout.header.Export") }} @@ -13,7 +13,7 @@ {{ t("layout.header.Import") }} @@ -21,135 +21,283 @@ \ No newline at end of file diff --git a/packages/editor/src/language/zh-CN-en-US.ts b/packages/editor/src/language/zh-CN-en-US.ts index 2e36d0b..7066b24 100644 --- a/packages/editor/src/language/zh-CN-en-US.ts +++ b/packages/editor/src/language/zh-CN-en-US.ts @@ -77,6 +77,9 @@ export default { Export: '导出', 'Export Object': '导出物体', 'Export Scene': '导出场景', + 'Offline package': '离线包', + 'Single packet': '单包', + 'Multiple packet': '多包', 'PLY (Binary)': 'PLY(二进制)', 'STL (Binary)': 'STL(二进制)', /* File 下的选项 End */ diff --git a/packages/sdk/lib/core/loader/Package.ts b/packages/sdk/lib/core/loader/Package.ts index 1a32164..01e942b 100644 --- a/packages/sdk/lib/core/loader/Package.ts +++ b/packages/sdk/lib/core/loader/Package.ts @@ -7,6 +7,7 @@ */ import { Mesh, Group, Bone } from "three"; import JSZip from "jszip"; +import { strToU8 } from 'three/examples/jsm/libs/fflate.module.js'; import { BASE64_TYPES, TYPED_ARRAYS } from "@/constant"; import { unzip, zip, fetchController } from "@/utils"; import { PackageSkeleton } from "@/core/loader/Package.Skeleton"; @@ -16,18 +17,70 @@ import App from "@/core/app/App"; import type Viewer from "@/core/viewer/Viewer"; interface IPackConfig { - name: string; // 首包名称 - layer?: number; // 拆分的最深层级 0:拆分至最深层 - zipUploadFun: (zip: File) => Promise; // 压缩包上传接口函数,多压缩包 - onProgress?: (progress: number) => void; // 打包进度回调 - onComplete?: (_: { firstUploadResult: any, totalSize: number, totalZipNumber: number }) => void; // 打包完成回调 + // 首包名称 + name: string; + // 拆分的最深层级 0:拆分至最深层 + layer?: number; + // 压缩包上传接口函数,多压缩包 + zipUploadFun: (zip: File) => Promise; + // 原始包上传接口函数(不压缩) + rawUploadFun?: (raw: { name: string; files: Record }) => Promise; + // 压缩进度回调(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 为单 zip,multiple 为多 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 { - url: string, // 首包url - onSceneLoad?: (sceneJson: ISceneJson, configJson: IAppProject.Config) => void, // 场景首包加载完成回调 - onProgress?: (progress: number) => void; // 场景加载进度回调 - onComplete?: () => void // 场景加载完成回调. + // 首包url + url: string; + // 场景首包加载完成回调 + onSceneLoad?: (sceneJson: ISceneJson, configJson: IAppProject.Config) => void; + // 场景加载进度回调 + onProgress?: (progress: number) => void; + // 场景加载完成回调 + onComplete?: () => void; + // 离线多包文件列表(可选) + offlineFiles?: File[]; + // 离线单包(内部包含多个包) + offlineBundle?: Blob | ArrayBuffer | Uint8Array; + // 离线首包名称(可选,未提供则使用 url 的文件名) + offlineEntry?: string; } interface SourceData { @@ -71,6 +124,19 @@ export class Package { private callFunNum: { value: number; }; private skeletonClass: PackageSkeleton; + // 离线包相关属性 + // @ts-ignore + private offlineZipMap: Map | null = null; + // @ts-ignore + private offlineEntryZip: string | null = null; + // @ts-ignore + private offlineFlatPackages: Map> | null = null; + // @ts-ignore + private offlineFlatEntryBase: string | null = null; + private readonly offlinePackagesPrefix = "Packages/"; + // @ts-ignore + private isOfflineImportContext = false; + constructor(viewer:Viewer) { this.viewer = viewer; @@ -1199,4 +1265,140 @@ export class Package { // 5. 释放 viewer 引用(注意:不销毁 viewer,仅移除引用) 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 }> = []; + + 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 = {}; + + 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): Promise { + 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; + } } \ No newline at end of file