feat(All):Initial
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Create a container.
|
||||
*/
|
||||
export function createDivContainer(): HTMLDivElement {
|
||||
const div = document.createElement("div");
|
||||
document.body.appendChild(div);
|
||||
return div;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 下载blob二进制对象
|
||||
* @param blob
|
||||
* @param filename
|
||||
*/
|
||||
export function downloadBlob(blob, filename) {
|
||||
const link = document.createElement('a');
|
||||
|
||||
if (link.href) {
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = filename || 'data.json';
|
||||
link.dispatchEvent(new MouseEvent('click'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载ArrayBuffer对象
|
||||
* @param buffer
|
||||
* @param filename
|
||||
*/
|
||||
export function saveArrayBuffer(buffer, filename) {
|
||||
downloadBlob(new Blob([buffer], {type: 'application/octet-stream'}), filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载text文档
|
||||
* @param text
|
||||
* @param filename
|
||||
*/
|
||||
export function saveString(text, filename) {
|
||||
downloadBlob(new Blob([text], {type: 'text/plain'}), filename);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 递归访问嵌套属性
|
||||
* @param {object} obj
|
||||
* @param {string} path 属性路径字符串,eg: "a.b.c"
|
||||
*/
|
||||
export function getNestedProperty(obj:object, path:string):any {
|
||||
return path.split('.').reduce((o, key) => o?.[key], obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义正则特殊字符
|
||||
* @param {string} str
|
||||
*/
|
||||
export function escapeRegExp(str:string) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统主题色
|
||||
*/
|
||||
export function getOsTheme(){
|
||||
const isDarkTheme = window.matchMedia("(prefers-color-scheme: dark)"); // 是深色
|
||||
if (isDarkTheme.matches) {
|
||||
return 'dark';
|
||||
} else {
|
||||
return "light";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取rem的px值
|
||||
*/
|
||||
export function remToPxNumber(rem: number): number {
|
||||
const f = parseFloat(document.documentElement.style.fontSize);
|
||||
return f * rem;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './object';
|
||||
export * from './performance';
|
||||
export * from './helper';
|
||||
export * from './download';
|
||||
export * from './verify';
|
||||
export * from './dom';
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 将对象source的值深度遍历赋值给target对象相同key
|
||||
* @param target
|
||||
* @param source
|
||||
*/
|
||||
export function deepAssign(target, source) {
|
||||
for (const key in source) {
|
||||
if (source.hasOwnProperty(key)) {
|
||||
if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
|
||||
if (!target[key]){
|
||||
target[key] = {};
|
||||
}
|
||||
deepAssign(target[key], source[key]);
|
||||
} else {
|
||||
target[key] = source[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 防抖函数
|
||||
* @param {Function} func - 需要防抖的函数
|
||||
* @param {number} wait - 时间间隔(毫秒)
|
||||
* @returns {Function} - 返回一个防抖后的函数
|
||||
*/
|
||||
export function debounce(func, wait): (...args: any[]) => void {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
return function(){
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
func(...arguments)
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
* @param {Function} func - 需要节流的函数
|
||||
* @param {number} wait - 时间间隔(毫秒),表示在这个时间间隔内最多执行一次函数
|
||||
* @returns {Function} - 返回一个节流后的函数
|
||||
*/
|
||||
export function throttle(func, wait:number):(...args: any[]) => void {
|
||||
// 上一次执行函数的时间戳,初始值为 0
|
||||
let lastTime = 0;
|
||||
|
||||
// 返回一个闭包函数,作为节流后的函数
|
||||
return function () {
|
||||
// 获取当前时间戳
|
||||
const now = Date.now();
|
||||
|
||||
// 如果当前时间与上一次执行时间的差值大于等于 wait,则执行函数
|
||||
if (now - lastTime >= wait) {
|
||||
// 更新上一次执行函数的时间戳
|
||||
lastTime = now;
|
||||
// 调用原始函数,并传入参数
|
||||
func(...arguments);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 验证方法
|
||||
*/
|
||||
|
||||
export const IS_MAC = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
|
||||
export const isNil = (v) => v === null || v === undefined;
|
||||
|
||||
// 判断是否是空对象,排除数组
|
||||
export const isEmptyObject = (obj:object) => typeof obj === 'object' && obj !== null && !Array.isArray(obj) && Object.keys(obj).length === 0;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './pako';
|
||||
@@ -0,0 +1,54 @@
|
||||
import pako from "pako";
|
||||
import { encode, decode } from 'js-base64';
|
||||
|
||||
// 压缩
|
||||
export const zip = (data, needEncode = true) => {
|
||||
if (!data) return data
|
||||
// 判断数据是否需要转为JSON
|
||||
const dataJson: string = typeof data !== 'string' ? JSON.stringify(data) : data
|
||||
|
||||
// 使用Base64.encode处理字符编码,兼容中文
|
||||
const str = needEncode ? encode(dataJson) : dataJson;
|
||||
let binaryString = pako.gzip(str) as number[];
|
||||
let arr = Array.from(binaryString);
|
||||
let s = "";
|
||||
arr.forEach((item: number) => {
|
||||
s += String.fromCharCode(item)
|
||||
})
|
||||
return btoa(s)
|
||||
}
|
||||
|
||||
// 解压
|
||||
export const unzip = (b64Data, needDecode = true) => {
|
||||
let strData = atob(b64Data);
|
||||
let charData = strData.split('').map(function (x) {
|
||||
return x.charCodeAt(0);
|
||||
});
|
||||
let binData = new Uint8Array(charData);
|
||||
let data = pako.ungzip(binData);
|
||||
|
||||
// ↓切片处理数据,防止内存溢出报错↓
|
||||
let str = '';
|
||||
const chunk = 8 * 1024
|
||||
let i;
|
||||
for (i = 0; i < data.length / chunk; i++) {
|
||||
str += String.fromCharCode.apply(null, data.slice(i * chunk, (i + 1) * chunk));
|
||||
}
|
||||
str += String.fromCharCode.apply(null, data.slice(i * chunk));
|
||||
// ↑切片处理数据,防止内存溢出报错↑
|
||||
|
||||
const unzipStr = needDecode ? decode(str) : str;
|
||||
let result;
|
||||
|
||||
// 对象或数组进行JSON转换
|
||||
try {
|
||||
result = JSON.parse(unzipStr)
|
||||
} catch (error) {
|
||||
if (/Unexpected token o in JSON at position 0/.test(error as string)) {
|
||||
// 如果没有转换成功,代表值为基本数据,直接赋值
|
||||
result = unzipStr
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Astral3D.Utils namespace
|
||||
*/
|
||||
export * from "./common";
|
||||
export * from "./scene";
|
||||
export * from "./handler";
|
||||
export * from "./request";
|
||||
export * from "./signals/signalRegister";
|
||||
export {logger} from "./log/Logger";
|
||||
export type {ILog} from "./log/Logger";
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @author ErSan
|
||||
* @email mlt131220@163.com
|
||||
* @date 2025/2/24 下午2:21
|
||||
* @description 日志记录器
|
||||
*/
|
||||
import {useAddSignal, useDispatchSignal,useRemoveSignal} from '@/hooks';
|
||||
|
||||
export interface ILog {
|
||||
id: number;
|
||||
message: string;
|
||||
time: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
let _delLogFn,_clearLogFn,_historyChangedFn;
|
||||
class Logger {
|
||||
static Enum = Object.freeze({
|
||||
TRACE: "trace",
|
||||
DEBUG: "debug",
|
||||
INFO: "info",
|
||||
WARN: "warn",
|
||||
ERROR: "error"
|
||||
});
|
||||
|
||||
// 是否启用日志
|
||||
enabled: boolean = true;
|
||||
|
||||
// 日志信息
|
||||
logs: ILog[] = [];
|
||||
|
||||
constructor() {
|
||||
_delLogFn = this.delLog.bind(this);
|
||||
useAddSignal("deleteLog",_delLogFn);
|
||||
_clearLogFn = this.clearLogs.bind(this);
|
||||
useAddSignal("clearLogs",_clearLogFn);
|
||||
_historyChangedFn = this.historyChanged.bind(this);
|
||||
useAddSignal("historyChanged",_historyChangedFn);
|
||||
}
|
||||
|
||||
log(methodName:string,message:string){
|
||||
if(!this.enabled) return;
|
||||
|
||||
const _log = {
|
||||
id: this.logs.length,
|
||||
message,
|
||||
level: methodName,
|
||||
time: new Date().toLocaleString()
|
||||
}
|
||||
|
||||
this.logs.unshift(_log);
|
||||
|
||||
useDispatchSignal("addLog", _log, this.logs);
|
||||
}
|
||||
|
||||
trace(message:string) { this.log(Logger.Enum.TRACE, message); }
|
||||
debug(message:string) { this.log(Logger.Enum.DEBUG, message); }
|
||||
info(message:string) { this.log(Logger.Enum.INFO, message); }
|
||||
warn(message:string) { this.log(Logger.Enum.WARN, message); }
|
||||
error(message:string) { this.log(Logger.Enum.ERROR, message); }
|
||||
|
||||
/**
|
||||
* 删除日志
|
||||
* @param _log
|
||||
*/
|
||||
delLog(_log: ILog) {
|
||||
this.logs = this.logs.filter(log => log.id!== _log.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空日志
|
||||
*/
|
||||
clearLogs() {
|
||||
this.logs = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史记录变化回调
|
||||
* @param cmd
|
||||
*/
|
||||
historyChanged(cmd){
|
||||
if(!cmd?.name) return;
|
||||
|
||||
let msg = cmd.name;
|
||||
const postposition = ['AddObjectCommand','RemoveObjectCommand','MoveObjectCommand'];
|
||||
if(postposition.includes(cmd.type)){
|
||||
msg = `${msg}: ${cmd.object.name} `;
|
||||
}else if(cmd.object){
|
||||
msg = `${cmd.object.name} ${msg.toLowerCase()}`;
|
||||
}
|
||||
|
||||
if(cmd.newValue !== undefined && cmd.oldValue !== undefined){
|
||||
let newValue = cmd.newValue;
|
||||
let oldValue = cmd.oldValue;
|
||||
if(typeof newValue === 'object'){
|
||||
newValue = JSON.stringify(newValue);
|
||||
}
|
||||
if(typeof oldValue === 'object'){
|
||||
oldValue = JSON.stringify(oldValue);
|
||||
}
|
||||
|
||||
msg = `${msg}: ${oldValue} ⇒ ${newValue}`;
|
||||
}
|
||||
|
||||
this.info(msg);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
useRemoveSignal("deleteLog",_delLogFn)
|
||||
_delLogFn = null;
|
||||
useRemoveSignal("clearLogs",_clearLogFn)
|
||||
_clearLogFn = null;
|
||||
useRemoveSignal("historyChanged",_historyChangedFn)
|
||||
_historyChangedFn = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
|
||||
export default logger;
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 创建一个handlerFetch
|
||||
*
|
||||
* @param limit 并发控制
|
||||
* @param timeout 超时设
|
||||
* @return function 返回一个函数
|
||||
*/
|
||||
export function fetchController(limit: number, timeout: number | boolean) {
|
||||
limit = limit || 1;
|
||||
timeout = timeout || false;
|
||||
let count = 0, pool: any = [];
|
||||
|
||||
return function (url: string, options?: any) {
|
||||
// 通过AbortController 控制 取消fetch 请求
|
||||
let controller = new AbortController();
|
||||
let signal = controller.signal;
|
||||
// 判断是否需要超时
|
||||
let isTimeout = options && options.timeout || timeout;
|
||||
|
||||
// 控制请求超时
|
||||
let timeoutPromise = () => {
|
||||
return new Promise((reject) => {
|
||||
setTimeout(() => {
|
||||
// resolve('请求超时');
|
||||
reject('请求超时');
|
||||
controller.abort();
|
||||
}, options?.timeout || timeout)
|
||||
})
|
||||
}
|
||||
|
||||
// 返回fetch 本身
|
||||
let taskPromise = () => new Promise((resolve, reject) => {
|
||||
fetch(url, { signal, ...options }).then(res => {
|
||||
resolve(res);
|
||||
}).catch(err => {
|
||||
reject(err)
|
||||
})
|
||||
});
|
||||
|
||||
// 通过Promise.race可以控制超时,并在访问结果中 去继续调用等待池中的请求
|
||||
let task = () => (isTimeout ? Promise.race([timeoutPromise(), taskPromise()]) : taskPromise())
|
||||
.then((res) => {
|
||||
options.onSuccess && options.onSuccess(res)
|
||||
next();
|
||||
})
|
||||
.catch((err) => {
|
||||
options.onError && options.onError(err)
|
||||
next();
|
||||
});
|
||||
|
||||
// 定一个next 控制等待队列中的请求继续并发调用
|
||||
let next = () => {
|
||||
// 每执行一次next count - 1,然后比较当前的count 与 limit
|
||||
// 如果小于limit 循环执行limit-count 次
|
||||
count--;
|
||||
if (count < limit && pool.length) {
|
||||
let n = limit - count;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let curTask: any = pool.shift();
|
||||
curTask();
|
||||
++count;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 比较count与limit 大于等于limit的推入等待队列 小于limit的 count + 1,并执行fetch请求
|
||||
if (count >= limit) {
|
||||
pool.push(task);
|
||||
} else {
|
||||
++count;
|
||||
task();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './fetchController';
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 性能状态监视器,基于stats.js
|
||||
* 默认展示全部面板
|
||||
*/
|
||||
import ThreeStats from 'three/examples/jsm/libs/stats.module.js';
|
||||
import Viewer from "@/core/viewer/Viewer.ts";
|
||||
|
||||
export class Stats {
|
||||
private viewer: Viewer;
|
||||
private threeStats: ThreeStats;
|
||||
private panel = 0;
|
||||
private _visible = true;
|
||||
|
||||
private fns:{
|
||||
beforeRender:null | (() => void);
|
||||
afterRender:null | (() => void);
|
||||
} = {
|
||||
beforeRender: null,
|
||||
afterRender: null,
|
||||
}
|
||||
|
||||
constructor(viewer: Viewer) {
|
||||
this.viewer = viewer;
|
||||
this.threeStats = new ThreeStats();
|
||||
|
||||
this.initEvent();
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
get domElement():HTMLElement {
|
||||
return this.threeStats.dom;
|
||||
}
|
||||
|
||||
get visible(){
|
||||
return this._visible;
|
||||
}
|
||||
|
||||
set visible(visible:boolean) {
|
||||
this._visible = visible;
|
||||
|
||||
this.domElement.style.display = visible ? "block" : 'none';
|
||||
}
|
||||
|
||||
initEvent(){
|
||||
this.fns.beforeRender = () => {
|
||||
if(!this.visible) return;
|
||||
|
||||
this.threeStats.begin();
|
||||
};
|
||||
this.viewer.addEventListener("beforeRender",this.fns.beforeRender);
|
||||
|
||||
this.fns.afterRender = () => {
|
||||
if(!this.visible) return;
|
||||
|
||||
this.threeStats.end();
|
||||
};
|
||||
this.viewer.addEventListener("afterRender",this.fns.afterRender);
|
||||
}
|
||||
|
||||
init(){
|
||||
const canvases = this.domElement.querySelectorAll("canvas");
|
||||
canvases.forEach(canvas => {
|
||||
canvas.style.width = "5rem";
|
||||
canvas.style.height = "3rem";
|
||||
canvas.style.display = "block";
|
||||
});
|
||||
}
|
||||
|
||||
showPanel(type: number | 'fps' | 'ms' | 'mb') {
|
||||
if (typeof type === 'number') {
|
||||
this.threeStats.showPanel(type);
|
||||
this.panel = type;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type.toLowerCase()) {
|
||||
case 'fps':
|
||||
this.threeStats.showPanel(0);
|
||||
this.panel = 0;
|
||||
break;
|
||||
case 'ms':
|
||||
this.threeStats.showPanel(1);
|
||||
this.panel = 1;
|
||||
break;
|
||||
case 'mb':
|
||||
this.threeStats.showPanel(2);
|
||||
this.panel = 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
showAllPanels(show:boolean) {
|
||||
const canvases = this.domElement.querySelectorAll("canvas");
|
||||
canvases.forEach(canvas => {
|
||||
canvas.style.display = show ? "block" : "none";
|
||||
});
|
||||
|
||||
if(!show){
|
||||
this.showPanel(this.panel);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(){
|
||||
if(this.fns.beforeRender){
|
||||
this.viewer.removeEventListener("beforeRender",this.fns.beforeRender);
|
||||
this.fns.beforeRender = null;
|
||||
}
|
||||
|
||||
if(this.fns.afterRender){
|
||||
this.viewer.removeEventListener("afterRender",this.fns.afterRender);
|
||||
this.fns.afterRender = null;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
this.threeStats = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {Object3D,Box3,Vector3} from "three";
|
||||
import CameraControls from "camera-controls";
|
||||
|
||||
export function focusObject(object:Object3D,controls:CameraControls,enableTransition: boolean = true){
|
||||
const box3 = new Box3();
|
||||
box3.setFromObject(object);
|
||||
|
||||
if(box3.isEmpty()){
|
||||
box3.set(new Vector3(object.position.x-1, object.position.y-1, object.position.z-1), new Vector3(object.position.x+1, object.position.y+1, object.position.z+1));
|
||||
}
|
||||
return controls.fitToBox(box3,enableTransition);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Object3D,InstancedMesh,Mesh,Matrix4,AnimationAction,AnimationClip } from "three";
|
||||
import App from "@/core/app/App";
|
||||
|
||||
export * from "./material";
|
||||
export * from "./Stats";
|
||||
export * from "./controls";
|
||||
|
||||
/**
|
||||
* 获取对象到父对象的路径(结果不包含parentObject)
|
||||
* @param parentObject
|
||||
* @param object
|
||||
* @param attr 对象属性名
|
||||
* @param splitter 路径分隔符
|
||||
*/
|
||||
export function getParentPath(parentObject:Object3D,object:Object3D,attr = 'name',splitter = '/'){
|
||||
if(!parentObject || !object) return '';
|
||||
|
||||
if(parentObject === object) return object[attr];
|
||||
|
||||
let path = [object[attr]];
|
||||
|
||||
const getPath = (obj) => {
|
||||
if(!obj.parent) return;
|
||||
|
||||
if(obj.parent === parentObject) return;
|
||||
|
||||
path.unshift(obj.parent[attr]);
|
||||
|
||||
getPath(obj.parent);
|
||||
}
|
||||
getPath(object);
|
||||
|
||||
return path.join(splitter);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取鼠标按下的位置
|
||||
* @param dom
|
||||
* @param x
|
||||
* @param y
|
||||
*/
|
||||
export function getMousePosition(dom: HTMLElement, x: number, y: number) {
|
||||
const rect = dom.getBoundingClientRect();
|
||||
return [(x - rect.left) / rect.width, (y - rect.top) / rect.height];
|
||||
}
|
||||
|
||||
/**
|
||||
* InstancedMesh 解出所有 mesh
|
||||
*/
|
||||
export function getMeshByInstancedMesh(instancedMesh:InstancedMesh){
|
||||
const meshes:Mesh[] = [];
|
||||
// if (instancedMesh.material === undefined) return meshes;
|
||||
|
||||
const matrixWorld = instancedMesh.matrixWorld;
|
||||
const count = instancedMesh.count;
|
||||
|
||||
for (let instanceId = 0; instanceId < count; instanceId++) {
|
||||
const _mesh = new Mesh();
|
||||
const _instanceLocalMatrix = new Matrix4();
|
||||
const _instanceWorldMatrix = new Matrix4();
|
||||
|
||||
_mesh.geometry = instancedMesh.geometry;
|
||||
_mesh.material = instancedMesh.material;
|
||||
|
||||
// 计算每个实例的世界矩阵
|
||||
instancedMesh.getMatrixAt(instanceId, _instanceLocalMatrix);
|
||||
|
||||
_instanceWorldMatrix.multiplyMatrices(matrixWorld, _instanceLocalMatrix);
|
||||
|
||||
// 网格表示这个单一实例
|
||||
_mesh.matrixWorld = _instanceWorldMatrix;
|
||||
|
||||
meshes.push(_mesh);
|
||||
}
|
||||
|
||||
return meshes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是group,因为导入有可能存在被定义为Object3D类型的group
|
||||
*/
|
||||
export function isGroup(object3D:Object3D){
|
||||
return (object3D.isGroup || object3D.children.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是代理粒子发射器的3D对象
|
||||
*/
|
||||
export function isParticleObject(object:Object3D | null){
|
||||
return object && object.type === "Particle" && object.emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是Billboard 3D对象
|
||||
*/
|
||||
export function isBillboardObject(object:Object3D | null){
|
||||
return object && object.type === "Billboard" && object.options;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是HtmlPanel 3D对象
|
||||
*/
|
||||
export function isHtmlPanelObject(object:Object3D | null){
|
||||
return object && (object.isHtmlPanel || object.isHtmlSprite) && object.element;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取场景/物体中的所有动画
|
||||
*/
|
||||
export function getAnimations(object = App.scene) {
|
||||
const animations: any = [];
|
||||
|
||||
object.traverse(function (object) {
|
||||
animations.push(...object.animations);
|
||||
});
|
||||
|
||||
return animations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取场景/物体中的所有动画剪辑
|
||||
*/
|
||||
export function getAnimationClips(object:Object3D = App.scene) {
|
||||
const animations: any = [];
|
||||
|
||||
object.traverse(function (object) {
|
||||
object.animations.forEach(animation => {
|
||||
if(animation instanceof AnimationAction){
|
||||
animations.push(animation.getClip());
|
||||
}
|
||||
|
||||
if(animation instanceof AnimationClip){
|
||||
animations.push(animation);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return animations;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import JSZip from "jszip";
|
||||
import * as THREE from "three";
|
||||
import Loader from "@/core/loader/Loader.ts";
|
||||
import App from "@/core/app/App.ts";
|
||||
|
||||
/**
|
||||
* 解析材质zip包
|
||||
*/
|
||||
export function parseMaterialZip(zipFile: File): Promise<THREE.MeshStandardMaterial> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const zip = new JSZip();
|
||||
|
||||
const zipContent = await zip.loadAsync(zipFile);
|
||||
|
||||
// 强制检查根目录下是否存在material.json
|
||||
let materialJson: any = zipContent.file('material.json');
|
||||
if (!materialJson) {
|
||||
materialJson = {
|
||||
textures: {},
|
||||
properties: {}
|
||||
}
|
||||
|
||||
// 获取所有文件路径列表(JSZip存储结构为 {路径: 文件对象})
|
||||
const filePaths = Object.keys(zipContent.files);
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
const relativePath: string = filePaths[i];
|
||||
const file = zipContent.file(relativePath);
|
||||
|
||||
if (!file) continue;
|
||||
|
||||
// 判断是否为文件(JSZip中目录路径以 '/' 结尾)
|
||||
if (file.dir || relativePath.endsWith('/')) continue;
|
||||
|
||||
if (relativePath.includes("baseColor")) {
|
||||
materialJson.textures.baseColor = relativePath;
|
||||
} else if (relativePath.includes("normal")) {
|
||||
materialJson.textures.normal = relativePath;
|
||||
} else if (relativePath.includes("bump")) {
|
||||
materialJson.textures.bump = relativePath;
|
||||
} else if (relativePath.includes("displacement")) {
|
||||
materialJson.textures.displacement = relativePath;
|
||||
} else if (relativePath.includes("emissive")) {
|
||||
materialJson.textures.emissive = relativePath;
|
||||
} else if (relativePath.includes("alpha")) {
|
||||
materialJson.textures.alpha = relativePath;
|
||||
} else if (relativePath.includes("env")) {
|
||||
materialJson.textures.env = relativePath;
|
||||
} else if (relativePath.includes("light")) {
|
||||
materialJson.textures.light = relativePath;
|
||||
} else {
|
||||
// arm可能在一张图上各占一个通道
|
||||
if (relativePath.includes("roughness")) {
|
||||
materialJson.textures.roughness = relativePath;
|
||||
}
|
||||
if (relativePath.includes("metalness")) {
|
||||
materialJson.textures.metalness = relativePath;
|
||||
}
|
||||
if (relativePath.includes("ao")) {
|
||||
materialJson.textures.ao = relativePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
materialJson = JSON.parse(await materialJson.async('text'));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 并行加载所有纹理
|
||||
const texturePromises = Object.entries(materialJson.textures).map(
|
||||
async ([type, path]:any) => {
|
||||
const textureFile = zipContent.file(path);
|
||||
if (!textureFile) {
|
||||
console.warn(`Texture file not found: ${path}`);
|
||||
return { type, texture: null };
|
||||
}
|
||||
|
||||
const extension = path.toString().split(".").pop()?.toLowerCase() || "jpg";
|
||||
let textureBlob: Blob;
|
||||
|
||||
try {
|
||||
// 特殊处理 EXR 格式
|
||||
if (extension === "exr") {
|
||||
const buffer = await textureFile.async("arraybuffer");
|
||||
textureBlob = new Blob([buffer], { type: "image/x-exr" });
|
||||
} else {
|
||||
textureBlob = await textureFile.async("blob");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load texture (${type}):`, err);
|
||||
return { type, texture: null };
|
||||
}
|
||||
|
||||
const textureUrl = URL.createObjectURL(textureBlob);
|
||||
|
||||
return new Promise<{ type: string; texture: THREE.Texture | null }>(
|
||||
(resolve) => {
|
||||
Loader.loadUrlTexture(
|
||||
extension,
|
||||
textureUrl,
|
||||
(texture: THREE.Texture) => {
|
||||
URL.revokeObjectURL(textureUrl); // 清理资源
|
||||
resolve({ type, texture });
|
||||
},
|
||||
(error: Error) => {
|
||||
URL.revokeObjectURL(textureUrl);
|
||||
console.error(`Texture load error (${type}):`, error);
|
||||
resolve({ type, texture: null });
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// 等待所有纹理加载完成
|
||||
const textureResults = await Promise.all(texturePromises);
|
||||
const textures = textureResults.reduce((acc, { type, texture }) => {
|
||||
if (texture) acc[type] = texture;
|
||||
return acc;
|
||||
}, {} as Record<string, THREE.Texture>);
|
||||
|
||||
// 处理无有效纹理的情况
|
||||
if (Object.keys(textures).length === 0) {
|
||||
throw new Error("No valid textures found in the zip file");
|
||||
}
|
||||
|
||||
const material = await App.createPBRMaterial(textures, materialJson.properties || {});
|
||||
|
||||
resolve(material);
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import Signal from 'signals';
|
||||
|
||||
interface SignalObj<T = any> {
|
||||
add(listener: (...params: T[]) => void, listenerContext?: any, priority?: Number): void;
|
||||
addOnce(listener: (...params: T[]) => void, listenerContext?: any, priority?: Number): void;
|
||||
dispatch(...params: T[]): void;
|
||||
remove(listener: (...params: T[]) => void, context?: any): void;
|
||||
removeAll(): void;
|
||||
setActive(active: boolean): void;
|
||||
halt(): void;
|
||||
dispose(): void;
|
||||
has(listener: (...params: T[]) => void, context?: any): boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface SignalRegister {
|
||||
[s: string]: SignalObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* App
|
||||
* @path lib/core/App
|
||||
*/
|
||||
const appSignals: SignalRegister = {
|
||||
// xr
|
||||
enterXR: new Signal(),
|
||||
offerXR: new Signal(),
|
||||
leaveXR: new Signal(),
|
||||
|
||||
sceneCleared: new Signal(),
|
||||
|
||||
transformModeChanged: new Signal(),
|
||||
snapChanged: new Signal(),
|
||||
spaceChanged: new Signal(),
|
||||
rendererCreated: new Signal(),
|
||||
rendererUpdated: new Signal(),
|
||||
rendererConfigUpdate: new Signal(),
|
||||
rendererDetectKTX2Support: new Signal(),
|
||||
|
||||
sceneBackgroundChanged: new Signal(),
|
||||
sceneEnvironmentChanged: new Signal(),
|
||||
sceneFogSettingsChanged: new Signal(),
|
||||
// 雨效果配置参数变更
|
||||
sceneRainSettingsChanged: new Signal(),
|
||||
// 雪效果配置参数变更
|
||||
sceneSnowSettingsChanged: new Signal(),
|
||||
sceneGraphChanged: new Signal(),
|
||||
sceneRendered: new Signal(),
|
||||
sceneResize: new Signal(),
|
||||
|
||||
cameraAdded: new Signal(),
|
||||
cameraRemoved: new Signal(),
|
||||
cameraChanged: new Signal(),
|
||||
cameraReset: new Signal(),
|
||||
|
||||
geometryChanged: new Signal(),
|
||||
|
||||
objectSelected: new Signal(),
|
||||
objectFocused: new Signal(),
|
||||
objectFocusByUuid: new Signal(),
|
||||
|
||||
// 锁定模型
|
||||
objectLocked: new Signal(),
|
||||
// 解锁模型
|
||||
objectUnlocked: new Signal(),
|
||||
|
||||
objectAdded: new Signal(),
|
||||
objectChanged: new Signal(),
|
||||
objectRemoved: new Signal(),
|
||||
|
||||
materialAdded: new Signal(),
|
||||
materialChanged: new Signal(),
|
||||
materialRemoved: new Signal(),
|
||||
materialCurrentSlotChange: new Signal(),
|
||||
|
||||
scriptAdded: new Signal(),
|
||||
scriptChanged: new Signal(),
|
||||
scriptRemoved: new Signal(),
|
||||
|
||||
showGridChanged: new Signal(),
|
||||
showHelpersChanged: new Signal(),
|
||||
historyChanged: new Signal(),
|
||||
|
||||
// 场景主相机变更
|
||||
viewportCameraChanged: new Signal(),
|
||||
viewportShadingChanged: new Signal(),
|
||||
|
||||
intersectionsDetected: new Signal(),
|
||||
|
||||
pathTracerUpdated: new Signal(),
|
||||
|
||||
// 实例化ShaderMaterial类型内置材质
|
||||
instantiateShaderMaterial: new Signal(),
|
||||
|
||||
// 场景加载完成
|
||||
sceneLoadComplete: new Signal(),
|
||||
|
||||
// 添加日志
|
||||
addLog: new Signal(),
|
||||
// 删除日志
|
||||
deleteLog: new Signal(),
|
||||
// 清空日志
|
||||
clearLogs: new Signal(),
|
||||
|
||||
// 动画更新渲染
|
||||
animationMixerUpdate: new Signal(),
|
||||
// 动画轨道时间变化(游标拖动)
|
||||
timelineTimeChanged: new Signal(),
|
||||
// 动画轨道行变化(添加/删除)
|
||||
timelineRowChanged: new Signal(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Viewer
|
||||
* @path lib/core/Viewer
|
||||
*/
|
||||
const ViewerSignals: SignalRegister = {
|
||||
// viewer 初始化完毕,即构造函数执行完毕
|
||||
viewerInitCompleted: new Signal(),
|
||||
// 插件注册
|
||||
pluginInstall: new Signal(),
|
||||
// 插件卸载
|
||||
pluginUninstall: new Signal(),
|
||||
IFCPropertiesVisible: new Signal(),
|
||||
// 启用/禁用后处理
|
||||
effectEnabledChange: new Signal(),
|
||||
// 后处理通道配置变更
|
||||
effectPassConfigChange: new Signal(),
|
||||
// 粒子body使用的Object3D变更
|
||||
particleBodyChanged: new Signal(),
|
||||
// 粒子系统添加emitter后触发
|
||||
particleSystemAddEmitter: new Signal(),
|
||||
// 将emitter添加到粒子系统:fromJSON时触发
|
||||
emitterAdd2ParticleSystem: new Signal(),
|
||||
}
|
||||
|
||||
/**
|
||||
* 图纸 相关
|
||||
*/
|
||||
const drawingSignals: SignalRegister = {
|
||||
drawingMarkDone: new Signal(), // 新增/编辑 图纸标记完成回调
|
||||
cadViewerResize: new Signal(), // 图纸面板移动
|
||||
}
|
||||
|
||||
|
||||
export const SignalsRegister: SignalRegister = {
|
||||
...appSignals,
|
||||
...ViewerSignals,
|
||||
...drawingSignals,
|
||||
};
|
||||
|
||||
// signal注册器
|
||||
export const SignalsRegisterFn = (newSignals:Array<string>) => {
|
||||
newSignals.forEach(key => {
|
||||
SignalsRegister[key] = new Signal();
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user