feat(All):Initial
This commit is contained in:
@@ -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`;
|
||||
}
|
||||
@@ -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 { }
|
||||
}
|
||||
Reference in New Issue
Block a user