feat(All):Initial

This commit is contained in:
2025-10-04 23:36:07 +08:00
commit 2b4e5d2668
1321 changed files with 415958 additions and 0 deletions
@@ -0,0 +1,46 @@
/**
* @author ErSan
* @email mlt131220@163.com
* @date 2024/9/18 22:24
* @description
*/
export class ListrTask {
// @ts-ignore
private title: any;
private taskFn: any;
isFailed: boolean;
constructor(title, taskFn) {
this.title = title;
this.taskFn = taskFn;
this.isFailed = false;
}
async run() {
try {
await this.taskFn(this);
} catch (error) {
this.isFailed = true;
throw error;
}
}
}
export class Listr {
private tasks: ListrTask[];
constructor(tasks: { title:string,task:(task: any) => Promise<void> }[]) {
this.tasks = tasks.map(task => new ListrTask(task.title, task.task));
}
async run() {
for (const task of this.tasks) {
await task.run();
if (task.isFailed) {
break; // 如果任务失败,停止执行后续任务
}
}
}
}
@@ -0,0 +1,247 @@
/**
* @author ErSan
* @email mlt131220@163.com
* @date 2024/9/16 23:19
* @description glTF处理器插件
*/
import {h, ref} from "vue";
import type {ModalReactive} from "naive-ui";
import {t} from "@/language";
import type {Plugin} from "@astral3d/engine";
import GLTFHandlerComponent from "@/components/es/plugin/builtin/GLTFHandler.vue";
import { MeshoptEncoder, MeshoptSimplifier } from 'meshoptimizer';
// @ts-ignore
import { ready as resampleReady, resample as resampleWASM } from 'keyframe-resample';
import { Logger,WebIO, Transform } from '@gltf-transform/core';
import {
dedup,
instance,
prune,
quantize,
resample,
weld,
meshopt,
draco,
simplify,
textureCompress,
flatten,
join,
sparse,
palette,
} from '@gltf-transform/functions';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
import {Session} from "./session";
import {loadScript} from "@/utils/common/utils";
//使用'micromatch',因为'contains: true'没有像预期的那样在minimatch中工作。需要确保'*'匹配的模式,如'image/png'。
export const MICROMATCH_OPTIONS = { nocase: true, contains: true };
export default class GLTFHandler implements Plugin{
icon= "";
name = "glTF处理器";
version = 1;
logger = new Logger(Logger.Verbosity.INFO);
io = new WebIO({credentials: 'include'}).registerExtensions(ALL_EXTENSIONS);
modalInstance:ModalReactive | undefined = undefined;
GLTFHandlerComponentRef = ref();
dracoScript = new Proxy({
encoder:false,
decoder:false,
failMsg:""
},{
set:(target: {decoder: boolean;encoder: boolean;failMsg: string;}, p: string | symbol, newValue: any): boolean => {
target[p] = newValue;
if(p === "failMsg"){
window.$message?.error(newValue)
return true;
}
if(target.encoder && target.decoder){
this.registerDependencies()
}
return true;
}
})
async install() {
// console.log(`%c glTF处理器 %c 版本:1.0.0`, 'background: #35495e; padding: 4px; border-radius: 3px 0 0 3px; color: #fff',
// 'background: #41b883; padding: 4px; border-radius: 0 3px 3px 0; color: #fff');
}
async run() {
// 运行时再加载draco相关js
if(!this.dracoScript.encoder){
loadScript("/libs/draco/draco_encoder.js",false).then(() => {
this.dracoScript.encoder = true;
}).catch(() => {
this.dracoScript.failMsg = t("plugin.gltfHandler['Draco encoder load fail,Refresh the page and try again.']");
})
}
if(!this.dracoScript.decoder) {
loadScript("/libs/draco/draco_decoder.js", false).then(() => {
this.dracoScript.decoder = true;
}).catch(() => {
this.dracoScript.failMsg = t("plugin.gltfHandler['Draco decoder load fail,Refresh the page and try again.']");
})
}
this.GLTFHandlerComponentRef = ref();
const finishFn = this.finish.bind(this);
this.modalInstance = window.$modal.create({
title: this.name,
preset:"card",
maskClosable:false,
style: {
width: '90%',
maxWidth: '800px'
},
onAfterLeave: finishFn,
content: () => {
return h(GLTFHandlerComponent,{
onOptimize:this.optimize.bind(this),
onFinish: finishFn,
ref:this.GLTFHandlerComponentRef
},"")
},
})
}
// 关闭插件
finish(){
this.modalInstance && this.modalInstance.destroy();
this.GLTFHandlerComponentRef = ref();
}
uninstall(): void {}
setLogger(log:string){
if(!this.GLTFHandlerComponentRef.value) return;
this.GLTFHandlerComponentRef.value.addLog(log);
}
async registerDependencies(){
this.io.registerDependencies({
// @ts-ignore
'draco3d.encoder': await new DracoEncoderModule(),
// @ts-ignore
'draco3d.decoder': await new DracoDecoderModule(),
})
}
/* 下面是实现的自定义的处理器方法 */
async optimize(opts:IPlugin.GLTFHandlerOptimizeModel,inputFile:File,outputFileName = ""){
// console.log("调用优化处理器,",opts,inputFile)
this.setLogger(`Optimize ${inputFile.name}`);
if(this.dracoScript.failMsg){
window.$message?.error(this.dracoScript.failMsg);
return;
}
if(!this.dracoScript.encoder || !this.dracoScript.decoder){
setTimeout(()=>this.optimize(opts,inputFile,outputFileName),1000)
return;
}
/* 文件准备就绪,开始优化 */
const transforms: Transform[] = [dedup()];
if (opts.instance) transforms.push(instance({ min: opts.instanceMin }));
if (opts.palette) {
transforms.push(
palette({
min: opts.paletteMin,
keepAttributes: !opts.prune || !opts.pruneAttributes,
}),
);
}
if (opts.flatten) transforms.push(flatten());
if (opts.join) transforms.push(join());
if (opts.weld) transforms.push(weld());
if (opts.simplify) {
transforms.push(
simplify({
simplifier: MeshoptSimplifier,
// simplifyError 用%显示时扩大了100倍,需要高精度计算减小100倍
error: opts.simplifyError / 100,
ratio: opts.simplifyRatio,
lockBorder: opts.simplifyLockBorder,
}),
);
}
// 重新采样动画通道,无损地删除重复的关键帧以减小文件大小。重复的关键帧通常出现在由创作软件“烘焙”的动画中,以应用 IK 约束或其他软件特定功能。
transforms.push(resample({ ready: resampleReady, resample: resampleWASM }));
if (opts.prune) {
transforms.push(
prune({
keepAttributes: !opts.pruneAttributes,
keepIndices: false,
keepLeaves: !opts.pruneLeaves,
keepSolidTextures: !opts.pruneSolidTextures,
}),
);
}
transforms.push(sparse());
if (opts.textureCompress !== 'none') {
transforms.push(
textureCompress({
resize: [opts.textureSize, opts.textureSize],
targetFormat: opts.textureCompress === 'auto' ? undefined : opts.textureCompress,
// limitInputPixels: options.limitInputPixels as boolean,
limitInputPixels: false,
}),
);
}
// 最后进行网格压缩
if (opts.compress === 'draco') {
transforms.push(draco());
} else if (opts.compress === 'meshopt') {
transforms.push(meshopt({ encoder: MeshoptEncoder, level: opts.meshoptLevel }));
} else if (opts.compress === 'quantize') {
transforms.push(quantize());
}
// 设置输出文件名
if(!outputFileName){
const format = inputFile.name.split(".").pop();
outputFileName = inputFile.name.replace(`.${format}`,`_astral3d.optimize.${format}`)
}
// 生成临时 URL
const inputFileUrl = URL.createObjectURL(inputFile);
let outputFile:File | undefined = undefined
try {
outputFile = await Session.create(this,inputFileUrl, inputFile.name, outputFileName)
.setDisplay(true)
.transform(...transforms);
this.setLogger(`Optimize ${inputFile.name} success!`);
}catch (e:unknown){
if (e instanceof Error) {
window.$message?.error(e.message);
this.setLogger(`Optimize ${inputFile.name} error: ${e.message}`);
} else {
window.$message?.error(e as string);
this.setLogger(`Optimize ${inputFile.name} error: ${e}`);
}
}
return outputFile;
}
}
@@ -0,0 +1,125 @@
import {Document, WebIO, FileUtils, Transform, Format, Logger} from '@gltf-transform/core';
import type { Packet, KHRXMP } from '@gltf-transform/extensions';
import { unpartition } from '@gltf-transform/functions';
import {Listr} from "./Listr";
import { formatBytes, XMPContext } from './util.js';
import GLTFHandler from "./glTFHandler";
export class Session {
private _outputFormat: Format;
private _display = false;
constructor(
private _io: WebIO,
private _logger: Logger,
private setLogger: (log:string) => void,
private _input: string,
private _inputName: string,
private _output: string,
) {
_io.setLogger(_logger);
this._outputFormat = FileUtils.extension(_output) === 'glb' ? Format.GLB : Format.GLTF;
}
public static create(handler:GLTFHandler, inputFileUrl: string,inputName:string, output: string): Session {
return new Session(handler.io, handler.logger, handler.setLogger.bind(handler),inputFileUrl, inputName,output);
}
public setDisplay(display: boolean): this {
this._display = display;
return this;
}
public async transform(...transforms: Transform[]): Promise<File> {
this.setLogger("Start");
let _document = this._input
? (await this._io.read(this._input)).setLogger(this._logger)
: new Document().setLogger(this._logger);
// Warn and remove lossy compression, to avoid increasing loss on round trip.
for (const extensionName of ['KHR_draco_mesh_compression', 'EXT_meshopt_compression']) {
const extension = _document
.getRoot()
.listExtensionsUsed()
.find((extension) => extension.extensionName === extensionName);
if (extension) {
extension.dispose();
this._logger.warn(`Decoded ${extensionName}. Further compression will be lossy.`);
}
}
if (this._display) {
const tasks = [] as { title:string,task:(task: any) => Promise<void> }[];
for (const transform of transforms) {
tasks.push({
title: transform.name,
task: async (task) => {
try{
this.setLogger(task.title)
let time = performance.now();
_document = await _document.transform(transform);
time = Math.round(performance.now() - time);
this.setLogger(task.title.padEnd(20) + ` ${time}ms`)
}catch (error:unknown){
// @ts-ignore
this.setLogger(`${task.title} run fail: ${error?.message || error}`)
}
},
});
}
await new Listr(tasks).run();
} else {
await _document.transform(...transforms);
}
await _document.transform(updateMetadata);
if (this._outputFormat === Format.GLB) {
await _document.transform(unpartition());
}
const outputUint8Array = await this._io.writeBinary(_document);
// Uint8Array转file
const mimeType = this._outputFormat === Format.GLB ? "model/gltf-binary" : "model/gltf+json";
const blob = new Blob([outputUint8Array], { type: mimeType});
const outputFile = new File([blob], this._output, { type: mimeType });
const { lastReadBytes, lastWriteBytes } = this._io;
if (!this._input) {
const output = FileUtils.basename(this._output) + '.' + FileUtils.extension(this._output);
this._logger.info(`${output} (${formatBytes(lastWriteBytes)})`);
} else {
const input = FileUtils.basename(this._inputName) + '.' + FileUtils.extension(this._inputName);
const output = FileUtils.basename(this._output) + '.' + FileUtils.extension(this._output);
this._logger.info(
`${input} (${formatBytes(lastReadBytes)})` + `${output} (${formatBytes(lastWriteBytes)})`,
);
}
this.setLogger("Done")
return outputFile;
}
}
function updateMetadata(_document: Document): void {
const root = _document.getRoot();
const xmpExtension = root
.listExtensionsUsed()
.find((ext) => ext.extensionName === 'KHR_xmp_json_ld') as KHRXMP | null;
// 不要将KHR_xmp_json_ld添加到尚未使用它的资产中。
if (!xmpExtension) return;
const rootPacket = root.getExtension<Packet>('KHR_xmp_json_ld') || xmpExtension.createPacket();
// xmp:MetadataDate should be the same as, or more recent than, xmp:ModifyDate.
// https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/xmp.md
const date = new Date().toISOString().substring(0, 10);
rootPacket
.setContext({ ...rootPacket.getContext(), xmp: XMPContext.xmp })
.setProperty('xmp:ModifyDate', date)
.setProperty('xmp:MetadataDate', date);
}
@@ -0,0 +1,27 @@
export const XMPContext: Record<string, string> = {
dc: 'http://purl.org/dc/elements/1.1/',
model3d: 'https://schema.khronos.org/model3d/xsd/1.0/',
rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
xmp: 'http://ns.adobe.com/xap/1.0/',
xmpRights: 'http://ns.adobe.com/xap/1.0/rights/',
};
export function formatLong(x: number): string {
return x.toString();
}
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes';
const k = 1000;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
export function dim(str: string): string {
return `\x1b[2m${str}\x1b[0m`;
}
+10
View File
@@ -0,0 +1,10 @@
import GLTFHandler from "./glTFHandler/glTFHandler";
import PointCloudReconstructor from "./pointCloudReconstructor/PointCloudReconstructor";
// 注册内置插件
export const installBuiltinPlugin = (viewer) => {
//glTF处理器
viewer.modules.plugin.use(new GLTFHandler());
// 语义化点云重建
viewer.modules.plugin.use(new PointCloudReconstructor());
}
@@ -0,0 +1,55 @@
/**
* @author ErSan
* @email mlt131220@163.com
* @date 2025/07/03 14:20
* @description 语义化点云重建
*/
import { h, ref } from "vue";
import type { ModalReactive } from "naive-ui";
import type { Plugin } from "@astral3d/engine";
import PointCloudReconstructorComponent from "@/components/es/plugin/builtin/PointCloudReconstructor.vue";
export default class PointCloudReconstructor implements Plugin {
icon = "";
name = "语义化点云重建";
version = 0.1;
modalInstance: ModalReactive | undefined = undefined;
componentRef = ref();
async install() {
// console.log(`%c 语义化点云重建 %c 版本:0.1.0`, 'background: #35495e; padding: 4px; border-radius: 3px 0 0 3px; color: #fff',
// 'background: #41b883; padding: 4px; border-radius: 0 3px 3px 0; color: #fff');
}
async run() {
this.componentRef = ref();
this.modalInstance = window.$modal.create({
title: this.name,
preset: "card",
maskClosable: false,
style: {
width: '90%',
maxWidth: '800px'
},
onAfterLeave: () => {
this.componentRef.value.handleClose();
this.finish();
},
content: () => {
return h(PointCloudReconstructorComponent, {
ref: this.componentRef
}, "")
},
})
}
// 关闭插件
finish() {
this.modalInstance && this.modalInstance.destroy();
this.componentRef = ref();
}
uninstall(): void { }
}