TkAstral3D/packages/sdk/lib/core/loader/Package.ts
2026-04-09 00:20:33 +08:00

1810 lines
69 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @file Package.ts
* @description 场景打包解包
* @create 2024-07-31
* @update 2025-02-14
* @version 5.0.0
*/
import { Mesh, Group, Bone, Object3D, Texture } 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";
import { useDispatchSignal } from "@/hooks";
import { ObjectLoader } from './ObjectLoader';
import App from "@/core/app/App";
import type Viewer from "@/core/viewer/Viewer";
interface IPackConfig {
// 首包名称
name: string;
// 拆分的最深层级 0:拆分至最深层
layer?: number;
// 压缩包上传接口函数,多压缩包
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 {
// 首包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 {
name: string;
json?: string | ArrayBuffer
texture?: string | ArrayBuffer;
geometry?: string;
drawing?: string;
}
interface GroupJson {
images: any[];
geometries: any[];
object: {
children: any[],
//groupChildren: string[]
};
}
export class Package {
protected viewer:Viewer;
// 控制fetch并发
static _fetch = fetchController(10, false);
private totalSize: number = 0; // 总包体大小
private geometryArr: any[];
private imagesArr: any[];
private materialsArr: any[];
private textureArr: any[];
private skeletonsArr: any[];
// 解压时 对应文件夹前缀url
private prefix_url: string;
private loader: ObjectLoader;
private geometryMap: Map<string, any>;
private imagesMap: Map<string, any>;
private materialsMap: Map<string, any>;
private textureMap: Map<string, any>;
private callFunNum: { value: number; };
private skeletonClass: PackageSkeleton;
private dataComponentMap: Record<string, any> | null = null;
// 离线包相关属性
// @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) {
this.viewer = viewer;
// 存储已参与打包过的geometry uuid
this.geometryArr = [];
// 存储已参与打包过的images uuid
this.imagesArr = [];
// 存储已参与打包过的materials uuid
this.materialsArr = [];
// 存储已参与打包过的texture uuid
this.textureArr = [];
// 存储已参与打包过的skeleton uuid
this.skeletonsArr = [];
/** 下面都是解包用 */
this.prefix_url = "";
this.loader = new ObjectLoader();
this.geometryMap = new Map<string, any>();
this.imagesMap = new Map<string, any>();
this.materialsMap = new Map<string, any>();
this.textureMap = new Map<string, any>();
this.callFunNum = { value: 0 };
this.skeletonClass = new PackageSkeleton();
}
/* -------------------------------------------- 切片打包 --------------------------------------------------- */
/**
* 处理 geometry json
* @param arr 几何数据数组
*/
private handleGeometryJson(arr) {
function handler(value) {
if (value instanceof Array) {
return zip(value, false);
}
return value;
}
return arr.map((geometry) => {
if (geometry.data) {
const data = geometry.data;
if (data.attributes) {
for (let key in data.attributes) {
data.attributes[key].array = handler(data.attributes[key].array);
}
}
if (data.index) {
data.index.array = handler(data.index.array);
}
}
return geometry;
})
}
/**
* 处理 image json
* @param imageJson
* @param zipData 存储待压缩数据
* @returns {string} 返回贴图存储文件名称
*/
handleImage(imageJson:ITHREEScene.ImageJSON, zipData:SourceData[]): string {
if (imageJson.url && typeof imageJson.url === "object" && !imageJson.url.type) {
const ktx2Data = (imageJson.url as any).ktx2OriginalData;
if (ktx2Data) {
const name = imageJson.uuid + `.ktx2`;
zipData.push({ name, texture: ktx2Data });
return name;
}
}
if (typeof imageJson.url === "string") {
const name = imageJson.uuid + `.${BASE64_TYPES[imageJson.url.split(",")[0]]}`;
zipData.push({ name, texture: imageJson.url });
return name;
}
// 20250707:three的toJSON方法暂不支持KTX2纹理会返回{url:{},uuid:"xxxxx"}
if (!imageJson.url.type) {
const name = imageJson.uuid + `.ktx2`;
zipData.push({ name, texture: JSON.stringify(imageJson.url) });
return name;
}
const name = `${imageJson.url.type}!${imageJson.url.width}!${imageJson.url.height}!${imageJson.uuid}.env`;
const buffer = new TYPED_ARRAYS[imageJson.url.type](imageJson.url.data);
zipData.push({ name, texture: buffer.buffer });
return name;
}
private findTextureByImageUuid(mesh: Mesh, imageUuid: string): Texture | null {
if (!mesh.material) return null;
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
for (const material of materials) {
for (const key in material) {
const value = (material as any)[key];
if (value && value.isTexture && value.source?.uuid === imageUuid) {
return value as Texture;
}
}
}
return null;
}
/**
* 处理 mesh json
* @param mesh
* @param json group json
* @param zipData 存储待压缩数据
*/
handleMesh(mesh: Mesh, json:ITHREEScene.SceneJSON, zipData:SourceData[]) {
const meshJson: any = mesh.toJSON() as unknown as ITHREEScene.MeshJSON;
// 处理几何数据
if (meshJson.geometries) {
const geometries: any = [];
meshJson.geometries.forEach((geometry) => {
if (this.geometryArr.indexOf(geometry.uuid) === -1) {
this.geometryArr.push(geometry.uuid);
geometries.push(geometry);
}
})
!json.geometries && (json.geometries = []);
json.geometries.push(...this.handleGeometryJson(geometries));
}
// 处理贴图image
if (meshJson.images) {
meshJson.images.forEach((image) => {
if (this.imagesArr.indexOf(image.uuid) === -1) {
this.imagesArr.push(image.uuid);
!json.images && (json.images = []);
const texture = this.findTextureByImageUuid(mesh, image.uuid);
const textureMeta = (texture as any)?.metadata;
if (textureMeta?.isKTX2 && textureMeta?.ktx2OriginalData && typeof image.url === "object") {
(image.url as any).ktx2OriginalData = textureMeta.ktx2OriginalData;
}
const name = this.handleImage(image, zipData)
if(name){
json.images.push(name);
}
}
})
}
// 处理贴图texture
if (meshJson.textures) {
meshJson.textures.forEach((texture) => {
if (this.textureArr.indexOf(texture.uuid) === -1) {
this.textureArr.push(texture.uuid);
!json.textures && (json.textures = []);
json.textures.push(texture);
}
})
}
// 处理材质material
if (meshJson.materials) {
meshJson.materials.forEach((material) => {
if (this.materialsArr.indexOf(material.uuid) === -1) {
this.materialsArr.push(material.uuid);
!json.materials && (json.materials = []);
json.materials.push(material);
}
})
}
// 处理骨骼动画
if (meshJson.skeletons) {
meshJson.skeletons.forEach((skeleton) => {
if (this.skeletonsArr.indexOf(skeleton.uuid) === -1) {
this.skeletonsArr.push(skeleton.uuid);
!json.skeletons && (json.skeletons = []);
json.skeletons.push(skeleton);
}
})
}
// 处理动画
if (meshJson.animations) {
!json.animations && (json.animations = []);
json.animations.push(...meshJson.animations);
}
// object 字段存入group json(parent json)
if (meshJson.object) {
!json.object.children && (json.object.children = []);
json.object.children.push(meshJson.object);
}
}
private sanitizeDataComponentList(list: any) {
if (!Array.isArray(list) || list.length === 0) return null;
return list.map((entry) => {
if (!entry || typeof entry !== "object") return entry;
const config = entry.config && typeof entry.config === "object" ? { ...entry.config } : entry.config;
return { ...entry, config, data: null };
});
}
private collectDataComponentMap(root: Object3D) {
const map: Record<string, any> = {};
const condition = (obj: any) => !obj.ignore;
const visit = (obj: any) => {
const sanitized = this.sanitizeDataComponentList(obj?.dataComponent);
if (sanitized) {
map[obj.uuid] = sanitized;
}
};
if (typeof (root as any).traverseByCondition === "function") {
(root as any).traverseByCondition(visit, condition);
} else {
root.traverse((obj: any) => {
if (!condition(obj)) return;
visit(obj);
});
}
return map;
}
private applyDataComponentMap(root: Object3D) {
const map = this.dataComponentMap;
if (!map || Object.keys(map).length === 0) return;
const condition = (obj: any) => !obj.ignore;
const apply = (obj: any) => {
if (!Object.prototype.hasOwnProperty.call(map, obj.uuid)) return;
const sanitized = this.sanitizeDataComponentList(map[obj.uuid]);
if (sanitized) {
obj.dataComponent = sanitized;
}
};
if (typeof (root as any).traverseByCondition === "function") {
(root as any).traverseByCondition(apply, condition);
} else {
root.traverse((obj: any) => {
if (!condition(obj)) return;
apply(obj);
});
}
}
/**
* 按 group 分组各打包为1个zip文件
* @param {IPackConfig} packConfig
* @remarks 首包保存scene基本信息 和 图纸信息 及 基础配置
* @remarks 前面已打包的几何数据和材质贴图不会再次打包
*/
async pack(packConfig: IPackConfig) {
packConfig.layer = packConfig.layer || 0;
this.totalSize = 0;
// 首包保存scene基本信息,不clone子级
const newScene = this.viewer.scene.clone(false);
newScene.children = [];
const sceneJson = newScene.toJSON() as unknown as ITHREEScene.SceneJSON;
// scene uuid需要和原来一致防止绑定在scene的脚本无法还原
sceneJson.object.uuid = this.viewer.scene.uuid;
sceneJson.object.children = [];
// 20250718: 环境类型是ModelViewer时需要特殊处理因为scene.toJSON()不会处理renderTargetTexture
if(newScene.environment && newScene.environment.isRenderTargetTexture){
sceneJson.object.environmentType = "ModelViewer";
}
const sceneZipData: SourceData[] = [];
// 处理背景和环境贴图
if (!sceneJson.images) sceneJson.images = [];
sceneJson.images = sceneJson.images.map((image) => this.handleImage(image as ITHREEScene.ImageJSON, sceneZipData));
// 保存场景中需打包的group数组
let groupArr: Group[] = [];
// 处理 scene 子级
this.viewer.scene.children.forEach((child) => {
if (child.ignore) return;
// 3DTiles 处理
if (child.isTilesGroup || child.type === "TilesGroup") {
const targetGroup = child.clone(false);
const tilesJson = targetGroup.toJSON().object;
sceneJson.object.children?.push(tilesJson);
return;
}
if (child.type === "Group" || child.children?.length > 0) {
sceneJson.object.children?.push(child.uuid);
child.groupLayer = 1;
groupArr.push(child as Group);
child.traverseByCondition((c) => {
// 不递归自身
if (c.uuid === child.uuid) return;
// 3DTiles 此处不处理
if (c.isTilesGroup || c.type === "TilesGroup") return;
if (c.type === "Group" || c.children?.length > 0) {
c.groupLayer = c.parent.groupLayer + 1;
if (c.groupLayer <= <number>packConfig.layer || packConfig.layer === 0) {
groupArr.push(c);
}
}
}, (c) => !c.ignore)
} else {
this.handleMesh(<Mesh>child, sceneJson, sceneZipData);
}
})
// 将所有几何数据取出 单独存储
if (sceneJson.geometries) {
// 为避免数据量过大超过V8引擎对于字符串2^32的限制分为多个切片10个几何数据为一组json
const transferNum = Math.ceil(sceneJson.geometries.length / 10);
for (let i = 0; i < transferNum; i++) {
const name = `geometries_${i}.json`;
const geometry = JSON.stringify(sceneJson.geometries.slice(i * 10, (i + 1) * 10));
sceneZipData.push({ name, geometry });
}
sceneJson.geometries = [];
}
const drawingInfo = App.project.getKey("drawing");
const sceneInfo = Object.assign(App.project.getKey("sceneInfo"),{
// 覆盖原zip包位置
zip:"",
hasDrawing: drawingInfo.isUploaded ? 1 : 0,
});
// 图纸
if (drawingInfo.isUploaded) {
// 图片
sceneZipData.push({
name: sceneInfo.id + `.${BASE64_TYPES[drawingInfo.imgSrc.split(",")[0]]}`,
drawing: drawingInfo.imgSrc
});
// 标记
sceneZipData.push({ name: "drawingMark.txt", drawing: zip(drawingInfo.markList) });
// 图片信息(宽高信息等,以便于其他地方使用可计算标记左上距离百分比)
sceneZipData.push({ name: "drawingImgInfo.json", drawing: JSON.stringify(drawingInfo.imgInfo) });
console.log(sceneZipData,drawingInfo)
}
const dataComponents = this.collectDataComponentMap(this.viewer.scene);
if (Object.keys(dataComponents).length > 0) {
sceneZipData.push({
name: "data_components.json",
json: JSON.stringify(dataComponents)
});
}
// 项目配置
sceneZipData.push({
name: "config.json",
json: JSON.stringify({
// 项目运行是否启用xr
xr: App.project.getKey("xr"),
// 项目渲染器配置
renderer: App.project.getKey("renderer"),
// BVH 配置
bvh: App.project.getKey("bvh"),
// 项目级联阴影映射
csm: App.project.getKey("csm"),
// 项目后处理配置
effect: App.project.getKey("effect"),
// 项目天气配置
weather: App.project.getKey('weather')
})
});
const totalNum = groupArr.length + 1;
sceneZipData.push({
name: "scene.json",
json: JSON.stringify({
// 解包时需要还原的编辑器场景信息
metadata: App.metadata,
camera: this.viewer.camera.toJSON(),
scene: sceneJson,
scripts: App.scripts,
controls: {
state: this.viewer.modules.controls.toJSON(),
},
totalZipNumber: totalNum,
sceneInfo: sceneInfo,
})
});
// 首包上传
const firstUploadResult = await this.zip(sceneZipData, packConfig.name, packConfig.zipUploadFun, packConfig.rawUploadFun);
// 进度
let progress = 0;
packConfig.onProgress && packConfig.onProgress(parseFloat((progress / groupArr.length * 100).toFixed(2)));
// 遍历打包group并上传
for (const group of groupArr) {
// clone(false) 不克隆子元素
const g = group.clone(false);
g.children = [];
// 空 group
let json: any = g.toJSON() as unknown as ITHREEScene.SceneJSON;
json.geometries = [];
json.images = [];
json.textures = [];
json.materials = [];
json.object.uuid = group.uuid;
json.object.children = [];
// 存储待压缩数据
const zipData: SourceData[] = [];
group.children.forEach((child) => {
// 被groupArr包含的子级后面会单独处理此处仅引用其uuid
if (groupArr.find(item => item.uuid === child.uuid)) {
json.object.children?.push(child.uuid);
return;
}
// 3DTiles 处理
if (child.isTilesGroup || child.type === "TilesGroup") {
const targetGroup = child.clone(false);
const tilesJson = targetGroup.toJSON().object;
json.object.children?.push(tilesJson);
return;
}
this.handleMesh(<Mesh>child, json, zipData);
})
// 将所有几何数据取出 单独存储
if (json.geometries) {
// 为避免数据量过大超过V8引擎对于字符串2^32的限制分为多个切片10个几何数据为一组json
const transferNum = Math.ceil(json.geometries.length / 10);
for (let i = 0; i < transferNum; i++) {
const name = `geometries_${i}.json`;
const geometry = JSON.stringify(json.geometries.slice(i * 10, (i + 1) * 10));
zipData.push({ name, geometry });
}
json.geometries = [];
}
// json 打包
// 还原uuid
json.object.uuid = group.uuid;
const name = `${group.uuid}.json`;
const content = JSON.stringify(json);
zipData.push({ name, json: content });
await this.zip(zipData, group.uuid, packConfig.zipUploadFun, packConfig.rawUploadFun);
progress++;
packConfig.onProgress && packConfig.onProgress(parseFloat((progress / groupArr.length * 100).toFixed(2)));
}
// reset
groupArr = [];
this.geometryArr = [];
this.imagesArr = [];
this.materialsArr = [];
this.textureArr = [];
packConfig.onComplete && packConfig.onComplete({ firstUploadResult, totalSize: this.totalSize, totalZipNumber: totalNum });
return { firstUploadResult, totalSize: this.totalSize, totalZipNumber: totalNum };
}
/**
* zip 打包
* @param sourceData 待打包数据
* @param {string | number} zipName 打包文件名
* @return {Promise<any>} 返回包上传接口结果
*/
private toUint8(data: string | ArrayBuffer | Uint8Array): Uint8Array {
if (data instanceof Uint8Array) return data;
if (typeof data === "string") return strToU8(data);
return new Uint8Array(data);
}
private buildFilesMapFromSourceData(sourceData: SourceData[]): Record<string, Uint8Array> {
const files: Record<string, Uint8Array> = {};
sourceData.forEach((item) => {
if (item.texture) {
files[`Textures/${item.name}`] = this.toUint8(item.texture);
} else if (item.geometry) {
files[`Geometries/${item.name}`] = this.toUint8(item.geometry);
} else if (item.json) {
files[item.name] = this.toUint8(item.json);
} else if (item.drawing) {
files[`Drawing/${item.name}`] = this.toUint8(item.drawing);
}
})
return files;
}
private async zip(
sourceData: SourceData[],
zipName: string | number,
zipUploadFun: (zip: File) => Promise<any>,
rawUploadFun?: (raw: { name: string; files: Record<string, Uint8Array> }) => Promise<any>
): Promise<any> {
if (rawUploadFun) {
const files = this.buildFilesMapFromSourceData(sourceData);
const size = Object.values(files).reduce((sum, data) => sum + data.byteLength, 0);
this.totalSize += size;
return await rawUploadFun({ name: String(zipName), files });
}
const jszip = new JSZip();
const imgFolder = jszip.folder("Textures") as JSZip; // 贴图文件夹
const geometriesFolder = jszip.folder("Geometries") as JSZip; // 几何数据文件夹
let drawingFolder = jszip.folder("Drawing") as JSZip; // 图纸文件夹
sourceData.forEach((item) => {
if (item.texture) {
imgFolder.file(item.name, item.texture, {
compression: "DEFLATE",//"STORE",//"DEFLATE
compressionOptions: {
level: 7
}
});
} else if (item.geometry) {
geometriesFolder.file(item.name, item.geometry, {
compression: "DEFLATE",//"STORE",//"DEFLATE
compressionOptions: {
level: 7
}
});
} else if (item.json) {
jszip.file(item.name, item.json, {
compression: "DEFLATE",//"STORE",//"DEFLATE
compressionOptions: {
level: 7
}
});
} else if (item.drawing) {
drawingFolder.file(item.name, item.drawing, {
compression: "DEFLATE",//"STORE",//"DEFLATE
compressionOptions: {
level: 9
}
});
}
})
const content = await jszip.generateAsync({ type: 'blob' });
const zipFile = new File([content], `${zipName}.zip`, { type: "application/zip" });
this.totalSize += zipFile.size;
// 上传zip包
return await zipUploadFun(zipFile);
}
/* -------------------------------------------- 解包 --------------------------------------------------- */
/**
* 还原几何数据
* @param arr
* @private
*/
private unGzipGeometryJson(arr) {
// 几何数据array 还原
function handler(value) {
if (typeof value === "string") {
return unzip(value, false);
}
return value;
}
return arr.map((geometry) => {
if (geometry.data) {
const data = geometry.data;
if (data.attributes) {
for (let key in data.attributes) {
data.attributes[key].array = handler(data.attributes[key].array);
}
}
if (data.index) {
data.index.array = handler(data.index.array);
}
}
return geometry;
})
}
/**
* 还原贴图
* @param imageName
* @param data
*/
private unGzipImage(imageName: string, data) {
const nameSplit = imageName.split(".");
if (nameSplit[1] === "env") {
const urlSplit = nameSplit[0].split("!");
this.imagesMap.set(urlSplit[3], {
uuid: urlSplit[3],
url: {
type: urlSplit[0],
width: parseInt(urlSplit[1]),
height: parseInt(urlSplit[2]),
/**
* sceneJson打zip包前原数据为Array,此处解压后我们使用ArrayBuffer不还原为Array
* 还原为Array这样写 Array.from(new TYPED_ARRAYS[urlSplit[0]](textureMap.get(urlSplit[3] + ".env")))
**/
data: data
}
});
} else {
this.imagesMap.set(nameSplit[0], {
uuid: nameSplit[0],
url: data
});
}
}
/**
* 记录materials、texture、geometry已加载的uuid
* @param object3D 模型json
*/
private recordUuid(object3D) {
if (object3D.geometries) {
object3D.geometries.forEach((geometry) => {
this.geometryMap.set(geometry.uuid, geometry);
})
}
if (object3D.materials) {
object3D.materials.forEach((material) => {
this.materialsMap.set(material.uuid, material);
})
}
if (object3D.textures) {
object3D.textures.forEach((texture) => {
this.textureMap.set(texture.uuid, texture);
})
}
}
private decodeUint8Text(data: Uint8Array): string {
return new TextDecoder().decode(data);
}
private parseOfflineManifest(unzipped: Record<string, Uint8Array>): any | null {
const manifestData = unzipped["offline.json"];
if (!manifestData) return null;
try {
return JSON.parse(this.decodeUint8Text(manifestData));
} catch {
return null;
}
}
private buildOfflineFlatPackages(unzipped: Record<string, Uint8Array>, packagesPrefix: string): Map<string, Record<string, Uint8Array>> {
const prefix = packagesPrefix.endsWith("/") ? packagesPrefix : `${packagesPrefix}/`;
const map = new Map<string, Record<string, Uint8Array>>();
for (const [name, data] of Object.entries(unzipped)) {
if (name === "offline.json") continue;
if (!name.startsWith(prefix)) continue;
const rest = name.slice(prefix.length);
const splitIndex = rest.indexOf("/");
if (splitIndex <= 0) continue;
const baseName = rest.slice(0, splitIndex);
const innerPath = rest.slice(splitIndex + 1);
if (!innerPath) continue;
let pkg = map.get(baseName);
if (!pkg) {
pkg = {};
map.set(baseName, pkg);
}
pkg[innerPath] = data;
}
return map;
}
private async unzipJsZipToMap(zipInput: Blob | ArrayBuffer | Uint8Array): Promise<Record<string, Uint8Array>> {
const jszip = new JSZip();
const zipRes = await jszip.loadAsync(zipInput as any);
const out: Record<string, Uint8Array> = {};
for (const key in zipRes.files) {
if (zipRes.files[key].dir) continue;
const fileName = zipRes.files[key].name;
const content = await zipRes.file(fileName)?.async("uint8array");
if (content) out[fileName] = content;
}
return out;
}
private async buildOfflinePackageMapFromFiles(files: File[]): Promise<Map<string, Uint8Array>> {
const map = new Map<string, Uint8Array>();
for (const file of files) {
const buffer = new Uint8Array(await file.arrayBuffer());
const normalized = this.normalizeOfflinePackageName(file.name);
map.set(normalized, buffer);
const uuid = this.extractUuidFromName(file.name);
if (uuid) {
const canonical = this.normalizeOfflinePackageName(uuid.toLowerCase());
if (!map.has(canonical)) {
map.set(canonical, buffer);
}
}
}
return map;
}
private async initOfflineSource(unpackConfig: IUnpackConfig): Promise<void> {
this.isOfflineImportContext = Boolean(unpackConfig.offlineBundle || (unpackConfig.offlineFiles && unpackConfig.offlineFiles.length > 0));
this.offlineZipMap = null;
this.offlineEntryZip = null;
this.offlineFlatPackages = null;
this.offlineFlatEntryBase = null;
const setupFlat = (unzipped: Record<string, Uint8Array>, manifest?: any, fallbackEntry?: string) => {
const packagesPrefix = manifest?.packagesPrefix || this.offlinePackagesPrefix;
this.offlineFlatPackages = this.buildOfflineFlatPackages(unzipped, packagesPrefix);
const entryName = manifest?.entry
? this.normalizeOfflinePackageName(String(manifest.entry))
: (fallbackEntry ? this.normalizeOfflinePackageName(fallbackEntry) : undefined);
const entryBase = manifest?.entryBase || (entryName ? this.getOfflinePackageBaseName(entryName) : undefined);
if (entryBase) this.offlineFlatEntryBase = entryBase;
if (entryName) this.offlineEntryZip = entryName;
};
if (unpackConfig.offlineBundle) {
let u8: Uint8Array;
if (unpackConfig.offlineBundle instanceof Uint8Array) {
u8 = unpackConfig.offlineBundle;
} else if (unpackConfig.offlineBundle instanceof ArrayBuffer) {
u8 = new Uint8Array(unpackConfig.offlineBundle);
} else {
u8 = new Uint8Array(await unpackConfig.offlineBundle.arrayBuffer());
}
const unzipped = await this.unzipJsZipToMap(u8);
const manifest = this.parseOfflineManifest(unzipped);
if (manifest?.mode === "single") {
setupFlat(unzipped, manifest);
return;
}
const map = new Map<string, Uint8Array>();
for (const [name, data] of Object.entries(unzipped)) {
if (name === "offline.json") continue;
if (name.toLowerCase().endsWith(".astral")) {
map.set(this.normalizeOfflinePackageName(name), data);
}
}
this.offlineZipMap = map;
if (manifest?.entry) {
this.offlineEntryZip = this.normalizeOfflinePackageName(String(manifest.entry));
} else if (unpackConfig.offlineEntry) {
this.offlineEntryZip = this.normalizeOfflinePackageName(unpackConfig.offlineEntry);
}
} else if (unpackConfig.offlineFiles && unpackConfig.offlineFiles.length > 0) {
if (unpackConfig.offlineFiles.length === 1) {
const file = unpackConfig.offlineFiles[0];
try {
const unzipped = await this.unzipJsZipToMap(await file.arrayBuffer());
const manifest = this.parseOfflineManifest(unzipped);
if (manifest?.mode === "single") {
setupFlat(unzipped, manifest, file.name);
return;
}
} catch (err) {
// 兼容直接传入 .astral 分包文件的场景,忽略单包探测失败
}
}
this.offlineZipMap = await this.buildOfflinePackageMapFromFiles(unpackConfig.offlineFiles);
if (unpackConfig.offlineEntry) {
this.offlineEntryZip = this.normalizeOfflinePackageName(unpackConfig.offlineEntry);
}
}
if (this.offlineZipMap && !this.offlineEntryZip) {
this.offlineEntryZip = this.normalizeOfflinePackageName(unpackConfig.url);
}
}
/**
* 从首包开始解包
* @param {IUnpackConfig} unpackConfig
*/
public async unpack(unpackConfig: IUnpackConfig) {
unpackConfig.onProgress && unpackConfig.onProgress(0);
let totalZipNumber = 0, progress = 0;
await this.initOfflineSource(unpackConfig);
if (!this.offlineZipMap && !this.offlineFlatPackages) {
const match = unpackConfig.url.match( /(.*[\\/])?([a-zA-Z0-9]+-V\d+)(?=[\\/]|$)/);
this.prefix_url = this.viewer.options.request?.baseUrl + (match ? match[0] : unpackConfig.url.substring(0, unpackConfig.url.lastIndexOf("/")));
} else {
this.prefix_url = "";
}
// indexDb存储
// const db = window.VIEWPORT.modules["db"];
//const dbKey = `${useProjectState.getState().sceneId}-${useProjectState.getState().version.id}`;
const that = this;
this.callFunNum = new Proxy({ value: 0 }, {
set(target, p, value) {
if (target[p] < value) {
progress += (value - target[p]) / totalZipNumber * 100;
unpackConfig.onProgress && unpackConfig.onProgress(progress);
}
target[p] = value;
if (value <= 0) {
const done = () => {
// 重置清除map
that.geometryMap.clear();
that.imagesMap.clear();
that.materialsMap.clear();
that.textureMap.clear();
that.offlineZipMap = null;
that.offlineEntryZip = null;
that.offlineFlatPackages = null;
that.offlineFlatEntryBase = null;
that.dataComponentMap = null;
// @ts-ignore 清除loader
that.loader = undefined;
}
const complete = () => {
done();
// 场景内容加载完毕后注入脚本执行逻辑
that.viewer.installScripts();
that.viewer.dispatchEvent({type:"loaded"})
that.skeletonClass.clear();
// 关闭IndexDB 否则新的标签页无法正常打开
// db.close();
unpackConfig.onComplete && unpackConfig.onComplete();
}
complete();
}
return true;
}
});
// map 存储 json 解析完成后执行的 function; key 为 uuid
const funcMap = new Map<string, Function>();
const loadScene = (sceneJson: ISceneJson,drawingInfo:IDrawingInfo | null, configJson: IAppProject.Config) => {
App.fromJSON(sceneJson).then(async (scene) => {
// 还原控制器
if (sceneJson.controls?.state) {
this.viewer.modules.controls.fromJSON(sceneJson.controls.state, true);
}
// 管理3DTiles
scene.traverseByCondition((c) => {
if (c.isTilesGroup || c.type === "TilesGroup") {
this.viewer.addTiles(c,"none");
}
}, (c) => !c.ignore)
if (drawingInfo) {
const projectDrawing = App.project.getKey("drawing");
projectDrawing.isCad = drawingInfo.imgSrc.split(".").pop() === "dxf";
projectDrawing.imgSrc = drawingInfo.imgSrc;
projectDrawing.markList = drawingInfo.markList;
projectDrawing.imgInfo = drawingInfo.imgInfo;
projectDrawing.isUploaded = true;
}
// 还原项目配置
if (configJson) {
App.project.setKey('xr',configJson.xr || false);
if (configJson.renderer) {
App.project.setKey("renderer",configJson.renderer);
// fps需要通过 App.FPS 进行set才能正确计算单帧渲染时长
App.FPS = Number(configJson.renderer.fps);
}
const bvhConfig = (configJson as any)?.bvh;
if (bvhConfig) {
App.project.setKey("bvh", bvhConfig);
}
if(configJson.csm){
const projectCSM = App.project.getKey("csm");
let _csmNotChange = true;
Object.keys(configJson.csm).forEach(csmKey => {
if (projectCSM[csmKey] !== configJson.csm[csmKey]){
projectCSM[csmKey] = configJson.csm[csmKey];
_csmNotChange = false;
}
})
if (!_csmNotChange){
App.csm.enabled = projectCSM.enabled;
}
}
if(configJson.effect){
Object.keys(configJson.effect).forEach((key) => {
App.project.setKey(`effect.${key}`,configJson.effect[key]);
});
}
if (configJson.weather) {
const projectWeather = App.project.getKey("weather");
if (configJson.weather.fog) {
Object.keys(configJson.weather.fog).forEach((key) => {
projectWeather.fog[key] = configJson.weather.fog[key];
});
useDispatchSignal("sceneFogSettingsChanged");
}
if (configJson.weather.rain) {
Object.keys(configJson.weather.rain).forEach((key) => {
projectWeather.rain[key] = configJson.weather.rain[key];
});
useDispatchSignal("sceneRainSettingsChanged");
}
if (configJson.weather.snow) {
Object.keys(configJson.weather.snow).forEach((key) => {
projectWeather.snow[key] = configJson.weather.snow[key];
});
useDispatchSignal("sceneSnowSettingsChanged");
}
}
}
// 添加indexDB表存储zip包
// await db.addStore(dbKey);
unpackConfig.onSceneLoad && unpackConfig.onSceneLoad(sceneJson, configJson);
this.applyDataComponentMap(App.scene as unknown as Object3D);
this.dataComponentMap = null;
// 防止项目只有一个包的情况造成不触发proxy set
if (this.callFunNum.value === 0) {
this.callFunNum.value = 0;
unpackConfig.onProgress && unpackConfig.onProgress(100);
}
// 开始执行funcMap中的function
funcMap.forEach((func, uuid) => {
func.call(this, uuid, scene, uuid);
})
})
}
const parseSceneZip = async (file: Blob | Uint8Array | ArrayBuffer) => {
unpackConfig.onProgress && unpackConfig.onProgress(1);
// @ts-ignore
let sceneJson: ISceneJson = undefined, configJson: IAppProject.Config = undefined;
let dataComponentMap: Record<string, any> | null = null;
// 开始解压首包
const zip = new JSZip();
// 几何数据数组
let geometries: Array<any> = [];
// 图纸信息
let drawingInfo:IDrawingInfo = {
imgSrc: "",
markList: [],
imgInfo: {
width: 0,
height: 0
}
}
const res = await zip.loadAsync(file as any);
/**
* res.files里包含整个zip里的文件描述、目录描述列表
* res本身就是JSZip的实例
*/
for (let key in res.files) {
//判断是否是目录
if (!res.files[key].dir) {
const fileName = res.files[key].name;
//找到我们压缩包所需要的json文件
if (fileName === "scene.json") { // 场景json
const content = await res.file(fileName)?.async('string') as string;
//得到scene.json文件的内容
sceneJson = JSON.parse(content);
} else if (fileName === "config.json") { // 项目配置json
const content = await res.file(fileName)?.async('string') as string;
configJson = JSON.parse(content);
} else if (fileName === "data_components.json") {
const content = await res.file(fileName)?.async('string') as string;
dataComponentMap = JSON.parse(content);
} else if (fileName.substring(0, 9) === "Textures/") {
/**
* 贴图
* 分为两种情况:
* 1.贴图为env格式type!width!height!uuid.env转换为arraybuffer格式存入map
* 2.贴图为普通图片格式直接存入map
**/
if (/\.env$/.test(fileName)) {
// 转换回贴图原始信息存入map
const content = await res.file(fileName)?.async('arraybuffer');
this.unGzipImage(fileName.replace("Textures/", ""), content);
} else if (/\.ktx2$/i.test(fileName)) {
const content = await res.file(fileName)?.async('arraybuffer') as ArrayBuffer;
const blob = new Blob([content], { type: "image/ktx2" });
const blobUrl = URL.createObjectURL(blob) + `#${fileName.replace("Textures/", "")}`;
this.unGzipImage(fileName.replace("Textures/", ""), blobUrl);
} else {
const content = await res.file(fileName)?.async('string')
this.unGzipImage(fileName.replace("Textures/", ""), content);
}
} else if (/^Geometries\/geometries_\d*\.json$/.test(fileName)) {
const content = await res.file(fileName)?.async('string') as string;
geometries.push(...this.unGzipGeometryJson(JSON.parse(content)));
} else if (/^Geometries\/geometries_\d*\.zip$/.test(fileName)) {
/** 此处为兼容整体打包的版本 **/
// geometry切片zip包内部是json文件
const content = await res.file(fileName)?.async('blob') as Blob;
const zip = new JSZip();
const zipRes = await zip.loadAsync(content);
for (let zipKey in zipRes.files) {
const content = await zip.file(zipRes.files[zipKey].name)?.async('string') as string;
geometries.push(...this.unGzipGeometryJson(JSON.parse(content)));
}
} else if (fileName.substring(0, 8) === "Drawing/") {
/**
* 图纸文件夹下的文件
* 1. drawingMarking.txt 为图纸标注文件,须解压
* 2. sceneId开头的图片是图纸
*/
if (res.files[key].name === "Drawing/drawingMark.txt") {
const content = await res.file(res.files[key].name)?.async('string') as string;
drawingInfo.markList = unzip(content)
} else if (res.files[key].name === "Drawing/drawingImgInfo.json") {
drawingInfo.imgInfo = JSON.parse(await res.file(res.files[key].name)?.async('string') as string);
} else {
drawingInfo.imgSrc = await res.file(res.files[key].name)?.async('string') as string;
}
}
}
}
totalZipNumber = sceneJson.totalZipNumber || 0;
this.dataComponentMap = dataComponentMap;
// 贴图还原至sceneJson
sceneJson.scene.images = sceneJson.scene.images.map((item) => {
const nameSplit = item.split(".");
if (nameSplit[1] === "env") {
const urlSplit = nameSplit[0].split("!");
return this.imagesMap.get(urlSplit[3])
} else {
return this.imagesMap.get(nameSplit[0])
}
});
// 几何数据还原至sceneJson
sceneJson.scene.geometries = geometries;
this.recordUuid(sceneJson.scene);
const newChildren: any = [];
// 遍历sceneJson.object.children,拉取group zip还原
sceneJson.scene.object.children?.forEach(objectJsonOruuid => {
if (typeof objectJsonOruuid === "string") {
// 保存uuid对应的function
funcMap.set(objectJsonOruuid, this.unpackGroup);
this.callFunNum.value++;
} else {
newChildren.push(objectJsonOruuid)
}
})
sceneJson.scene.object.children = newChildren;
// 图档信息
const _drawingInfo = drawingInfo.imgSrc ? drawingInfo : null;
loadScene(sceneJson,_drawingInfo, configJson);
//sceneJson.scene.groupChildren = [...funcMap.keys()];
// 解压处理好的数据添加至 indexDB -> Msy3D -> scene
// db.setItem(dbKey, sceneJson);
}
const networkGet = async () => {
if (this.offlineFlatPackages) {
const entryBase = this.offlineFlatEntryBase || this.getOfflinePackageBaseName(this.offlineEntryZip || unpackConfig.url);
const entry = this.offlineFlatPackages.get(entryBase);
if (!entry) {
console.error(`[Package] 离线包缺少首包内容: ${entryBase}`);
return;
}
const zipData = await this.zipRawFiles(entry);
await parseSceneZip(zipData);
return;
}
if (this.offlineZipMap) {
const entryName = this.offlineEntryZip || this.normalizeOfflinePackageName(unpackConfig.url);
const zipData = this.offlineZipMap.get(entryName);
if (!zipData) {
console.error(`[Package] 离线包缺少首包: ${entryName}`);
return;
}
await parseSceneZip(zipData);
return;
}
// 下载场景包
const file = await fetch(this.viewer.options.request?.baseUrl + unpackConfig.url).then(zipRes => zipRes.blob());
await parseSceneZip(file);
}
// db.getItem(dbKey).then((dbData) => {
// if (dbData === undefined) {
await networkGet();
// }else{
// this.recordUuid(dbData.scene);
//
// dbData.scene.images.forEach((image) => {
// this.imagesMap.set(image.uuid, image);
// })
//
// dbData.scene.groupChildren.forEach((uuid) => {
// // 保存uuid对应的function
// funcMap.set(uuid, this.unpackGroup);
//
// this.callFunNum.value++;
// })
//
// loadScene(dbData);
// }
// })
}
/**
* 异步解压group zip
* @param uuid
* @param parent
* @param rootGroupUuid
*/
private unpackGroup(uuid: string, parent, rootGroupUuid) {
//const db = window.VIEWPORT.modules["db"];
//const dbTable = `${useProjectState.getState().sceneId}-${useProjectState.getState().info.id}`;
// map 存储 json 解析完成后执行的 function; key 为 uuid
const funcMap = new Map<string, Function>();
const check = (object, group) => {
// 检查数据是否已完善
let isDone = true;
const useMaterials = new Set<string>();
object.children.forEach((child) => {
// 检查几何数据是否都已拥有
if (child.geometry && group.geometries?.findIndex((geometry) => geometry.uuid === child.geometry) === -1) {
if (!this.geometryMap.has(child.geometry)) {
isDone = false;
} else {
group.geometries.push(this.geometryMap.get(child.geometry));
}
}
// material->texture->image
if (child.material) {
const materialUUIDs = Array.isArray(child.material) ? child.material : [child.material];
materialUUIDs.forEach((materialUuid) => useMaterials.add(materialUuid));
}
if (child.children?.length > 0 && isDone) {
isDone = check(child, group);
}
})
if (!isDone) return isDone;
const useTextures = new Set<string>();
const textureFields = [
"map", "alphaMap", "aoMap", "anisotropyMap", "bumpMap", "clearcoatMap", "clearcoatNormalMap", "clearcoatRoughnessMap",
"displacementMap", "envMap", "emissiveMap", "iridescenceMap", "iridescenceThicknessMap", "lightMap", "sheenColorMap",
"sheenRoughnessMap", "specularColorMap", "specularIntensityMap", "specularMap", "thicknessMap", "transmissionMap",
"metalnessMap", "roughnessMap", "normalMap", "gradientMap"
];
useMaterials.forEach((materialUuid) => {
if (!isDone) return;
const material = this.materialsMap.get(materialUuid);
if (!material) {
isDone = false;
return;
}
if (group.materials?.findIndex((item) => item.uuid === materialUuid) === -1) {
group.materials.push(material);
}
textureFields.forEach((field) => {
const textureUuid = material[field];
if (textureUuid) useTextures.add(textureUuid);
});
});
if (!isDone) return isDone;
const useImages = new Set<string>();
useTextures.forEach((textureUuid) => {
if (!isDone) return;
const texture = this.textureMap.get(textureUuid);
if (!texture) {
isDone = false;
return;
}
if (group.textures?.findIndex((item) => item.uuid === textureUuid) === -1) {
group.textures.push(texture);
}
if (texture.image) useImages.add(texture.image);
});
if (!isDone) return isDone;
useImages.forEach((imageUuid) => {
if (!isDone) return;
if (!this.imagesMap.has(imageUuid)) {
isDone = false;
return;
}
if (group.images?.findIndex((item) => item.uuid === imageUuid) === -1) {
group.images.push(this.imagesMap.get(imageUuid));
}
});
return isDone;
}
const parse = (json) => {
if (check(json.object, json)) {
this.loader.parse(json, (group) => {
const bones: Bone[] = [];
group.getObjectsByProperty("type", "Bone", bones);
if (bones.length > 0) {
this.skeletonClass.addBones(bones);
}
// 如果存在Skeleton骨架须存下来后面替换回原骨骼。
// 因为loader.parse时如果对应骨骼Bone还未加载会生成新的空骨骼替代
if (json.skeletons) {
this.skeletonClass.handleSkeletons(json.skeletons, group);
}
group.uuid = uuid;
// requestIdleCallback(()=>{
App.addObject(group, parent);
// 管理3DTiles
group.traverseByCondition((c) => {
if (c.isTilesGroup || c.type === "TilesGroup") {
this.viewer.addTiles(c,"none");
}
}, (c) => !c.ignore)
this.callFunNum.value--;
// })
// 开始执行funcMap中的function
funcMap.forEach((func, uuid) => {
func.call(this, uuid, group, rootGroupUuid);
})
})
} else {
const timer = setTimeout(() => {
clearTimeout(timer);
parse(json);
}, 200)
}
}
const parseGroupZip = async (file: Blob | Uint8Array | ArrayBuffer) => {
const zip = new JSZip();
let json: GroupJson;
// 几何数据数组
let geometries: Array<any> = [];
const unzipDone = () => {
// 贴图还原至sceneJson
json.images = json.images.map((item) => {
const nameSplit = item.split(".");
if (nameSplit[1] === "env") {
const urlSplit = nameSplit[0].split("!");
return this.imagesMap.get(urlSplit[3])
} else {
return this.imagesMap.get(nameSplit[0])
}
});
// 几何数据还原至sceneJson
json.geometries = geometries;
this.recordUuid(json);
// 遍历children,拉取group zip还原
const children: any = [];
json.object.children.forEach((childUuid) => {
if (typeof childUuid === "string") {
// 保存uuid对应的function
funcMap.set(childUuid, this.unpackGroup);
this.callFunNum.value++;
} else {
children.push(childUuid)
}
})
json.object.children = children;
parse(json);
}
const res = await zip.loadAsync(file as any);
let num = new Proxy({ value: Object.keys(res.files).length }, {
set(target, p, value) {
target[p] = value;
if (value === 0) {
unzipDone();
}
return true;
}
})
for (let key in res.files) {
//判断是否是目录
if (!res.files[key].dir) {
const fileName = res.files[key].name;
//找到我们压缩包所需要的json文件
if (fileName === `${uuid}.json`) { // 场景json
res.file(fileName)?.async('string').then(content => {
json = JSON.parse(content);
num.value--;
})
} else if (fileName.substring(0, 9) === "Textures/") {
if (/\.env$/.test(fileName)) {
// 转换回贴图原始信息存入map
res.file(fileName)?.async('arraybuffer').then(content => {
this.unGzipImage(fileName.replace("Textures/", ""), content);
num.value--;
})
} else if (/\.ktx2$/i.test(fileName)) {
res.file(fileName)?.async('arraybuffer').then(content => {
const blob = new Blob([content], { type: "image/ktx2" });
const blobUrl = URL.createObjectURL(blob) + `#${fileName.replace("Textures/", "")}`;
this.unGzipImage(fileName.replace("Textures/", ""), blobUrl);
num.value--;
})
} else {
res.file(fileName)?.async('string').then(content => {
this.unGzipImage(fileName.replace("Textures/", ""), content);
num.value--;
})
}
} else if (/^Geometries\/geometries_\d*\.json$/.test(fileName)) {
res.file(fileName)?.async('string').then(content => {
geometries.push(...this.unGzipGeometryJson(JSON.parse(content)));
num.value--;
})
} else {
num.value--;
}
} else {
num.value--;
}
}
}
const getByNetwork = () => {
if (this.offlineFlatPackages) {
const entry = this.offlineFlatPackages.get(uuid);
if (!entry) {
console.error(`[Package] 离线包缺少分包内容: ${uuid}`);
this.callFunNum.value--;
return;
}
this.zipRawFiles(entry).then((zipData) => {
parseGroupZip(zipData);
});
return;
}
if (this.offlineZipMap) {
const fileName = this.normalizeOfflinePackageName(uuid);
const normalizedLowerName = this.normalizeOfflinePackageName(uuid.toLowerCase());
const zipData = this.offlineZipMap.get(fileName) || this.offlineZipMap.get(normalizedLowerName);
if (!zipData) {
console.error(`[Package] 离线包缺少分包: ${fileName}`);
this.callFunNum.value--;
return;
}
parseGroupZip(zipData);
return;
}
Package._fetch(`${this.prefix_url}/${uuid}.zip`, {
onSuccess: (zipRes) => {
parseGroupZip(zipRes.blob());
}
});
}
// db.getItem(`${uuid}.zip`, dbTable).then((dbData:GroupJson | undefined) => {
// if (dbData === undefined) {
getByNetwork();
// } else {
// this.recordUuid(dbData);
//
// dbData.images.forEach((image) => {
// this.imagesMap.set(image.uuid, image);
// })
//
// dbData.object.groupChildren.forEach((uuid) => {
// // 保存uuid对应的function
// funcMap.set(uuid, this.unpackGroup);
//
// this.callFunNum.value++;
// })
//
// parse(dbData);
// }
// }).catch((err: string) => {
// console.log("err:", err)
// })
}
/**
* 销毁此类
*/
dispose(){
// 1. 清空所有数组
this.geometryArr = [];
this.imagesArr = [];
this.materialsArr = [];
this.textureArr = [];
this.skeletonsArr = [];
// 2. 清空所有映射
this.geometryMap.clear();
this.imagesMap.clear();
this.materialsMap.clear();
this.textureMap.clear();
// 3. 销毁 loader 和骨架处理器
if (this.loader) {
this.loader = null as any; // 清空引用
}
if (this.skeletonClass) {
this.skeletonClass.clear(); // 清除骨架数据
this.skeletonClass = null as any; // 清空引用
}
// 4. 重置其他属性
this.prefix_url = "";
this.callFunNum = { value: 0 }; // 重置为初始状态
this.totalSize = 0;
this.offlineZipMap = null;
this.offlineEntryZip = null;
this.offlineFlatPackages = null;
this.offlineFlatEntryBase = null;
this.dataComponentMap = null;
// 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<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;
}
}