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,44 @@
<template>
<n-dropdown v-bind="attrs" :x="x" :y="y" :options="options" :show="visible" :on-clickoutside="onClickoutside" @select="handleSelect" />
</template>
<script setup lang="ts">
import { useAttrs,ref } from "vue";
withDefaults(defineProps<{
options: any[];
}>(), {
options:[] as any,
})
const emits = defineEmits(['select']);
const attrs = useAttrs();
const visible = ref(false);
const x = ref(0);
const y = ref(0);
function onClickoutside(){
visible.value = false;
}
function handleSelect(key:string){
emits('select',key)
visible.value = false;
}
function show(_x,_y){
x.value = _x;
y.value = _y;
visible.value = true;
}
defineExpose({
show
})
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,108 @@
<script setup lang="ts">
withDefaults(defineProps<{
visible:boolean
}>(),{
visible:false
})
</script>
<template>
<div class="w-full h-full flex flex-col justify-center items-center bg-#1D82B8 relative z-9999" v-if="visible">
<div class="es-folding-cube bg-#fff">
<div class="es-cube1 es-cube"></div>
<div class="es-cube2 es-cube"></div>
<div class="es-cube4 es-cube"></div>
<div class="es-cube3 es-cube"></div>
</div>
</div>
</template>
<style scoped lang="less">
.es-folding-cube {
margin: 20px auto;
width: 40px;
height: 40px;
position: relative;
-webkit-transform: rotateZ(45deg);
transform: rotateZ(45deg);
}
.es-folding-cube .es-cube {
float: left;
width: 50%;
height: 50%;
position: relative;
-webkit-transform: scale(1.1);
-ms-transform: scale(1.1);
transform: scale(1.1);
}
.es-folding-cube .es-cube:before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #1D82B8;
-webkit-animation: es-foldCubeAngle 2.4s infinite linear both;
animation: es-foldCubeAngle 2.4s infinite linear both;
-webkit-transform-origin: 100% 100%;
-ms-transform-origin: 100% 100%;
transform-origin: 100% 100%;
}
.es-folding-cube .es-cube2 {
-webkit-transform: scale(1.1) rotateZ(90deg);
transform: scale(1.1) rotateZ(90deg);
}
.es-folding-cube .es-cube3 {
-webkit-transform: scale(1.1) rotateZ(180deg);
transform: scale(1.1) rotateZ(180deg);
}
.es-folding-cube .es-cube4 {
-webkit-transform: scale(1.1) rotateZ(270deg);
transform: scale(1.1) rotateZ(270deg);
}
.es-folding-cube .es-cube2:before {
-webkit-animation-delay: 0.3s;
animation-delay: 0.3s;
}
.es-folding-cube .es-cube3:before {
-webkit-animation-delay: 0.6s;
animation-delay: 0.6s;
}
.es-folding-cube .es-cube4:before {
-webkit-animation-delay: 0.9s;
animation-delay: 0.9s;
}
@-webkit-keyframes es-foldCubeAngle {
0%, 10% {
-webkit-transform: perspective(140px) rotateX(-180deg);
transform: perspective(140px) rotateX(-180deg);
opacity: 0;
} 25%, 75% {
-webkit-transform: perspective(140px) rotateX(0deg);
transform: perspective(140px) rotateX(0deg);
opacity: 1;
} 90%, 100% {
-webkit-transform: perspective(140px) rotateY(180deg);
transform: perspective(140px) rotateY(180deg);
opacity: 0;
}
}
@keyframes es-foldCubeAngle {
0%, 10% {
-webkit-transform: perspective(140px) rotateX(-180deg);
transform: perspective(140px) rotateX(-180deg);
opacity: 0;
} 25%, 75% {
-webkit-transform: perspective(140px) rotateX(0deg);
transform: perspective(140px) rotateX(0deg);
opacity: 1;
} 90%, 100% {
-webkit-transform: perspective(140px) rotateY(180deg);
transform: perspective(140px) rotateY(180deg);
opacity: 0;
}
}
</style>
@@ -0,0 +1,25 @@
<template>
<n-alert type="success" :show-icon="false" closable class="mb-2">
<div class="flex items-center">
<n-icon :size="20">
<Link />
</n-icon>
<a :href="url" target="_blank" class="ml-2">{{ t("other.Related document") }}</a>
</div>
</n-alert>
</template>
<script setup lang="ts">
import {t} from "@/language";
import {Link} from "@vicons/carbon";
withDefaults(defineProps<{
url: string
}>(), {
url: "",
})
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import {Help} from '@vicons/carbon';
withDefaults(defineProps<{
label:string
}>(),{
label:""
})
</script>
<template>
<div class="flex items-center justify-end">
<span>{{ label }}</span>
<n-tooltip trigger="hover">
<template #trigger>
<n-icon :size="16" :depth="3" class="ml-5px mb-1px cursor-pointer">
<Help />
</n-icon>
</template>
<slot></slot>
</n-tooltip>
</div>
</template>
<style scoped lang="less">
</style>
@@ -0,0 +1,44 @@
<template>
<div class="es-input flex items-center w-full">
<label v-if="label">{{ label }}</label>
<div v-if="defaultNoBorder && noFocus" @click="handleClickText" class="w-full">{{value}}</div>
<n-input v-else ref="inRef" :value="value" @input="handleInput" @change="handleChange" @blur="noFocus = true" type="text" v-bind="attrs" />
</div>
</template>
<script setup lang="ts">
import {useAttrs,ref,nextTick} from "vue";
withDefaults(defineProps<{
value: string,
label: string,
defaultNoBorder: boolean,
}>(), {
value: '',
label: '',
defaultNoBorder: false
})
const emits = defineEmits(["update:value","change"])
const attrs = useAttrs();
const inRef = ref();
const noFocus = ref(true);
async function handleClickText(){
noFocus.value = false;
await nextTick();
inRef.value.focus();
}
function handleInput(value: string) {
emits("update:value", value);
}
function handleChange(value: string) {
emits("change", value);
}
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,153 @@
<template>
<n-input-number ref="inRef" v-model:value="esNumber" :step="step" :min="min" :max="max" v-bind="attrs"
@update:value="handlerChange" @mousedown.stop="onMouseDown">
<template #prefix v-if="label">
<n-text>
&nbsp;{{ label }} :
</n-text>
</template>
<template #suffix v-if="unit !== null">
<n-text type="success">
{{ unit }}
</n-text>
</template>
</n-input-number>
</template>
<script lang="ts" setup>
import {ref, useAttrs, onMounted, watch,computed} from "vue";
import {NInputNumber} from 'naive-ui';
import {Utils} from "@astral3d/engine";
import {useGlobalConfigStore} from "@/store/modules/globalConfig";
const globalConfigStore = useGlobalConfigStore();
const primaryColor = computed(() => (globalConfigStore.mainColor as IConfig.Color).hex);
const attrs = useAttrs();
let props = withDefaults(defineProps<{
value: number,
label?: string | null,
unit?: string | null,
min?: number,
max?: number,
step?:number,
//保留小数位
decimal?: number
}>(), {
value: 0,
min: -Infinity,
max: Infinity,
decimal: 0
})
const emits = defineEmits(["update:value", "change"])
const inRef = ref();
const step = ref(props.step);
const esNumber = ref(0);
function handlerChange(value: number) {
if (value === null) {
esNumber.value = props.value;
return;
}
value = parseFloat(value.toFixed(props.decimal));
emits("update:value", value);
emits("change", value)
}
onMounted(() => {
esNumber.value = props.value;
if (props.decimal !== 0 && Utils.isNil(step.value)) {
step.value = Number(`${Number(0).toFixed(props.decimal - 1)}1`);
}
})
watch(() => props.value, (newVal) => {
esNumber.value = newVal;
})
let distance = 0;
let onMouseDownValue = 0;
const pointer = {x: 0, y: 0};
const prevPointer = {x: 0, y: 0};
function onMouseDown(event) {
event.preventDefault();
if(!Number.isFinite(esNumber.value) || esNumber.value === undefined || esNumber.value === null) return;
distance = 0;
onMouseDownValue = esNumber.value;
prevPointer.x = event.clientX;
prevPointer.y = event.clientY;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
function onMouseMove(event) {
event.stopPropagation();
pointer.x = event.clientX;
pointer.y = event.clientY;
distance += (pointer.x - prevPointer.x) - (pointer.y - prevPointer.y);
let value = onMouseDownValue + (distance / (event.shiftKey ? 5 : 50)) * (step.value || 1);
value = Math.min(props.max, Math.max(props.min, value));
if (onMouseDownValue !== value && value !== null) {
value = parseFloat(value.toFixed(props.decimal));
esNumber.value = value;
handlerChange(value);
}
prevPointer.x = pointer.x;
prevPointer.y = pointer.y;
}
function onMouseUp(event) {
event.stopPropagation();
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
</script>
<style lang="less" scoped>
.n-input-number {
:deep(.n-input) {
cursor: ns-resize;
background-color: transparent;
border-radius: 0;
.n-input-wrapper {
padding: 0;
text-align: center;
}
.n-input__suffix{
margin-left: 1px;
}
.n-input__border{
display: none;
}
.n-input__state-border{
border-top: none;
border-right: none;
border-left: none;
border-bottom-style: dashed;
}
}
:deep(.n-input__input-el){
color: v-bind(primaryColor) !important;
cursor: ns-resize;
}
}
</style>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import {ConditionPoint} from '@vicons/carbon';
import {t} from "@/language";
import {useAnimationStore} from "@/store/modules/animation";
withDefaults(defineProps<{
label: string,
attr: string
}>(), {
label: "",
attr: ""
})
const {addKeyframe} = useAnimationStore();
</script>
<template>
<span class="flex items-center">
<n-tooltip trigger="hover">
<template #trigger>
<n-button text @click="addKeyframe(attr)">
<template #icon>
<n-icon size="12">
<ConditionPoint/>
</n-icon>
</template>
</n-button>
</template>
{{ t("extra.Add keyframe") }}
</n-tooltip>
<label>{{ label }}</label>
</span>
</template>
<style scoped lang="less">
</style>
@@ -0,0 +1,50 @@
<template>
<n-select size="small" v-bind="attrs" v-model:value="selected" @update:value="update" :options="options"/>
</template>
<script setup lang="ts">
import {ref,useAttrs} from "vue";
import {t} from "@/language";
const props =withDefaults(defineProps<{
value: string
}>(), {
value: ''
})
const emits = defineEmits(["update:value"])
const attrs = useAttrs();
const selected = ref(props.value);
const options = [
{ label: t('layout.sider.particle.Ease linear'), value: 'easeLinear' },
{ label: t('layout.sider.particle.Ease in quad'), value: 'easeInQuad' },
{ label: t('layout.sider.particle.Ease out quad'), value: 'easeOutQuad' },
{ label: t('layout.sider.particle.Ease in out quad'), value: 'easeInOutQuad' },
{ label: t('layout.sider.particle.Ease in cubic'), value: 'easeInCubic' },
{ label: t('layout.sider.particle.Ease out cubic'), value: 'easeOutCubic' },
{ label: t('layout.sider.particle.Ease in out cubic'), value: 'easeInOutCubic' },
{ label: t('layout.sider.particle.Ease in quart'), value: 'easeInQuart' },
{ label: t('layout.sider.particle.Ease out quart'), value: 'easeOutQuart' },
{ label: t('layout.sider.particle.Ease in out quart'), value: 'easeInOutQuart' },
{ label: t('layout.sider.particle.Ease in sine'), value: 'easeInSine' },
{ label: t('layout.sider.particle.Ease out sine'), value: 'easeOutSine' },
{ label: t('layout.sider.particle.Ease in out sine'), value: 'easeInOutSine' },
{ label: t('layout.sider.particle.Ease in expo'), value: 'easeInExpo' },
{ label: t('layout.sider.particle.Ease out expo'), value: 'easeOutExpo' },
{ label: t('layout.sider.particle.Ease in out expo'), value: 'easeInOutExpo' },
{ label: t('layout.sider.particle.Ease in circ'), value: 'easeInCirc' },
{ label: t('layout.sider.particle.Ease out circ'), value: 'easeOutCirc' },
{ label: t('layout.sider.particle.Ease in out circ'), value: 'easeInOutCirc' },
{ label: t('layout.sider.particle.Ease in back'), value: 'easeInBack' },
{ label: t('layout.sider.particle.Ease out back'), value: 'easeOutBack' },
{ label: t('layout.sider.particle.Ease in out back'), value: 'easeInOutBack' }
];
function update(value: string) {
emits("update:value", value);
}
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import {ref, computed, onMounted} from 'vue';
import {useThemeVars} from 'naive-ui'
import {useGlobalConfigStore} from "@/store/modules/globalConfig";
import {App} from "@astral3d/engine";
import Logo from "@/components/header/Logo.vue";
import EsPluginDialog from "@/components/es/plugin/EsPluginDialog.vue";
const globalConfigStore = useGlobalConfigStore();
const primaryColor = computed(() => (globalConfigStore.mainColor as IConfig.Color).hex);
const themeVars = useThemeVars();
const baseColor = computed(() => themeVars.value.baseColor);
const toolsDialogVisible = ref(false);
const pluginRef = ref();
const x = ref(315);
const y = ref(document.body.clientHeight - 80);
const cursor = ref("pointer");
// 是否正在拖动的标志
const isDragging = ref(false);
function startDrag(downEvent: MouseEvent) {
const clientHeight = (document.documentElement.clientHeight || document.body.clientHeight) - 25;
const clientWidth = (document.documentElement.clientWidth || document.body.clientWidth) - 25;
const lastClientX = downEvent.clientX;
const lastClientY = downEvent.clientY;
const dragging = (moveEvent: MouseEvent) => {
if (Math.abs(lastClientX - moveEvent.clientX) > 1 || Math.abs(lastClientY - moveEvent.clientY) > 1) {
isDragging.value = true;
cursor.value = "grab";
let boxLeft = moveEvent.clientX;
let boxTop = moveEvent.clientY;
if (boxLeft < 25) {
boxLeft = 25;
} else if (boxLeft > clientWidth) {
boxLeft = clientWidth;
}
if (boxTop < 25) {
boxTop = 25;
} else if (boxTop > clientHeight) {
boxTop = clientHeight;
}
x.value = boxLeft - 25;
y.value = boxTop - 25;
}
}
const stopDrag = () => {
if (!isDragging.value) {
handleClick()
}
App.storage.setOtherItem("es-plugins-position", {
x: x.value,
y: y.value
})
isDragging.value = false;
cursor.value = "pointer";
document.removeEventListener('pointermove', dragging);
document.removeEventListener('pointerup', stopDrag);
}
// 添加鼠标移动和释放时的事件监听器
document.addEventListener('pointermove', dragging);
document.addEventListener('pointerup', stopDrag);
}
function handleClick() {
toolsDialogVisible.value = !toolsDialogVisible.value;
}
onMounted(async () => {
const xy = await App.storage.getOtherItem("es-plugins-position") as {x:number,y:number};
if (xy) {
x.value = xy.x > document.body.clientWidth ? 315 : xy.x;
y.value = xy.y > document.body.clientHeight ? document.body.clientHeight - 120 : xy.y;
}
})
</script>
<template>
<div ref="pluginRef" class="es-tools w-40px h-40px fixed z-9000 flex justify-center items-center"
:style="{ left: `${x}px`, top: `${y}px`,cursor:`${cursor}` }"
@pointerdown="startDrag">
<Logo class="w-26px h-26px"/>
</div>
<EsPluginDialog v-model:visible="toolsDialogVisible"/>
</template>
<style scoped lang="less">
.es-tools {
border: 4px solid transparent;
border-radius: 50%;
background-clip: padding-box, border-box;
background-origin: padding-box, border-box;
background-image: linear-gradient(to right, v-bind(baseColor), v-bind(baseColor)), linear-gradient(120deg, v-bind(primaryColor), #578AEF);
transition: background-image .5s;
& > svg {
transition: transform .5s;
}
&:hover {
transform: rotate(45deg);
& > svg {
transform: rotate(45deg) scale(1.1);
}
}
}
</style>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import {ref, onMounted, nextTick} from 'vue';
import type {TreeOption} from 'naive-ui';
import {CaretRight, CaretDown} from '@vicons/carbon';
import {findTreeNode, markLeafNodes} from "@/utils/common/utils";
const props = withDefaults(defineProps<{
trigger?: 'hover' | 'click' | 'focus' | 'manual',
options: TreeOption[],
value: string | number
}>(), {
trigger: "hover",
options: () => [],
value: ""
})
const emits = defineEmits(['update:value', "select", "show","click"]);
const selectKey = ref(props.value);
const selectOption = ref<TreeOption | null>(null);
const treeShow = ref(false);
const treeNodeProps = ({ option }: { option: TreeOption }) => {
return {
onClick() {
emits("click", option);
}
}
}
onMounted(async () => {
await nextTick();
markLeafNodes(props.options);
selectOption.value = findTreeNode(props.options, selectKey.value);
})
function handleTreeShowChange(show: boolean) {
treeShow.value = show;
emits("show", show);
}
function handleTreeSelect(keys: Array<string | number>) {
emits("update:value", keys[0]);
selectKey.value = keys[0];
selectOption.value = findTreeNode(props.options, selectKey.value);
emits("select", selectOption.value, props.options);
}
</script>
<template>
<n-button v-bind="$attrs" @click="emits('click',selectOption)">
<template #icon>
<n-popover placement="bottom" :trigger="trigger" @update:show="handleTreeShowChange">
<template #trigger>
<n-icon>
<CaretRight v-if="!treeShow"/>
<CaretDown v-else/>
</n-icon>
</template>
<n-tree :data="options" block-line @update:selected-keys="handleTreeSelect"
default-expand-all :default-selected-keys="[value]" :cancelable="false"
:indent="18" :node-props="treeNodeProps"/>
</n-popover>
</template>
{{ selectOption?.label || "" }}
</n-button>
</template>
<style scoped lang="less">
</style>
@@ -0,0 +1,262 @@
<script lang="ts" setup>
import {ref, onMounted, watch,nextTick} from "vue";
import type {UploadFileInfo} from 'naive-ui';
import * as THREE from 'three';
import { TGALoader } from 'three/examples/jsm/loaders/TGALoader.js';
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js';
import { Loader } from "@astral3d/engine";
const props = withDefaults(defineProps<{
texture: THREE.Color | THREE.Texture | THREE.CubeTexture | null,
mapping?: THREE.Mapping,
width?: string,
height?: string,
disabled?: boolean
}>(),{
texture: null,
mapping:THREE.Texture.DEFAULT_MAPPING,
width: "2rem",
height: "2rem",
disabled:false
})
const emits = defineEmits(["update:texture", "change"]);
watch(() => props.texture, () => {
if(!props.texture) return;
setValue(props.texture)
})
const cache = new Map();
const file = ref<UploadFileInfo[]>([]);
const uploadRef = ref();
const canvasRef = ref();
onMounted(async () => {
await nextTick();
if(props.texture){
setValue(props.texture)
}
canvasRef.value.addEventListener('drop', function (event) {
event.preventDefault();
event.stopPropagation();
loadFile(event.dataTransfer.files[0]);
});
})
function updateFileList(fList: UploadFileInfo[]) {
//永远取最新值
file.value = [fList[fList.length - 1]];
file.value[0].file !== null && loadFile(file.value[0].file as File);
}
function canvasClick() {
if(props.disabled) return;
//uploadRef.value.clear();
uploadRef.value.openOpenFileDialog();
}
function setValue(newTexture) {
const context = canvasRef.value.getContext('2d');
// 如果画布不可见,则上下文似乎可以为空
if (context) {
// 始终在设置新纹理之前清除上下文,因为新纹理可能具有透明度
context.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
}
if (newTexture !== null) {
const image = newTexture.image;
if (image && image.width > 0) {
const scale = canvasRef.value.width / image.width;
if ( newTexture.isDataTexture || newTexture.isCompressedTexture ) {
const canvas2 = renderToCanvas(newTexture);
context.drawImage( canvas2, 0, 0, image.width * scale, image.height * scale );
} else {
context.drawImage( image, 0, 0, image.width * scale, image.height * scale );
}
} else {
canvasRef.value.title = newTexture.sourceFile + ' (error)';
}
if (file.value.length === 0) {
file.value = [newTexture];
}
emits("update:texture", newTexture);
} else {
canvasRef.value.title = 'empty';
uploadRef.value.clear();
file.value = [];
}
}
function loadFile(file) {
//文件后缀
const extension = file.name.split('.').pop().toLowerCase();
const reader = new FileReader();
const hash = `${file.lastModified}_${file.size}_${file.name}`;
if (cache.has(hash)) {
const texture = cache.get(hash);
setValue(texture);
emits("change", texture);
} else if (extension === 'hdr' || extension === 'pic') {
reader.addEventListener('load', function (event) {
// 假设RGBE/Radiance HDR图像格式
const loader = new RGBELoader();
loader.load(event.target?.result as string, function (hdrTexture) {
// @ts-ignore
hdrTexture.sourceFile = file.name;
cache.set(hash, hdrTexture);
setValue(hdrTexture);
emits("change", hdrTexture);
});
});
reader.readAsDataURL(file);
} else if (extension === 'tga') {
reader.addEventListener('load', function (event) {
const loader = new TGALoader();
loader.load(event.target?.result as string, ( texture ) => {
texture.colorSpace = THREE.SRGBColorSpace;
// @ts-ignore
texture.sourceFile = file.name;
cache.set(hash, texture);
setValue(texture);
emits("change", texture);
});
}, false);
reader.readAsArrayBuffer(file);
} else if (extension === 'ktx2') {
reader.addEventListener( 'load', function ( event ) {
const arrayBuffer = event.target?.result as ArrayBuffer;
const blobURL = URL.createObjectURL( new Blob([arrayBuffer]) );
const ktx2Loader = Loader.ktx2Loader;
ktx2Loader.load(blobURL, function ( texture ) {
texture.colorSpace = THREE.SRGBColorSpace;
// @ts-ignore
texture.sourceFile = file.name;
texture.needsUpdate = true;
cache.set(hash, texture);
setValue( texture );
emits("change", texture);
ktx2Loader.dispose();
});
});
reader.readAsArrayBuffer(file);
}else if (file.type.match('image.*')) {
reader.addEventListener('load', function (event) {
const image = document.createElement('img');
image.addEventListener('load', function () {
const texture = new THREE.Texture(this, props.mapping);
// @ts-ignore
texture.sourceFile = file.name;
texture.needsUpdate = true;
cache.set(hash, texture);
setValue(texture);
emits("change", texture);
}, false);
image.src = event.target?.result as string;
}, false);
reader.readAsDataURL(file);
}else if(extension === 'exr'){
reader.addEventListener( 'load', ( event ) => {
const arrayBuffer = event.target?.result as ArrayBuffer;
const blobURL = URL.createObjectURL(new Blob([arrayBuffer]));
Loader.exrLoader.load(blobURL, (texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
// @ts-ignore
texture.sourceFile = file.name;
cache.set(hash, texture);
setValue(texture);
emits("change", texture);
});
});
reader.readAsArrayBuffer(file);
}
}
function setColorSpace(colorSpace: string) {
if (props.texture && !(props.texture instanceof THREE.Color)) {
props.texture.colorSpace = colorSpace;
}
}
let renderer;
function renderToCanvas(texture) {
if (renderer === undefined) {
renderer = new THREE.WebGLRenderer();
}
const image = texture.image;
renderer.setSize(image.width, image.height, false);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const material = new THREE.MeshBasicMaterial({map: texture});
const quad = new THREE.PlaneGeometry(2, 2);
const mesh = new THREE.Mesh(quad, material);
scene.add(mesh);
renderer.render(scene, camera);
return renderer.domElement;
}
defineExpose({
setColorSpace, setValue
})
</script>
<template>
<div class="es-texture">
<n-upload list-type="image-card" ref="uploadRef" v-show="file.length === 0" @update:file-list="updateFileList" :disabled="disabled" />
<canvas class="es-texture-canvas" ref="canvasRef" v-show="file.length === 1" @click="canvasClick()" title="Texture"></canvas>
</div>
</template>
<style lang="less" scoped>
:deep(.n-upload) {
.n-upload-trigger.n-upload-trigger--image-card,
.n-upload-file--image-card-type {
width: v-bind(width);
height: v-bind(height);
}
}
.es-texture {
width: v-bind(width);
height: v-bind(height);
&-canvas {
width: v-bind(width);
height: v-bind(height);
cursor: pointer;
}
}
</style>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import {AlertCircleOutline} from '@vicons/ionicons5';
withDefaults(defineProps<{
size?: string,
content?: string,
trigger?: 'click' | 'hover' | 'focus' | 'manual',
placement?: 'top-start' | 'top' | 'top-end' | 'right-start' | 'right' | 'right-end' | 'bottom-start' | 'bottom' | 'bottom-end' | 'left-start' | 'left' | 'left-end'
}>(), {
size: '18',
content: '',
trigger: "hover",
placement: 'top'
})
</script>
<template>
<span class="flex items-center justify-center cursor-pointer">
<span v-if="content" class="mr-2px">{{ content }}</span>
<n-tooltip :trigger="trigger" :placement="placement">
<template #trigger>
<n-icon :size="size">
<AlertCircleOutline/>
</n-icon>
</template>
<slot></slot>
</n-tooltip>
</span>
</template>
@@ -0,0 +1,52 @@
<script setup lang="ts">
import {ref} from "vue";
import {t} from "@/language";
import Logo from "@/components/header/Logo.vue";
import EsPluginList from "@/components/es/plugin/EsPluginList.vue";
withDefaults(defineProps<{
visible:boolean
}>(),{
visible:false
})
const emits = defineEmits(["update:visible"]);
const searchText = ref("");
function handleClose(){
emits("update:visible",false);
}
</script>
<template>
<n-modal :show="visible" @maskClick="handleClose" @esc="handleClose">
<n-card class="es-tools-dialog fixed top-20vh w-90% max-w-800px" :bordered="false" role="dialog" aria-modal="true">
<template #header>
<n-input v-model:value="searchText" type="text" size="large" autofocus clearable class="font-500"
placeholder="Hi, Astral3D Editor">
<template #prefix>
<Logo class="w-30px h-30px" />
</template>
</n-input>
</template>
<n-text depth="3" class="relative bottom-10px left-5px"> {{ t("plugin['Optional plug-in']") }} </n-text>
<EsPluginList @close="handleClose" :search="searchText" />
</n-card>
</n-modal>
</template>
<style lang="less">
@media screen and (max-width: 800px) {
.es-tools-dialog{
left:5%;
}
}
@media screen and (min-width: 800px) {
.es-tools-dialog{
left:calc(50% - 400px);
}
}
</style>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import {computed} from "vue";
import {usePluginStore} from "@/store/modules/plugin";
import Logo from "@/components/header/Logo.vue";
const props = withDefaults(defineProps<{
search:string
}>(),{
search:""
})
const emits = defineEmits(['close']);
const pluginStore = usePluginStore();
const list = computed(() => pluginStore.getPluginsList().value.filter(l => l.name.toLowerCase().indexOf(props.search.toLowerCase()) !== -1))
function handleRunPlugin(plugin:IPlugin.Item){
emits("close");
window.viewer.modules.plugin.run(plugin.name);
}
</script>
<template>
<div class="grid grid-cols-5 gap-4">
<n-card hoverable :bordered="false" v-for="plugin in list" :key="plugin.name" size="small"
@click.stop="handleRunPlugin(plugin)"
content-class="flex flex-col justify-center items-center cursor-pointer">
<n-image v-if="plugin.icon" :src="plugin.icon" object-fit="cover" width="60"
preview-disabled />
<Logo v-else class="w-60px" />
<span class="mt-8px text-center">{{ plugin.name }}</span>
</n-card>
</div>
</template>
<style scoped lang="less">
</style>
@@ -0,0 +1,11 @@
<script setup lang="ts">
</script>
<template>
</template>
<style scoped lang="less">
</style>
@@ -0,0 +1,354 @@
<script setup lang="ts">
import {computed, ref,watchEffect,nextTick} from "vue";
import { useThemeVars } from 'naive-ui';
import {Upload, Delete, Link} from '@vicons/carbon';
import {Loader} from "@astral3d/engine";
import {t} from "@/language";
import GLTFHandlerForm from "./glTFHandler/GLTFHandlerForm.vue";
const props = defineProps({
onOptimize: {
type: Function,
required: true
},
onFinish: {
type: Function,
required: true
}
});
// 优化结果文件
let outputFiles:File[] = [];
const themeVars = useThemeVars();
const borderColor = computed(() => themeVars.value.borderColor);
const primaryColor = computed(() => themeVars.value.primaryColor);
const hoverColor = computed(() => themeVars.value.hoverColor);
const textColor3 = computed(() => themeVars.value.textColor3);
const formRef = ref();
const loading = ref(false);
const loadingText = ref<string>(t("plugin.gltfHandler['The model is being optimized...']"));
const uploadInputRef = ref();
const fileList = ref<File[]>([]);
const optimizeDone = ref(false);
const logInstRef = ref();
const log = ref("");
watchEffect(() => {
if (log.value) {
nextTick(() => {
logInstRef.value?.scrollTo({ position: 'bottom', silent: true })
})
}
})
function setLoading(boolean:boolean){
loading.value = boolean;
}
function addLog(_log:string){
log.value += `${_log}\n`;
loadingText.value = _log;
}
// 拖拽文件进入
function handleFilesDrop(event:DragEvent){
if (fileList.value.length > 10) {
window.$message?.warning(t("plugin.gltfHandler['You can upload a maximum of 10 files']"));
return;
}
const originFiles = event.dataTransfer?.files;
if(!originFiles || !originFiles.length) return;
const files:File[] = [];
for (let i = 0; i < originFiles.length; i++) {
const s = originFiles[i].name.split(".");
if(["glb","gltf"].includes(s[s.length - 1].toLowerCase())){
files.push(originFiles[i]);
}
}
// 处理拖拽进来的文件
for (let i = 0; i < files.length; i++) {
if (!fileList.value.find(item => item.lastModified === files[i].lastModified && item.name === files[i].name)) {
if (fileList.value.length >= 10) {
fileList.value.length = 10;
break;
}
fileList.value.push(files[i]);
}
}
}
function handleUploadChange(event){
const files = event.target.files;
if (files) {
for (let i = 0; i < files.length; i++) {
if (!fileList.value.find(item => item.lastModified === files[i].lastModified && item.name === files[i].name)) {
if (fileList.value.length >= 10) {
fileList.value.length = 10;
break;
}
fileList.value.push(files[i]);
}
}
}
// TODO:暂时只支持单文件处理
// const file = event.target.files[0];
// if(file){
// fileList.value = [file];
// }
}
function handleSelectFile() {
if(optimizeDone.value){
window.$dialog.warning({
title: window.$t('other.warning'),
content: window.$t("plugin.gltfHandler['Re-selecting the file will clear the previous optimization result. Do you want to continue?']"),
positiveText: window.$t('other.Ok'),
negativeText: window.$t('other.Cancel'),
onPositiveClick: async () => {
outputFiles = [];
optimizeDone.value = false;
log.value = "";
uploadInputRef.value.click();
},
});
}else{
if (fileList.value.length > 10) {
window.$message?.warning(t("plugin.gltfHandler['You can upload a maximum of 10 files']"));
return;
}
uploadInputRef.value.click();
}
}
function handleDeleteFile(file:File) {
if(optimizeDone.value){
window.$dialog.warning({
title: window.$t('other.warning'),
content: window.$t("plugin.gltfHandler['This operation will clear the relevant optimization results. Do you want to continue?']"),
positiveText: window.$t('other.Ok'),
negativeText: window.$t('other.Cancel'),
onPositiveClick: async () => {
fileList.value = fileList.value.filter(item => !(item.lastModified === file.lastModified && item.name === file.name));
outputFiles = outputFiles.filter(item => !(item.name.replace("_astral3d.optimize","") === file.name));
if(!fileList.value.length){
optimizeDone.value = false;
log.value = "";
}
},
});
}else{
fileList.value = fileList.value.filter(item => !(item.lastModified === file.lastModified && item.name === file.name));
}
}
// 提交优化
function handleSubmit(){
if(fileList.value.length === 0) return;
setLoading(true);
handleOptimize(formRef.value.model)
}
// 处理优化
async function handleOptimize(model:IPlugin.GLTFHandlerOptimizeModel) {
for(let file of fileList.value){
outputFiles.push(await props.onOptimize(model, file))
}
optimizeDone.value = true;
setLoading(false);
}
// 下载优化结果
function handleDownload(){
if(outputFiles.length === 0) return;
outputFiles.forEach(outputFile => {
const url = URL.createObjectURL(outputFile);
const a = window.document.createElement('a');
a.href = url;
a.download = outputFile.name;
window.document.body.appendChild(a);
a.click();
window.document.body.removeChild(a);
URL.revokeObjectURL(url);
})
}
// 优化结果导入场景
function handleImportScene(){
if(outputFiles.length === 0) return;
Loader.loadFiles(outputFiles, undefined).then(() => {
window.$message?.success(t("plugin.gltfHandler['Import success!']"));
// outputFiles = [];
// optimizeDone.value = false;
});
// 关闭插件弹窗
// props.onFinish();
// log.value = "";
}
defineExpose({
setLoading,
addLog
})
</script>
<template>
<n-spin :show="loading" >
<template #description>
{{ loadingText }}
</template>
<div class="optimize-upload h-150px grid gap-2"
:style="{gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))'}">
<div class="optimize-upload-card" v-if="fileList.length === 0" @click="handleSelectFile" @dragover.stop.prevent @drop.stop.prevent="handleFilesDrop">
<input v-show="false" type="file" ref="uploadInputRef" multiple="multiple" accept=".glb,.gltf" @change="handleUploadChange" />
<n-icon size="48" :depth="3" class="mb-12px">
<Upload/>
</n-icon>
<n-text class="mb-6px text-16px"> {{ t("plugin.gltfHandler['Select the.glb/.gltf file']") }} </n-text>
<n-text depth="3"> {{ t("plugin.gltfHandler['Batch processing is supported (up to 10)']") }} </n-text>
</div>
<template v-else>
<div class="optimize-upload-list">
<div v-for="(file,index) in fileList" :key="index" class="optimize-upload-list-item">
<n-icon size="16">
<Link/>
</n-icon>
<span class="optimize-upload-list-item-name" :title="file.name">{{ file.name }}</span>
<n-icon size="16" class="optimize-upload-list-item-del" @click.stop="handleDeleteFile(file)">
<Delete/>
</n-icon>
</div>
</div>
<n-log ref="logInstRef" :log="log" trim class="!h-150px" />
</template>
</div>
<GLTFHandlerForm ref="formRef" />
<div class="w-full flex justify-center mt-10px">
<n-button v-if="!optimizeDone" type="primary" @click="handleSubmit">
{{ t("plugin.gltfHandler.Optimize") }}
</n-button>
<template v-else>
<n-button type="primary" @click="handleDownload">
{{ t("plugin.gltfHandler.Download") }}
</n-button>
<n-button type="primary" @click="handleImportScene" class="ml-4">
{{ t("plugin.gltfHandler.Import scene") }}
</n-button>
</template>
</div>
</n-spin>
</template>
<style scoped lang="less">
.optimize-upload {
&-card {
position: relative;
text-align: center;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.02);
border: 1px dashed v-bind(borderColor);
border-radius: 8px;
cursor: pointer;
outline: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
transition: border-color 0.3s, width 0.3s;
&:hover {
border-color: v-bind(primaryColor);
}
}
&-list {
width: 100%;
height: 100%;
overflow-y: auto;
transition: opacity 0.3s, height 0.3s;
&::before {
display: table;
width: 0;
height: 0;
content: "";
}
&-item {
color: v-bind(primaryColor);
position: relative;
height: 22px;
margin-top: 8px;
font-size: 14px;
display: flex;
align-items: center;
transition: background-color .3s;
cursor: pointer;
padding: 0 8px;
&:hover {
background-color: v-bind(hoverColor);
.optimize-upload-list-item-del{
opacity: 1;
}
}
&-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: 0 6px;
line-height: 1.57;
flex: auto;
max-width: 85%;
transition: all .3s;
}
&-del {
position: absolute;
right: 8px;
color: v-bind(textColor3);
cursor: pointer;
opacity: 0;
transition: all .3s;
&:hover {
transform: scale(1.2);
}
&:active {
transform: scale(0.9);
}
}
}
}
}
</style>
@@ -0,0 +1,419 @@
<script setup lang="ts">
import { computed, ref, onMounted, nextTick, useTemplateRef, reactive } from "vue";
import { useThemeVars } from 'naive-ui';
import { Upload, Delete, Link } from '@vicons/carbon';
import * as THREE from 'three';
import { App,Loader, PointCloudReconstructor,AddObjectCommand } from "@astral3d/engine";
import { t } from "@/language";
import { createBasicScene } from "@/utils/common/scenes";
import ReconstructorForm from "./pointCloudReconstructor/ReconstructorForm.vue";
const themeVars = useThemeVars();
const borderColor = computed(() => themeVars.value.borderColor);
const primaryColor = computed(() => themeVars.value.primaryColor);
const hoverColor = computed(() => themeVars.value.hoverColor);
const textColor3 = computed(() => themeVars.value.textColor3);
const formRef = useTemplateRef("formRef");
const viewerRef = useTemplateRef("viewerRef");
const uploadInputRef = useTemplateRef("uploadInputRef");
const loading = ref(false);
const loadingText = ref<string>(t("plugin.pointCloudReconstructor['The point cloud is being reconstructed...']"));
const fileList = ref<File[]>([]);
const stats = reactive({
reconstructorDone: false,
percentage:0,
message:"",
// 下面是统计信息
totalPoints: "0",
reconstructedObjects: 0,
processingTime: "0s"
})
let tmpScene, tmpDispose;
let reconstructor, startTime;
// 点云模型对象,重建好的对象
let pointCloud,reconstructedGroup;
onMounted(async () => {
await nextTick();
const p = createBasicScene(viewerRef.value as HTMLDivElement);
tmpScene = p.scene;
tmpDispose = p.dispose;
})
// 拖拽文件进入
function handleFilesDrop(event: DragEvent) {
const originFiles = event.dataTransfer?.files;
if (!originFiles || !originFiles.length) return;
const files: File[] = [];
for (let i = 0; i < originFiles.length; i++) {
const s = originFiles[i].name.split(".");
if (["glb", "gltf", "ply", "pcd"].includes(s[s.length - 1].toLowerCase())) {
files.push(originFiles[i]);
}
}
if (files.length > 0) {
// 暂时只支持单个模型处理
fileList.value = [files[0]];
handleFileLoad();
}
}
// 手动选择文件
function handleSelectFile() {
if (stats.reconstructorDone) {
window.$dialog.warning({
title: window.$t('other.warning'),
content: window.$t("plugin.pointCloudReconstructor['Re-selecting the file will empty the previous reconstruction. Do you want to continue?']"),
positiveText: window.$t('other.Ok'),
negativeText: window.$t('other.Cancel'),
onPositiveClick: async () => {
fileList.value = [];
stats.reconstructorDone = false;
uploadInputRef.value?.click();
},
});
} else {
fileList.value = [];
uploadInputRef.value?.click();
}
}
// 文件变更
function handleUploadChange(event) {
const files = event.target.files;
if (files) {
fileList.value = [files[0]];
handleFileLoad();
}
}
// 文件模型加载
async function handleFileLoad() {
clearScene();
const verifyExec = (object3D:THREE.Points) => {
pointCloud = object3D;
stats.totalPoints = object3D.geometry.attributes.position.count.toLocaleString();
tmpScene.add(pointCloud);
}
const object3D = await Loader.loadFile(fileList.value[0], new THREE.LoadingManager(), null, false) as THREE.Points;
if (object3D.type === "Points") {
verifyExec(object3D);
} else if (object3D.children.length === 1 && object3D.children[0].type === "Points") {
verifyExec(object3D.children[0] as THREE.Points);
} else {
window.$message?.error(t("plugin.pointCloudReconstructor['The model is not a point cloud']"));
fileList.value = [];
}
}
// 删除已选择文件
function handleDeleteFile() {
if (stats.reconstructorDone) {
window.$dialog.warning({
title: window.$t('other.warning'),
content: window.$t("plugin.pointCloudReconstructor['This operation will empty the associated reconstructions. Do you want to continue?']"),
positiveText: window.$t('other.Ok'),
negativeText: window.$t('other.Cancel'),
onPositiveClick: async () => {
fileList.value = [];
stats.reconstructorDone = false;
clearScene();
},
});
} else {
fileList.value = [];
clearScene();
}
}
// 提交重建
function handleSubmit() {
if (fileList.value.length === 0 || !pointCloud) return;
const model = formRef.value?.model;
if (!model) return;
loading.value = true;
// 防止二次重建
if(stats.reconstructorDone && reconstructedGroup){
// 移除原来的重建结果
tmpScene.remove(reconstructedGroup);
reconstructedGroup = null;
// 显示点云
pointCloud.visible = true;
stats.reconstructedObjects = 0;
stats.processingTime = "0s";
}
if (!reconstructor) {
reconstructor = new PointCloudReconstructor();
}
reconstructor.colorTolerance = model.colorTolerance;
reconstructor.distanceThreshold = model.distanceThreshold;
reconstructor.minClusterSize = model.minClusterSize;
reconstructor.downsampleResolution = model.downsampleResolution;
// 处理几何数据
reconstructor.processGeometry(pointCloud.geometry);
startTime = performance.now();
// 开始重建
reconstructor.reconstruct(
(progress, status) => {
stats.percentage = Math.round(progress);
stats.message = status;
},
(objectGroup, totalObjects) => {
reconstructedGroup = objectGroup;
tmpScene.add(reconstructedGroup);
// 更新统计
stats.reconstructedObjects = totalObjects;
// 计算处理时间
const endTime = performance.now();
const duration = ((endTime - startTime) / 1000).toFixed(1);
stats.processingTime = `${duration}s`;
// 隐藏点云
pointCloud.visible = false;
stats.reconstructorDone = true;
loading.value = false;
// 更新状态
stats.message = `重建完成: ${totalObjects}个对象 (${duration}秒)`;
}
)
}
// 重建结果导入场景
function handleImportScene() {
if(!tmpScene || !reconstructedGroup) return;
App.execute(new AddObjectCommand(reconstructedGroup.clone()));
}
// 关闭插件
function handleClose(){
clearScene();
tmpDispose && tmpDispose();
reconstructor && reconstructor.dispose();
}
// 清除场景在此添加的内容
function clearScene(){
pointCloud && tmpScene.remove(pointCloud);
reconstructedGroup && tmpScene.remove(reconstructedGroup);
}
defineExpose({
handleClose
})
</script>
<template>
<n-spin :show="loading">
<template #description>
{{ loadingText }}
</template>
<n-progress type="line" :percentage="stats.percentage" indicator-placement="inside" :processing="loading" class="mb-3" />
<div class="reconstructor-upload grid gap-2"
:style="{ gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))' }">
<div class="reconstructor-upload-card !h-150px" v-if="fileList.length === 0" @click="handleSelectFile"
@dragover.stop.prevent @drop.stop.prevent="handleFilesDrop">
<input v-show="false" type="file" ref="uploadInputRef" accept=".glb,.gltf,.ply,.pcd"
@change="handleUploadChange" />
<n-icon size="48" :depth="3" class="mb-12px">
<Upload />
</n-icon>
<n-text class="mb-6px text-16px">
{{ t("plugin.pointCloudReconstructor['Select the Point Cloud model file']") + ":.glb,.gltf,.ply,.pcd" }}
</n-text>
<n-text depth="3">
{{ t("plugin.pointCloudReconstructor['Please semantically segment the point cloud first']") }}
</n-text>
</div>
<template v-else>
<div class="reconstructor-upload-list">
<div v-for="(file, index) in fileList" :key="index" class="reconstructor-upload-list-item">
<n-icon size="16">
<Link />
</n-icon>
<span class="reconstructor-upload-list-item-name" :title="file.name">{{ file.name }}</span>
<n-icon size="16" class="reconstructor-upload-list-item-del" @click.stop="handleDeleteFile()">
<Delete />
</n-icon>
</div>
</div>
</template>
</div>
<div ref="viewerRef" class="w-full h-400px mb-2 relative">
<span class="absolute bottom-2 left-4">{{ stats.message }}</span>
</div>
<n-grid :x-gap="50" :cols="3">
<n-gi>
<n-card size="small" embedded hoverable content-class="flex flex-col justify-center items-center">
<div>总点数</div>
<div class="stats-text text-1.2rem mt-2">{{ stats.totalPoints }}</div>
</n-card>
</n-gi>
<n-gi>
<n-card size="small" embedded hoverable content-class="flex flex-col justify-center items-center">
<div>重建对象</div>
<div class="stats-text text-1.2rem mt-2">{{ stats.reconstructedObjects }}</div>
</n-card>
</n-gi>
<n-gi>
<n-card size="small" embedded hoverable content-class="flex flex-col justify-center items-center">
<div>处理时间</div>
<div class="stats-text text-1.2rem mt-2">{{ stats.processingTime }}</div>
</n-card>
</n-gi>
</n-grid>
<ReconstructorForm ref="formRef" />
<div class="w-full flex justify-center mt-10px">
<n-button type="primary" :loading="loading" @click="handleSubmit">
{{ t("plugin.pointCloudReconstructor.Reconstruction") }}
</n-button>
<template v-if="stats.reconstructorDone">
<n-button type="primary" @click="handleImportScene" class="ml-4">
{{ t("plugin.gltfHandler.Import scene") }}
</n-button>
</template>
</div>
</n-spin>
</template>
<style scoped lang="less">
.reconstructor-upload {
margin-bottom: 15px;
&-card {
position: relative;
text-align: center;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.02);
border: 1px dashed v-bind(borderColor);
border-radius: 8px;
cursor: pointer;
outline: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
transition: border-color 0.3s, width 0.3s;
&:hover {
border-color: v-bind(primaryColor);
}
}
&-list {
width: 100%;
height: 100%;
overflow-y: auto;
transition: opacity 0.3s, height 0.3s;
&::before {
display: table;
width: 0;
height: 0;
content: "";
}
&-item {
color: v-bind(primaryColor);
position: relative;
height: 22px;
font-size: 14px;
display: flex;
align-items: center;
transition: background-color .3s;
cursor: pointer;
padding: 0.4rem 1rem;
&:hover {
background-color: v-bind(hoverColor);
.reconstructor-upload-list-item-del {
opacity: 1;
}
}
&-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: 0 6px;
line-height: 1.57;
flex: auto;
max-width: 85%;
transition: all .3s;
}
&-del {
position: absolute;
right: 8px;
color: v-bind(textColor3);
cursor: pointer;
opacity: 0;
transition: all .3s;
&:hover {
transform: scale(1.2);
}
&:active {
transform: scale(0.9);
}
}
}
}
}
.stats-text {
color: v-bind(primaryColor);
}
</style>
@@ -0,0 +1,304 @@
<script setup lang="ts">
import {ref} from "vue";
import {t} from "@/language";
import EsFormItemHelpLabel from "@/components/es/EsFormItemHelpLabel.vue";
const model = ref<IPlugin.GLTFHandlerOptimizeModel>({
'compress': "draco",
'meshoptLevel': "high",
'instance': true,
'instanceMin': 5,
'flatten': true,
'join': true,
'palette': true,
'paletteMin': 5,
'prune': true,
'pruneAttributes': true,
'pruneLeaves': true,
'pruneSolidTextures': true,
'weld': true,
'simplify': true,
'simplifyError': 0.01,
'simplifyLockBorder': false,
'simplifyRatio': 0,
'textureCompress': "webp",
'textureSize': 512,
})
function handleKeyword(keyword: string, p: string) {
const strs = p.split(keyword);
return `${strs[0]}<span class="color-red">${keyword}</span>${strs[1]}`;
}
// 合并网格(开启合并网格必开启展平场景树)
function handleJoinChange(checked: boolean) {
if (checked) {
model.value.flatten = true;
}
}
// 简化网格(开启简化网格必开启合并同位顶点)
function handleSimplifyChange(checked: boolean) {
if (checked) {
model.value.weld = true;
}
}
defineExpose({
model
})
</script>
<template>
<n-form ref="formRef" :model="model" label-placement="left" label-width="auto" class="optimize-form">
<n-grid :cols="24" :x-gap="24">
<!-- 压缩方式 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Compress')">
<div>
{{ t("plugin.gltfHandler['Floating point compression method.']") }}<br/>
<span class="color-red">Draco</span>{{ t('plugin.gltfHandler.compresses') }} geometry <br/>
<span class="color-red">Meshopt & Quantization</span>{{ t('plugin.gltfHandler.compresses') }} geometry &
animation
</div>
</EsFormItemHelpLabel>
</template>
<n-select v-model:value="model.compress" class="!w-full" :options="[
{label:'Draco',value:'draco'},
{label:'Meshopt',value:'meshopt'},
{label:'Quantization',value:'quantize'},
{label:'None',value:'false'}
]"/>
</n-form-item-gi>
<!-- Meshopt压缩级别 -->
<n-form-item-gi v-if="model.compress === 'meshopt'" :span="12" :label="t('plugin.gltfHandler.Meshopt compression level')">
<n-select v-model:value="model.meshoptLevel" class="!w-full" :options="[
{label:'medium',value:'medium'},
{label:'high',value:'high'}
]"/>
</n-form-item-gi>
<!-- 实例化网格 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Instance')">
{{ t("plugin.gltfHandler['Use GPU instancing with shared mesh references.']") }}
</EsFormItemHelpLabel>
</template>
<n-switch v-model:value="model.instance"/>
</n-form-item-gi>
<!-- 实例化网格界限 -->
<n-form-item-gi v-if="model.instance" :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Instance min')">
{{ t("plugin.gltfHandler['Number of instances required for instancing.']") }}
</EsFormItemHelpLabel>
</template>
<n-input-number v-model:value="model.instanceMin" :min="2" :max="Infinity" class="!w-full"/>
</n-form-item-gi>
<!-- 展平场景树 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Flatten')">
<div>
{{ t("plugin.gltfHandler['Flatten scene graph.']") }}<br/>
<div
v-html="handleKeyword(t('plugin.gltfHandler.Join'),t('plugin.gltfHandler.This item cannot be closed when opening Join'))"></div>
</div>
</EsFormItemHelpLabel>
</template>
<n-switch v-model:value="model.flatten" :disabled="model.join"/>
</n-form-item-gi>
<!-- 合并网格 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Join')">
<div>
{{ t("plugin.gltfHandler['Join meshes and reduce draw calls.']") }}<br/>
<div
v-html="handleKeyword(t('plugin.gltfHandler.Flatten'),t('plugin.gltfHandler[\'Prerequisites: Flatten is enabled\']'))"></div>
</div>
</EsFormItemHelpLabel>
</template>
<n-switch v-model:value="model.join" @update:value="handleJoinChange"/>
</n-form-item-gi>
<!-- 合并纹理 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Palette')">
{{ t("plugin.gltfHandler['Creates palette textures and merges materials.']") }}<br/>
</EsFormItemHelpLabel>
</template>
<n-switch v-model:value="model.palette"/>
</n-form-item-gi>
<!-- 合并纹理界限 -->
<n-form-item-gi v-if="model.palette" :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Palette min')">
{{
t("plugin.gltfHandler['Minimum number of blocks in the palette texture. If fewer unique material values are found, no palettes will be generated.']")
}}
</EsFormItemHelpLabel>
</template>
<n-input-number v-model:value="model.paletteMin" :min="2" :max="Infinity" class="!w-full"/>
</n-form-item-gi>
<!-- 修剪 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Prune')">
{{
t("plugin.gltfHandler['Removes properties from the file if they are not referenced by a Scene.']")
}}<br/>
</EsFormItemHelpLabel>
</template>
<n-switch v-model:value="model.prune"/>
</n-form-item-gi>
<template v-if="model.prune">
<!-- 修剪顶点 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Prune attributes')">
{{ t("plugin.gltfHandler['Whether to prune unused vertex attributes.']") }}<br/>
</EsFormItemHelpLabel>
</template>
<n-switch v-model:value="model.pruneAttributes"/>
</n-form-item-gi>
<!-- 修剪子节点 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Prune leaves')">
{{ t("plugin.gltfHandler['Whether to prune empty leaf nodes.']") }}<br/>
</EsFormItemHelpLabel>
</template>
<n-switch v-model:value="model.pruneLeaves"/>
</n-form-item-gi>
<!-- 修剪纹理 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Prune solid textures')">
{{
t("plugin.gltfHandler['Whether to prune solid (single-color) textures,converting them to material factors.']")
}}<br/>
</EsFormItemHelpLabel>
</template>
<n-switch v-model:value="model.pruneSolidTextures"/>
</n-form-item-gi>
</template>
<!-- 合并同位顶点 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Weld')">
<div>
{{ t("plugin.gltfHandler['Merge equivalent vertices.']") }}<br/>
<div
v-html="handleKeyword(t('plugin.gltfHandler.Simplify'),t('plugin.gltfHandler.This item cannot be closed when opening Simplify'))"></div>
</div>
</EsFormItemHelpLabel>
</template>
<n-switch v-model:value="model.weld" :disabled="model.simplify"/>
</n-form-item-gi>
<!-- 简化网格 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Simplify')">
<div>
{{ t("plugin.gltfHandler['Simplify mesh geometry with meshoptimizer.']") }}<br/>
<div
v-html="handleKeyword(t('plugin.gltfHandler.Weld'),t('plugin.gltfHandler[\'Prerequisites: Weld is enabled\']'))"></div>
</div>
</EsFormItemHelpLabel>
</template>
<n-switch v-model:value="model.simplify" @update:value="handleSimplifyChange"/>
</n-form-item-gi>
<template v-if="model.simplify">
<!-- 简化误差界限 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Simplify error')">
{{ t("plugin.gltfHandler['Simplification error tolerance, as a fraction of mesh extent.']") }}
</EsFormItemHelpLabel>
</template>
<n-input-number v-model:value="model.simplifyError" :min="0.01" :max="100" :step="0.01" :precision="2"
class="!w-full">
<template #suffix> %</template>
</n-input-number>
</n-form-item-gi>
<!-- 简化锁定边界 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Simplify lock border')">
{{ t("plugin.gltfHandler['Whether to lock topological borders of the mesh.']") }}
</EsFormItemHelpLabel>
</template>
<n-switch v-model:value="model.simplifyLockBorder"/>
</n-form-item-gi>
<!-- 简化比率 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Simplify ratio')">
{{ t("plugin.gltfHandler['Target ratio (0-1) of vertices to keep.']") }}
</EsFormItemHelpLabel>
</template>
<n-slider v-model:value="model.simplifyRatio" :min="0" :max="1" :step="0.01"/>
</n-form-item-gi>
</template>
<!-- 纹理压缩 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Texture compress')">
<div>
{{ t("plugin.gltfHandler['AVIF and WebP optimize transfer size;']") }}<br/>
{{ t("plugin.gltfHandler['Auto Compresses in the original format;']") }}<br/>
{{ t("plugin.gltfHandler['none Does not compress.']") }}
</div>
</EsFormItemHelpLabel>
</template>
<n-select v-model:value="model.textureCompress" class="!w-full" :options="[
{label:'WebP',value:'webp'},
{label:'AVIF',value:'avif'},
{label:'Auto',value:'auto'},
{label:'None',value:'none'}
]"/>
</n-form-item-gi>
<!-- 纹理最大尺寸 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.gltfHandler.Texture size')">
{{ t("plugin.gltfHandler['Maximum texture dimensions, in pixels.']") }}
</EsFormItemHelpLabel>
</template>
<n-input-number v-model:value="model.textureSize" :min="2" :step="2" :max="Infinity" class="!w-full">
<template #suffix> px</template>
</n-input-number>
</n-form-item-gi>
</n-grid>
</n-form>
</template>
<style scoped lang="less">
.optimize-form {
margin-top: 20px;
overflow-y: auto;
overflow-x: auto;
}
</style>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref } from "vue";
import { t } from "@/language";
import EsFormItemHelpLabel from "@/components/es/EsFormItemHelpLabel.vue";
const model = ref<IPlugin.PointCloudReconstructorModel>({
'colorTolerance': 5,
'distanceThreshold': 0.3,
'minClusterSize': 10,
'downsampleResolution': 0.05,
})
defineExpose({
model
})
</script>
<template>
<n-form ref="formRef" :model="model" label-placement="left" label-width="auto" class="overflow-auto mt-2">
<n-grid :cols="24" :x-gap="24">
<!-- 颜色容差 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.pointCloudReconstructor.Color tolerance')">
{{ t("plugin.pointCloudReconstructor['Color similarity processing, the larger the value, the wider the range of cluster color values.']") }}
</EsFormItemHelpLabel>
</template>
<n-slider v-model:value="model.colorTolerance" :min="1" :max="20" :step="1" />
</n-form-item-gi>
<!-- 距离阈值 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.pointCloudReconstructor.Distance threshold')">
{{ t("plugin.pointCloudReconstructor['Control the sensitivity of object separation, that is, spatial clustering distance threshold.']") }}
</EsFormItemHelpLabel>
</template>
<n-slider v-model:value="model.distanceThreshold" :min="0.05" :max="0.5" :step="0.05" />
</n-form-item-gi>
<!-- 最小簇大小 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.pointCloudReconstructor.Min cluster size')">
{{ t("plugin.pointCloudReconstructor['Minimum number of cluster points, filtering noise points and small clusters.']") }}
</EsFormItemHelpLabel>
</template>
<n-slider v-model:value="model.minClusterSize" :min="5" :max="100" :step="5" />
</n-form-item-gi>
<!-- 点云抽稀 -->
<n-form-item-gi :span="12">
<template #label>
<EsFormItemHelpLabel :label="t('plugin.pointCloudReconstructor.Downsample resolution')">
{{ t("plugin.pointCloudReconstructor['The grid space division method is used to reduce the number of points, so as to reduce the amount of calculation in the subsequent processing.']") }}
</EsFormItemHelpLabel>
</template>
<n-slider v-model:value="model.downsampleResolution" :min="0.01" :max="0.2" :step="0.01" />
</n-form-item-gi>
</n-grid>
</n-form>
</template>
<style scoped lang="less">
</style>