1851 lines
71 KiB
TypeScript
1851 lines
71 KiB
TypeScript
/**
|
||
* @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 为单 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
|
||
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;
|
||
|
||
// 每次解包前确保 loader 可用
|
||
if (!this.loader) {
|
||
this.loader = new ObjectLoader();
|
||
}
|
||
|
||
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;
|
||
// 不清除 loader,避免连续导入时被上一轮异步收尾误清理
|
||
}
|
||
|
||
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}`;
|
||
|
||
// 在调用时捕获 loader 引用,防止 done() 清除 this.loader 后重试路径报错
|
||
const loader = this.loader;
|
||
|
||
// 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)) {
|
||
if (!loader) {
|
||
// 生产环境定位: 打印关键上下文和调用栈
|
||
console.error("[Package.unpackGroup] loader is undefined before parse", {
|
||
uuid,
|
||
rootGroupUuid,
|
||
parent,
|
||
callFunNum: this.callFunNum?.value,
|
||
hasJson: Boolean(json),
|
||
childCount: json?.object?.children?.length,
|
||
currentLoader: this.loader,
|
||
capturedLoader: loader,
|
||
stack: new Error("[Package.unpackGroup] missing loader").stack
|
||
});
|
||
|
||
// 防止早退导致 callFunNum 无法归零,进度卡住
|
||
this.callFunNum.value--;
|
||
return;
|
||
}
|
||
try {
|
||
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);
|
||
})
|
||
})
|
||
} catch (err) {
|
||
console.error("[Package.unpackGroup] loader.parse threw", {
|
||
uuid,
|
||
rootGroupUuid,
|
||
parent,
|
||
callFunNum: this.callFunNum?.value,
|
||
hasJson: Boolean(json),
|
||
childCount: json?.object?.children?.length,
|
||
currentLoader: this.loader,
|
||
capturedLoader: loader,
|
||
err,
|
||
stack: err instanceof Error ? err.stack : new Error("[Package.unpackGroup] parse error").stack
|
||
});
|
||
throw err;
|
||
}
|
||
} 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;
|
||
}
|
||
} |