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,231 @@
<template>
<div ref="monacoEditorRef" class="h-full"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue';
import * as monaco from 'monaco-editor';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import CssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
import HtmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
import TsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
const props = withDefaults(defineProps<{
source: string,
mode: string,
config?: {
[key: string]: any
}
}>(), {
source: "",
mode: "javascript",
config: () => ({})
})
const emits = defineEmits(["update:source"]);
const monacoEditorRef = ref();
// 编辑器实列
let editor: monaco.editor.IStandaloneCodeEditor | null = null;
// 导入的sqlLanguage
let sqlLanguage:any = null;
// sql语法提示示例
let sqlProvider:monaco.IDisposable | null = null;
// 语法错误信息
let errorMarkers: monaco.editor.IMarker[] = [];
onMounted(async () => {
await nextTick();
window.MonacoEnvironment = {
getWorker(_, label) {
if (label === 'json') {
return new JsonWorker();
}
if (label === 'css' || label === 'scss' || label === 'less') {
return new CssWorker();
}
if (label === 'html' || label === 'handlebars' || label === 'razor') {
return new HtmlWorker();
}
if (label === 'typescript' || label === 'javascript') {
return new TsWorker();
}
return new EditorWorker();
},
};
await initMonaco();
})
onBeforeUnmount(() => {
if(sqlLanguage){
sqlLanguage = null;
}
if(sqlProvider){
sqlProvider.dispose();
sqlProvider = null;
}
if (editor) {
editor.dispose();
editor = null;
}
});
async function initMonaco() {
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false,
noSyntaxValidation: false,
});
// 如果使用 Webpack 或其他打包工具,可以使用 importScripts
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ES2016,
noImplicitAny: false, // 允许隐式 any 类型
strict: false, // 禁用所有严格类型检查。
allowJs: true,
checkJs: false, // 禁用对 .js 文件的类型检查(仅解析语法)
allowNonTsExtensions: true, // 允许非 .ts 文件的语法检查
});
// 添加 THREE 的全局声明,以便在 Monaco Editor 中使用而不报错
// const response = await fetch('/libs/@types/three/index.d.ts');
// const content = await response.text();
// monaco.languages.typescript.javascriptDefaults.addExtraLib(
// content,
// 'three.d.ts'
// );
if (props.mode === "sql") {
if(!sqlLanguage){
const { language } = await import("monaco-editor/esm/vs/basic-languages/sql/sql.js");
sqlLanguage = language;
}
sqlProvider = monaco.languages.registerCompletionItemProvider('sql', {
provideCompletionItems: (model, position) => {
let suggestions:any = []
const { lineNumber, column } = position
const textBeforePointer = model.getValueInRange({
startLineNumber: lineNumber,
startColumn: 0,
endLineNumber: lineNumber,
endColumn: column,
})
const contents = textBeforePointer.trim().split(/\s+/)
const lastContents = contents[contents?.length - 1];
if (lastContents) {
const sqlConfigKey = ['builtinFunctions', 'keywords', 'operators'];
sqlConfigKey.forEach(key => {
sqlLanguage[key].forEach(sql => {
suggestions.push(
{
label: sql, // 显示的提示内容;
insertText: sql, // 应插入到文档中的内容
kind: monaco.languages.CompletionItemKind.Keyword
}
)
})
})
}
return {
suggestions,
}
}
})
}
editor = monaco.editor.create(monacoEditorRef.value, {
value: props.source,
language: props.mode,
...Object.assign({
theme: 'vs-dark', //官方自带三种主题vs, hc-black, or vs-dark
readOnly: false, // 是否只读内容不可编辑
readOnlyMessage: { value: "不可以修改哦", supportThemeIcons: true, supportHtml: true }, // 为只读时编辑内日提示词
codeLens: true, // 代码透镜
folding: true, // 代码折叠
snippetSuggestions: 'inline' as 'inline', // 代码提示
tabCompletion: 'on' as 'on', // 代码提示按tab完成
foldingStrategy: 'auto' as 'auto', // 折叠策略
smoothScrolling: true, // 滚动动画
// acceptSuggestionOnCommitCharacter: true, // 接受关于提交字符的建议
// acceptSuggestionOnEnter: 'on', // 接受输入建议 "on" | "off" | "smart"
// accessibilityPageSize: 10, // 辅助功能页面大小 Number 说明:控制编辑器中可由屏幕阅读器读出的行数。警告:这对大于默认值的数字具有性能含义。
// accessibilitySupport: 'on', // 辅助功能支持 控制编辑器是否应在为屏幕阅读器优化的模式下运行。
// autoClosingBrackets: 'always', // 是否自动添加结束括号(包括中括号) "always" | "languageDefined" | "beforeWhitespace" | "never"
// autoClosingDelete: 'always', // 是否自动删除结束括号(包括中括号) "always" | "never" | "auto"
// autoClosingOvertype: 'always', // 是否关闭改写 即使用insert模式时是覆盖后面的文字还是不覆盖后面的文字 "always" | "never" | "auto"
// autoClosingQuotes: 'always', // 是否自动添加结束的单引号 双引号 "always" | "languageDefined" | "beforeWhitespace" | "never"
// automaticLayout: true, // 自动布局
// codeLensFontFamily: '', // codeLens的字体样式
// codeLensFontSize: 13, // codeLens的字体大小
// colorDecorators: true, // 呈现内联色彩装饰器和颜色选择器
// comments: {
// ignoreEmptyLines: true, // 插入行注释时忽略空行。默认为真。
// insertSpace: true, // 在行注释标记之后和块注释标记内插入一个空格。默认为真。
// }, // 注释配置
contextmenu: true, // 启用上下文菜单
// columnSelection: true, // 启用列编辑 按下shift键位然后按↑↓键位可以实现列选择 然后实现列编辑
// autoSurround: 'never', // 是否应自动环绕选择
// copyWithSyntaxHighlighting: true, // 是否应将语法突出显示复制到剪贴板中 即 当你复制到word中是否保持文字高亮颜色
// cursorBlinking: 'smooth', // 光标动画样式
// cursorSmoothCaretAnimation: 'on', // 是否启用光标平滑插入动画 当你在快速输入文字的时候 光标是直接平滑的移动还是直接"闪现"到当前文字所处位置
// cursorStyle: 'line', // "Block"|"BlockOutline"|"Line"|"LineThin"|"Underline"|"UnderlineThin" 光标样式
// cursorSurroundingLines: 0, // 光标环绕行数 当文字输入超过屏幕时 可以看见右侧滚动条中光标所处位置是在滚动条中间还是顶部还是底部 即光标环绕行数 环绕行数越大 光标在滚动条中位置越居中
// cursorSurroundingLinesStyle: 'all', // "default" | "all" 光标环绕样式
// cursorWidth: 2, // <=25 光标宽度
minimap: {
enabled: props.mode !== 'sql', // 是否启用预览图
},
// scrollbar: {
// verticalScrollbarSize: 5,
// horizontalScrollbarSize: 5,
// arrowSize: 10,
// alwaysConsumeMouseWheel: false,
// },
// links: true, // 是否点击链接
// overviewRulerBorder: true, // 是否应围绕概览标尺绘制边框
// renderLineHighlight: 'gutter', // 当前行突出显示方式
// scrollBeyondLastLine: false, // 设置编辑器是否可以滚动到最后一行之后
// lineNumbers: 'on',
// lineNumbersMinChars: 0,
// fontSize: 13,
// roundedSelection: false, // 右侧不显示编辑器预览框
// autoIndent: 'full',
// formatOnType: true,
// formatOnPaste: true
}, props.config)
})
editor.onDidChangeModelContent(() => {
editor && emits('update:source', editor.getValue())
})
monaco.editor.onDidChangeMarkers(([uri]) => {
errorMarkers = monaco.editor.getModelMarkers({ resource: uri });
})
}
function getErrors() {
const markers: string[] = [];
const ignoreError = ["All destructured elements are unused", "is declared but its value is never read"];
errorMarkers.forEach(marker => {
const { message, startLineNumber, startColumn, endLineNumber, endColumn } = marker;
const isIgnore = ignoreError.some(ignore => message.indexOf(ignore) >= 0);
if (isIgnore) return;
markers.push(`${message} [${startLineNumber}${startColumn}列 - ${endLineNumber}${endColumn}列]`);
});
return markers;
}
defineExpose({
getErrors
})
</script>
@@ -0,0 +1,57 @@
<template>
<n-modal :show="show" @update:show="(s) => emits('update:show',s)" class="!w-80vw" preset="dialog"
:title="title" :showIcon="false">
<CodeEditor ref="htmlPanelCodeRef" v-model:source="source" :mode="mode" class="!h-80vh" />
<n-alert title="Error" type="error" v-if="errors.length" closable @close="errors = []" class="absolute bottom-0 w-full z-9999">
<n-text depth="1" v-for="(error,index) in errors" :keys="index" class="block">{{error}}</n-text>
</n-alert>
<div class="float-right mt-10px">
<n-button size="small" @click="emits('update:show',false)">{{t('other.Cancel')}}</n-button>
<n-button class="ml-5px" type="primary" size="small" @click="submitCallback">{{t('other.Ok')}}</n-button>
</div>
</n-modal>
</template>
<script setup lang="ts">
import {ref, watch} from "vue";
import {t} from "@/language";
import CodeEditor from "@/components/code/CodeEditor.vue";
const props = withDefaults(defineProps<{
show: boolean,
value: string,
title: string,
mode: string,
}>(), {
show: false,
value: "{}",
title: "",
mode: 'html'
})
const emits = defineEmits(["update:show", "update:value"]);
const htmlPanelCodeRef = ref();
const source = ref<string>(props.value);
const errors = ref([]);
watch(() => props.show,(nv) => {
if(nv){
source.value = props.value;
}
})
function submitCallback(e: Event) {
e.stopPropagation();
errors.value = htmlPanelCodeRef.value.getErrors();
if(errors.value.length > 0) return;
emits("update:value", source.value);
emits("update:show", false);
source.value = "";
}
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,49 @@
<template>
<div class="w-full h-auto relative">
<CodeEditor ref="editorRef" v-model:source="source" mode="json" class="!h-300px" />
<n-alert title="Error" type="error" v-if="errors.length" closable @close="errors = []" class="absolute bottom-0 w-full z-9999">
<n-text depth="1" v-for="(error,index) in errors" :keys="index" class="block">{{error}}</n-text>
</n-alert>
</div>
</template>
<script setup lang="ts">
import {ref,useTemplateRef,watch} from "vue";
import {Utils} from "@astral3d/engine";
import CodeEditor from "@/components/code/CodeEditor.vue";
const props = withDefaults(defineProps<{
value: string
}>(), {
value: ""
})
const emits = defineEmits([ "update:value"]);
const editorRef = useTemplateRef("editorRef");
const source = ref<string>(props.value);
const errors = ref<string[]>([]);
const handleSourceChange = Utils.debounce(() => {
if(!editorRef.value) return;
errors.value = editorRef.value?.getErrors();
if(errors.value.length > 0) return;
emits("update:value", source.value);
},150)
watch(source,handleSourceChange);
function reset() {
source.value = "";
errors.value = [];
}
defineExpose({
errors,
reset
})
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,49 @@
<template>
<div class="w-full h-auto">
<CodeEditor ref="editorRef" v-model:source="source" mode="sql" class="!h-300px" />
<n-alert title="Error" type="error" v-if="errors.length" closable @close="errors = []" class="absolute bottom-0 w-full z-9999">
<n-text depth="1" v-for="(error,index) in errors" :keys="index" class="block">{{error}}</n-text>
</n-alert>
</div>
</template>
<script setup lang="ts">
import {ref,useTemplateRef,watch} from "vue";
import {Utils} from "@astral3d/engine";
import CodeEditor from "@/components/code/CodeEditor.vue";
const props = withDefaults(defineProps<{
value: string
}>(), {
value: ""
})
const emits = defineEmits([ "update:value"]);
const editorRef = useTemplateRef("editorRef");
const source = ref<string>(props.value);
const errors = ref<string[]>([]);
const handleSourceChange = Utils.debounce(() => {
if(!editorRef.value) return;
errors.value = editorRef.value?.getErrors();
if(errors.value.length > 0) return;
emits("update:value", source.value);
},150)
watch(source,handleSourceChange);
function reset() {
source.value = "";
errors.value = [];
}
defineExpose({
errors,
reset
})
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,165 @@
<template>
<n-modal v-model:show="show" class="!w-90vw" preset="dialog" :title="title" :showIcon="false">
<CodeEditor ref="scriptCodeRef" v-model:source="script.source" :mode="mode" class="!h-80vh"/>
<n-alert title="Error" type="error" v-if="errors.length" closable @close="errors = []"
class="absolute bottom-0 w-full z-9999">
<n-text depth="1" v-for="(error,index) in errors" :keys="index" class="block">{{ error }}</n-text>
</n-alert>
<div class="float-right mt-10px">
<n-button size="small" @click="show = false">{{ t('other.Cancel') }}</n-button>
<n-button class="ml-5px" type="primary" size="small" @click="submit">{{ t('other.Ok') }}</n-button>
</div>
</n-modal>
</template>
<script setup lang="ts">
import {onBeforeUnmount, onMounted, reactive, ref} from "vue";
import * as THREE from 'three';
import {App, Hooks, SetScriptValueCommand, SetMaterialValueCommand} from '@astral3d/engine';
import {t} from "@/language";
import CodeEditor from "@/components/code/CodeEditor.vue";
const scriptCodeRef = ref();
const title = ref("");
const show = ref(false);
const script = reactive({
name: '',
source: ''
});
const errors = ref([]);
const mode = ref('javascript');
let currentScript: IScript.IStruct | string | null = null;
let currentObject: THREE.Mesh | null = null;
onMounted(async () => {
Hooks.useAddSignal("sceneCleared", sceneCleared);
Hooks.useAddSignal("scriptRemoved", scriptRemoved);
Hooks.useAddSignal("editScript", editScript);
})
onBeforeUnmount(() => {
Hooks.useRemoveSignal("sceneCleared", sceneCleared);
Hooks.useRemoveSignal("scriptRemoved", scriptRemoved);
Hooks.useRemoveSignal("editScript", editScript);
})
function sceneCleared() {
show.value = false;
script.name = "";
script.source = "";
errors.value = [];
}
function scriptRemoved(_: THREE.Object3D, sc: IScript.IStruct) {
if (script.name === sc.name) {
sceneCleared();
}
}
function editScript(object: THREE.Object3D, sc: IScript.IStruct | string) {
if (typeof (sc) === 'object') {
mode.value = 'javascript';
script.name = sc.name;
script.source = sc.source;
title.value = object.name + ' / ' + script.name;
} else {
switch (sc) {
case 'vertexShader':
mode.value = 'glsl';
script.name = 'Vertex Shader';
// @ts-ignore
script.source = object.material.vertexShader || '';
break;
case 'fragmentShader':
mode.value = 'glsl';
script.name = 'Fragment Shader';
// @ts-ignore
script.source = object.material.fragmentShader || '';
break;
case 'programInfo':
mode.value = 'json';
script.name = 'Program Properties';
const json = {
// @ts-ignore
defines: object.material.defines,
// @ts-ignore
uniforms: object.material.uniforms,
// @ts-ignore
attributes: object.material.attributes
};
script.source = JSON.stringify(json, null, '\t');
}
// @ts-ignore
title.value = object.material.name + ' / ' + script.name;
}
currentScript = sc;
currentObject = object as THREE.Mesh;
show.value = true;
}
function submit(e: Event) {
e.stopPropagation();
errors.value = scriptCodeRef.value.getErrors();
if (errors.value.length > 0) return;
if (!currentObject) return;
const value = script.source;
if (typeof (currentScript) === 'object') {
if (value !== currentScript?.source) {
App.execute(new SetScriptValueCommand(currentObject as THREE.Object3D, currentScript as IScript.IStruct, 'source', value));
}
show.value = false;
return;
}
if (mode.value === 'glsl' && currentObject.material) {
currentObject.material[currentScript] = value;
(currentObject.material as THREE.Material).needsUpdate = true;
Hooks.useDispatchSignal('materialChanged', currentObject, 0);
show.value = false;
return;
}
if (currentScript !== 'programInfo') {
show.value = false;
return;
}
if (!currentObject.material) return
const json = JSON.parse(value);
if (JSON.stringify((currentObject.material as THREE.Material).defines) !== JSON.stringify(json.defines)) {
const cmd = new SetMaterialValueCommand(currentObject, 'defines', json.defines);
cmd.updatable = false;
App.execute(cmd);
}
// @ts-ignore
if (JSON.stringify(currentObject.material.uniforms) !== JSON.stringify(json.uniforms)) {
const cmd = new SetMaterialValueCommand(currentObject, 'uniforms', json.uniforms);
cmd.updatable = false;
App.execute(cmd);
}
// @ts-ignore
if (JSON.stringify(currentObject.material.attributes) !== JSON.stringify(json.attributes)) {
const cmd = new SetMaterialValueCommand(currentObject, 'attributes', json.attributes);
cmd.updatable = false;
App.execute(cmd);
}
show.value = false;
}
</script>
@@ -0,0 +1,53 @@
<template>
<n-modal :show="show" @update:show="(s) => emits('update:show',s)" class="!w-60vw" preset="dialog"
:title="t('layout.sider.object.userdata')" :showIcon="false">
<CodeEditor ref="userdataRef" v-model:source="source" mode="json" class="!h-600px" />
<n-alert title="Error" type="error" v-if="errors.length" closable @close="errors = []" class="absolute bottom-0 w-full z-9999">
<n-text depth="1" v-for="(error,index) in errors" :keys="index" class="block">{{error}}</n-text>
</n-alert>
<div class="float-right mt-10px">
<n-button size="small" @click="emits('update:show',false)">{{t('other.Cancel')}}</n-button>
<n-button class="ml-5px" type="primary" size="small" @click="submitCallback">{{t('other.Ok')}}</n-button>
</div>
</n-modal>
</template>
<script setup lang="ts">
import {ref, watch} from "vue";
import {t} from "@/language";
import CodeEditor from "@/components/code/CodeEditor.vue";
const props = withDefaults(defineProps<{
show: boolean,
value: string
}>(), {
show: false,
value: "{}"
})
const emits = defineEmits(["update:show", "update:value"]);
const userdataRef = ref();
const source = ref<string>(props.value);
const errors = ref([]);
watch(() => props.show,(nv) => {
if(nv){
source.value = props.value;
}
})
function submitCallback(e: Event) {
e.stopPropagation();
errors.value = userdataRef.value.getErrors();
if(errors.value.length > 0) return;
emits("update:value", source.value);
emits("update:show", false);
source.value = "";
}
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,226 @@
<template>
<div id="drawing" class="!w-full !h-full" @contextmenu="handleContextMenu">
<n-spin :show="loading">
<template #description>加载中...</template>
<div class="drawing-container" ref="containerRef">
<canvas ref="canvasRef"></canvas>
</div>
</n-spin>
<ImageToolbar v-if="!drawingInfo?.isCad" />
<CADToolbar v-else />
</div>
</template>
<script setup lang="ts">
import {onMounted, ref, nextTick, computed, onBeforeUnmount, watch, inject, Ref} from "vue";
import {useThemeVars} from 'naive-ui';
import {Hooks,DxfViewer,DxfParser} from "@astral3d/engine";
import {DrawRect} from "@/utils/drawing/drawRect";
import ImageToolbar from "./toolbar/Image.vue";
import CADToolbar from "./toolbar/CAD.vue";
import Config from "@/utils/storage/config";
const themeVars = useThemeVars();
const baseColor = computed(() => themeVars.value.baseColor);
const borderColor = computed(() => themeVars.value.borderColor);
const drawingInfo = inject("drawingInfo") as Ref<IDrawing>;
const containerRef = ref();
const canvasRef = ref();
const loading = ref(true);
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
return false
}
function loadCadFile(canvas:HTMLCanvasElement,parentElement:HTMLDivElement){
let dxf;
// 实例化Dxf.Viewer
function loadDxf() {
const DXFViewer = new DxfViewer(dxf, canvas, parentElement.offsetWidth, parentElement.offsetHeight, () => {
loading.value = false;
window.DrawViewer = DXFViewer;
});
if (dxf.tables?.layer?.layers) {
const bgColor = Config.getKey('cad')?.bgColor;
if (bgColor) {
const color = Number(bgColor);
const contrastColor = color === 0x000000 ? 0xffffff : 0x000000;
const l = dxf.tables.layer.layers;
Object.keys(l).forEach(k => {
if (l[k].color === color) {
l[k].color = contrastColor;
}
})
}
drawingInfo.value.layers = dxf.tables.layer.layers;
}
}
let notice = window.$notification.info({
title: window.$t("drawing['Get the drawing data']") + "...",
content: window.$t("other.Loading") + "...",
closable: false,
})
// dxf 加载图纸
const parser = new DxfParser();
fetch(drawingInfo.value.imgSrc).then(res => res.text()).then(text => {
notice.content = window.$t("scene['Parsing to editor']");
dxf = parser.parse(text);
loadDxf();
setTimeout(() => {
notice.destroy();
}, 800)
})
}
async function initCanvas() {
if(!drawingInfo.value.imgSrc) return;
let canvas = canvasRef.value as HTMLCanvasElement;
const parentElement = containerRef.value as HTMLDivElement;
if(drawingInfo.value.isCad){
// dxf 加载图纸
loadCadFile(canvas, parentElement);
}else{
// canvas 加载图片
let bigImg = new Image();
bigImg.src = drawingInfo.value.imgSrc;
bigImg.onload = () => {
const containerHeight = (containerRef.value as HTMLDivElement).offsetHeight;
canvas.height = containerHeight;
canvas.width = bigImg.width * (containerHeight / bigImg.height);
canvas.style.backgroundImage = `url(${drawingInfo.value.imgSrc})`;
canvas.style.backgroundRepeat = "no-repeat";
canvas.style.backgroundPosition = "top left";
canvas.style.backgroundSize = "100% 100%";
drawingInfo.value.imgInfo = {
width: canvas.width,
height: canvas.height
}
window.DrawViewer = new DrawRect(canvas,parentElement);
loading.value = false;
}
}
}
// 模型选中时反选图纸上的rect
function objectSelected(object){
if(!object ||!window.DrawViewer) return;
// if(!drawingInfo.isCad){
// window.DrawViewer?.selectRect(object.uuid);
// }else{
// // 检查该模型是否已有绑定标记
// for (const rect of drawingInfo.markList) {
// if (rect.modelUuid === object.uuid) {
// window.DrawViewer?.selectRect(object.uuid);
// return;
// }
// }
//
// window.DrawViewer?.selectRect(undefined);
// }
window.DrawViewer?.selectRect(object.uuid);
}
watch(() => drawingInfo.value.imgSrc, async () => {
if(!canvasRef.value) return;
const newCanvas = document.createElement("canvas");
window.DrawViewer?.dispose();
canvasRef.value.remove();
canvasRef.value = null;
containerRef.value.append(newCanvas);
canvasRef.value = newCanvas;
await initCanvas();
})
onMounted(async () => {
await nextTick();
await initCanvas();
Hooks.useAddSignal("objectSelected", objectSelected);
})
onBeforeUnmount(() => {
Hooks.useRemoveSignal("objectSelected", objectSelected);
window.DrawViewer?.dispose();
window.DrawViewer = undefined;
})
</script>
<style lang="less" scoped>
#drawing {
overflow: hidden;
display: flex;
justify-content: center;
:deep(.n-spin-container){
width: 100%;
height: 100%;
.n-spin-content{
width: 100%;
height: 100%;
}
}
.drawing-container {
position: relative;
margin: 0 auto;
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
canvas {
position: relative;
transition: all 16ms;
z-index: 10;
width: auto;
height: auto;
}
}
.drawing-tool-bar {
position: absolute;
bottom: 3%;
padding: 0.2rem 0.5rem;
background: v-bind(baseColor);
z-index: 999;
border: 1px solid v-bind(borderColor);
border-radius: 0.3rem;
display: flex;
:deep(.n-color-picker) {
position: absolute;
width: 0;
overflow: hidden;
&-trigger {
border: 0;
width: 0;
}
}
}
}
</style>
@@ -0,0 +1,152 @@
<template>
<div class="drawing-tool-bar">
<n-tooltip trigger="hover" placement="bottom" v-for="m in toolbar" :key="m.name">
<template #trigger>
<n-button quaternary :type="m.active ? 'primary' : 'default'" :disabled="m.disabled"
size="small" @click="clickMenu(m)" :id="'drawing-tool-bar-' + m.key">
<template #icon>
<n-icon>
<component :is="m.icon"/>
</n-icon>
</template>
</n-button>
</template>
{{ m.name }}
</n-tooltip>
<n-color-picker placement="top-start" size="small" v-model:show="colorPickerShow" v-model:value="rectColor"
:show-alpha="false" :modes="['hex']" @update:value="changeRectColor"/>
<!-- 图层 -->
<CadLayers v-model:show="layersVisible" @close="closeLayers" />
</div>
</template>
<script setup lang="ts">
import {computed, inject, onBeforeUnmount, onMounted, Ref, ref} from "vue";
import {LayersSharp, LocateSharp, LocationSharp, TrashSharp} from "@vicons/ionicons5";
import {App,Hooks} from "@astral3d/engine";
import {t} from "@/language";
import CadLayers from "@/components/drawing/toolbar/CadLayers.vue";
interface IToolbarItem {
key: string;
name: string;
icon: any;
active: boolean;
disabled: boolean;
}
const drawingInfo = inject("drawingInfo") as Ref<IDrawing>;
const toolbar = computed(() => [
{key:"reset",name: t("drawing.toolbar.Reset"), icon: LocateSharp, active: false, disabled: false},
{key:"layers",name:t("drawing.toolbar.Layer"),icon:LayersSharp, active: false, disabled: false},
{key:"mark",name: t("drawing.toolbar['Add mark']"), icon: LocationSharp, active: false, disabled: false},
//{key:"colorPicker",name: t("drawing.toolbar['Mark color']"), icon: ColorPaletteSharp,active: false, disabled: drawingStore.getSelectedRectIndex === -1},
{key:"delete",name: t("drawing.toolbar.Delete"), icon: TrashSharp, active: false, disabled:true /*window.DrawViewer?.selectRectIndex === -1*/},
//{key:"setting",name: t("drawing.toolbar.Setting"),icon: SettingsSharp,active: false, disabled: false},
]);
const layersVisible = ref(false);
const rectColor = ref(window.DrawViewer?.rectColor || "#15FF00");
const colorPickerShow = ref(false);
onMounted(() => {
Hooks.useAddSignal("drawingMarkDone", drawingMarkDone);
})
onBeforeUnmount(() => {
Hooks.useRemoveSignal("drawingMarkDone", drawingMarkDone);
})
function clickMenu( m: IToolbarItem) {
if (m.disabled) return;
switch (m.key) {
case "reset":
window.DrawViewer?.callMethod("resetCamera");
break;
case "layers":
layersVisible.value = !m.active;
m.active = !m.active;
break;
case "mark":
m.active = !m.active;
if (m.active) {
addMarkCheck(m);
}else{
// 退出绘制流程
window.DrawViewer?.exitRect();
}
break;
case "colorPicker":
if (colorPickerShow.value) return;
if (window.DrawViewer?.selectRectIndex === -1) {
window.$message?.warning(t("drawing['Select the mark whose color you want to change!']"));
} else {
rectColor.value = window.DrawViewer?.selectRectColor as string;
colorPickerShow.value = true;
}
break;
case "delete":
// 删除标记
window.DrawViewer?.deleteRect();
break;
}
}
function closeLayers(){
layersVisible.value = false;
const l = toolbar.value.find(item => item.key === "layers");
if(l){
l.active = false;
}
}
function addMarkCheck(m: IToolbarItem) {
const object = App.selected;
// 检查是否有选中模型
if (!object) {
window.$message?.warning(t("drawing['Please select the model you want to tag']"));
m.active = false;
return;
}
// 检查该模型是否已有绑定标记
console.log(drawingInfo.value.markList)
for (const rect of drawingInfo.value.markList) {
if (rect.modelUuid === object.uuid) {
window.$message?.warning(t("drawing['The current model has been tagged']"));
m.active = false;
window.DrawViewer?.selectRect(object.uuid);
return;
}
}
// 新增标记
window.DrawViewer?.callMethod("callModuleMethod", {
moduleName: "drawRect", methodName: "addRect",modelUuid: object.uuid,
});
window.$message?.info(t("drawing['Left-drag to add a mark']"));
}
function drawingMarkDone(type:"add" | "update",_:IDrawingMark){
switch (type) {
case "add":
const mark = toolbar.value.find(m => m.key === "mark");
if(mark) mark.active = false;
break;
case "update":
break;
}
}
function changeRectColor(color: string) {
window.DrawViewer?.setRectColor(color);
}
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,86 @@
<template>
<n-popover trigger="manual" :show="show">
<template #trigger>
<span class="absolute" ref="triggerRef"></span>
</template>
<div class="overflow-y-auto h-500px" ref="popoverRef">
<div className="flex items-center mb-15px">
<n-icon size="16" color="#CACACA" v-if="viewAll">
<EyeSharp className="cursor-pointer" @click="handleLayersAllView(false)"/>
</n-icon>
<n-icon size="16" color="#CACACA" v-else>
<EyeOffSharp className="cursor-pointer" @click="handleLayersAllView(true)"/>
</n-icon>
<span className="ml-5px">全部图层</span>
</div>
<div v-for="key in Object.keys(drawingInfo.layers)" :key="key" className="flex items-center mt-10px">
<n-icon size="16" color="#CACACA" v-if="drawingInfo.layers[key].visible">
<EyeSharp className="cursor-pointer" @click="handleSetLayerVisible(key,false)"/>
</n-icon>
<n-icon size="16" color="#CACACA" v-else>
<EyeOffSharp className="cursor-pointer" @click="handleSetLayerVisible(key,true)"/>
</n-icon>
<div className="w-15px h-15px mx-5px"
:style="{backgroundColor: decToRgb(drawingInfo.layers[key].color)}"></div>
<span className="ml-5px">{{ drawingInfo.layers[key].name }}</span>
</div>
</div>
</n-popover>
</template>
<script setup lang="ts">
import {ref,useTemplateRef, onMounted, nextTick, inject, Ref} from 'vue';
import { onClickOutside } from '@vueuse/core';
import {EyeSharp, EyeOffSharp} from '@vicons/ionicons5';
import {decToRgb} from "@/utils/common/color";
import {App} from "@astral3d/engine";
const props = withDefaults(defineProps<{
show: boolean
}>(), {
show: false
})
const emits = defineEmits(['update:show','close'])
const drawingInfo = inject("drawingInfo") as Ref<IDrawing>;
const triggerRef = useTemplateRef<HTMLSpanElement>('triggerRef');
const popoverRef = useTemplateRef<HTMLSpanElement>('popoverRef');
const viewAll = ref(true);
onMounted(() => {
// 在图层按钮位置弹出
const layerBtn = document.getElementById("drawing-tool-bar-layers");
nextTick().then(() => {
if (layerBtn && triggerRef.value) {
triggerRef.value.style.left = `${layerBtn.offsetLeft + layerBtn.clientWidth /2 }px`;
}
})
});
onClickOutside(popoverRef, () => {
if(!props.show) return;
emits('close');
})
// 全部图层显示/隐藏
function handleLayersAllView(bool: boolean) {
viewAll.value = bool;
Object.keys(drawingInfo.value.layers).forEach((key) => {
handleSetLayerVisible(key, bool);
});
}
function handleSetLayerVisible(layerName: string, bool: boolean) {
drawingInfo.value.layers[layerName].visible = bool;
window.DrawViewer?.callMethod("setLayerVisible", {layerName, visible: bool});
App.project.setDrawingLayerVisible(layerName, bool);
}
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,129 @@
<template>
<div class="drawing-tool-bar">
<n-tooltip trigger="hover" placement="bottom" v-for="m in toolbar" :key="m.name">
<template #trigger>
<n-button quaternary :type="m.active ? 'primary' : 'default'" :disabled="m.disabled"
size="small" @click="clickMenu(m)">
<template #icon>
<n-icon>
<component :is="m.icon"/>
</n-icon>
</template>
</n-button>
</template>
{{ m.name }}
</n-tooltip>
<n-color-picker placement="top-start" size="small" v-model:show="colorPickerShow" v-model:value="rectColor"
:show-alpha="false" :modes="['hex']" @update:value="changeRectColor"/>
</div>
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, inject, Ref} from "vue";
import {ColorPaletteSharp, LocateSharp, LocationSharp, TrashSharp} from "@vicons/ionicons5";
import {App,Hooks} from "@astral3d/engine";
import {t} from "@/language";
interface IToolbarItem {
key: string;
name: string;
icon: any;
active: boolean;
disabled: boolean;
}
const drawingInfo = inject("drawingInfo") as Ref<IDrawing>;
const toolbar = computed(() => [
{key:"reset",name: t("drawing.toolbar.Reset"), icon: LocateSharp, active: false, disabled: false},
{key:"mark",name: t("drawing.toolbar['Add mark']"), icon: LocationSharp, active: false, disabled: false},
{key:"colorPicker",name: t("drawing.toolbar['Mark color']"), icon: ColorPaletteSharp,active: false, disabled: drawingInfo.value.selectedRectIndex === -1},
{key:"delete",name: t("drawing.toolbar.Delete"), icon: TrashSharp, active: false, disabled: drawingInfo.value.selectedRectIndex === -1},
])
const rectColor = ref(window.DrawViewer?.rectColor || "#15FF00");
const colorPickerShow = ref(false);
onMounted(() => {
Hooks.useAddSignal("drawingMarkDone", drawingMarkDone);
})
onBeforeUnmount(() => {
Hooks.useRemoveSignal("drawingMarkDone", drawingMarkDone);
})
function clickMenu(m: IToolbarItem) {
if (m.disabled) return;
switch (m.key) {
case "reset":
window.DrawViewer?.canvasReset();
break;
case "mark":
m.active = !m.active;
if (m.active) {
addMarkCheck(m);
}else{
// 退出绘制流程
window.DrawViewer?.exitRect();
}
break;
case "colorPicker":
if (colorPickerShow.value) return;
if (window.DrawViewer?.selectRectIndex === -1) {
window.$message?.warning(t("drawing['Select the mark whose color you want to change!']"));
} else {
rectColor.value = window.DrawViewer?.selectRectColor as string;
colorPickerShow.value = true;
}
break;
case "delete":
// 删除标记
window.DrawViewer?.deleteRect();
break;
}
}
function addMarkCheck(m: IToolbarItem) {
const object = App.selected;
// 检查是否有选中模型
if (!object) {
window.$message?.warning(t("drawing['Please select the model you want to tag']"));
m.active = false;
return;
}
// 检查该模型是否已有绑定标记
for (const rect of drawingInfo.value.markList) {
if (rect.modelUuid === object.uuid) {
window.$message?.warning(t("drawing['The current model has been tagged']"));
m.active = false;
window.DrawViewer?.selectRect(object.uuid);
return;
}
}
// 新增标记
window.DrawViewer?.addRect();
window.$message?.info(t("drawing['Left-drag to add a mark']"));
}
function drawingMarkDone(type:"add" | "update",_:IDrawingMark){
switch (type) {
case "add":
const mark = toolbar.value.find(m => m.key === "mark");
if(mark) mark.active = false;
break;
case "update":
break;
}
}
function changeRectColor(color: string) {
window.DrawViewer?.setRectColor(color);
}
</script>
<style scoped lang="less">
</style>
@@ -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>
@@ -0,0 +1,20 @@
<template>
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="m27.19815,87.08654l-20.82,-11.88l0,-50.24l20.82,12.33l0,49.79z" :fill="primaryColor.hex"/>
<path d="m25.32815,13.95654l-18.95,11.01l20.82,12.33l18.95,-10.83l-20.82,-12.51z" :fill="primaryColor.hexHover"/>
<path d="m92.90815,50.47654l-0.11,24.39l-43.39,25.06l0.14,-24.56l43.36,-24.89z" :fill="primaryColor.hexHover"/>
<path d="m30.47815,88.79654l18.93,11.13l0.14,-24.56l-19.02,-10.58l-0.05,24.01z" :fill="primaryColor.hex"/>
<path d="m28.78815,12.09654l20.65,-12.17l43.28,25.04l-20.42,12.16l-43.51,-25.03z" :fill="primaryColor.hexHover"/>
<path d="m92.74815,46.90654l-0.03,-21.94l-20.42,12.16l0.02,21.81l20.43,-12.03z" :fill="primaryColor.hexHover"/>
<path d="m67.16815,60.04654l-17.52,10.11l-17.52,-10.11l0,-20.24l17.52,-10.11l17.52,10.11l0,20.24z" :fill="primaryColor.hexHover"/>
<path d="m32.14875,39.8347l17.43802,-10.33057l17.43801,10.24793l-18.26446,10.49587" :fill="primaryColor.hex"/>
</svg>
</template>
<script setup lang="ts">
import {computed} from "vue";
import {useGlobalConfigStore} from "@/store/modules/globalConfig";
const globalConfigStore = useGlobalConfigStore();
const primaryColor = computed(() => globalConfigStore.mainColor as IConfig.Color);
</script>
@@ -0,0 +1,34 @@
<template>
<div id="navigationOperation" class="flex items-center">
<!-- 撤回/重做 -->
<Do />
<!-- 删除 -->
<Delete />
<!-- 清空 -->
<Clear />
<!-- 复制 -->
<Copy />
<!-- 全屏 -->
<Fullscreen />
<!-- 导入/导出 -->
<ImportExport />
</div>
</template>
<script setup lang="ts">
import Fullscreen from "@/components/header/navigation/Fullscreen.vue";
import Do from "@/components/header/navigation/Do.vue";
import Copy from "@/components/header/navigation/Copy.vue";
import Delete from "@/components/header/navigation/Delete.vue";
import Clear from "@/components/header/navigation/Clear.vue";
import ImportExport from "@/components/header/navigation/ImportExport.vue";
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,59 @@
<script lang="ts" setup>
import {ref,onMounted} from 'vue';
import XR from '@/components/header/right/XR.vue';
import {App,Hooks} from "@astral3d/engine";
import Setting from "@/components/setting/Setting.vue";
import SaveToService from "@/components/header/right/SaveToService.vue";
import {t} from "@/language";
import {Airplay} from "@vicons/carbon";
const supportXr = ref(false);
onMounted(() => {
// 判断是否支持XR
if (navigator.xr) {
if ('offerSession' in navigator.xr) {
Hooks.useDispatchSignal("offerXR",'immersive-ar');
}else{
supportXr.value = true;
}
}else{
supportXr.value = false;
}
});
function handlePreview(){
// 新窗口打开
window.open(window.location.origin + "/#/preview/" + App.project.getKey("sceneInfo.id"), "_blank");
}
</script>
<template>
<div id="rightOperation">
<!-- 保存至服务器 -->
<SaveToService class="mr-2" />
<!-- 预览 -->
<n-button @click="handlePreview">
<template #icon>
<n-icon>
<Airplay />
</n-icon>
</template>
{{ t("home.Preview") }}
</n-button>
<!-- XR -->
<XR v-if="supportXr" />
<!-- 通用配置项 -->
<Setting show-setting />
</div>
</template>
<style lang="less" scoped>
#rightOperation {
display: flex;
align-items: center;
}
</style>
@@ -0,0 +1,47 @@
<template>
<n-tooltip trigger="hover">
<template #trigger>
<n-button text class="mr-5" :disabled="disabled" @click="handleClear">
<template #icon>
<n-icon size="22" class="cursor-pointer">
<PaintBrush />
</n-icon>
</template>
</n-button>
</template>
{{ t("layout.header.Clear Out") }}
</n-tooltip>
</template>
<script setup lang="ts">
import {ref,onMounted} from "vue";
import {PaintBrush} from "@vicons/carbon";
import {NIcon, NTooltip} from "naive-ui";
import {t} from "@/language";
import {App} from "@astral3d/engine";
const disabled = ref(false);
onMounted(() => {})
function handleClear() {
window.$dialog.warning({
title: window.$t('other.warning'),
content: window.$t("core['Any unsaved data will be lost. Are you sure?']"),
positiveText: window.$t('other.Ok'),
negativeText: window.$t('other.Cancel'),
onPositiveClick: () => {
if (App.project.getKey("sceneInfo.projectType") === 1) {
window.CesiumApp.reset();
//useDispatchSignal("cesium_destroy");
}else{
App.clear();
}
},
});
}
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,47 @@
<template>
<n-tooltip trigger="hover">
<template #trigger>
<n-button text class="mr-2" :disabled="disabled" @click="handleClone()">
<template #icon>
<n-icon size="22" class="cursor-pointer">
<Copy />
</n-icon>
</template>
</n-button>
</template>
{{ t("layout.header.Clone") }}
</n-tooltip>
</template>
<script setup lang="ts">
import {onMounted, ref} from "vue";
import {Copy} from "@vicons/carbon";
import {NIcon, NTooltip} from "naive-ui";
import {t} from "@/language";
import {App,Hooks,AddObjectCommand} from "@astral3d/engine";
const disabled = ref(true);
function objectSelected(object){
disabled.value = object === null;
}
onMounted(() => {
Hooks.useAddSignal("objectSelected",objectSelected)
})
function handleClone() {
let object = App.selected;
//避免复制相机或场景
if (object === null || object.parent === null) return;
object = object.clone();
App.execute(new AddObjectCommand(object));
}
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,44 @@
<template>
<n-tooltip trigger="hover">
<template #trigger>
<n-button text class="mr-2" :disabled="disabled" @click="handleDelete()">
<template #icon>
<n-icon size="22" class="cursor-pointer">
<Delete />
</n-icon>
</template>
</n-button>
</template>
{{ t("layout.header.Delete(Del)") }}
</n-tooltip>
</template>
<script setup lang="ts">
import {ref,onMounted} from "vue";
import {Delete} from "@vicons/carbon";
import {NIcon, NTooltip} from "naive-ui";
import {t} from "@/language";
import {App,Hooks,RemoveObjectCommand} from "@astral3d/engine";
const disabled = ref(true);
function objectSelected(object){
disabled.value = object === null;
}
onMounted(() => {
Hooks.useAddSignal("objectSelected",objectSelected)
})
function handleDelete() {
const object = App.selected;
if (object !== null && object.parent !== null) {
App.execute(new RemoveObjectCommand(object));
}
}
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,68 @@
<template>
<n-tooltip trigger="hover">
<template #trigger>
<n-button text class="mr-2" :disabled="undoDisabled" @click="handleUndo()">
<template #icon>
<n-icon size="22" class="cursor-pointer">
<Undo />
</n-icon>
</template>
</n-button>
</template>
{{ t("setting.shortcuts.Undo") + `(${Utils.IS_MAC ? 'Meta' : 'Ctrl'} + ${getUndoKey()})` }}
</n-tooltip>
<n-tooltip trigger="hover">
<template #trigger>
<n-button text class="mr-5" :disabled="redoDisabled" @click="handleRedo()">
<template #icon>
<n-icon size="22" class="cursor-pointer">
<Redo />
</n-icon>
</template>
</n-button>
</template>
{{ t("setting.shortcuts.Redo") + `(${Utils.IS_MAC ? 'Meta' : 'Ctrl'} + Shift +${getUndoKey()})` }}
</n-tooltip>
</template>
<script setup lang="ts">
import {onMounted, ref} from "vue";
import {NIcon, NTooltip} from "naive-ui";
import {Undo,Redo} from "@vicons/carbon";
import {t} from "@/language";
import {App,Hooks,Utils} from "@astral3d/engine";
const undoDisabled = ref(true);
const redoDisabled = ref(true);
function historyChanged() {
undoDisabled.value = App.history.undos.length === 0;
redoDisabled.value = App.history.redos.length === 0;
}
onMounted(() => {
Hooks.useAddSignal("historyChanged", historyChanged);
historyChanged();
})
// 获取配置的撤销按键
function getUndoKey() {
return (App.config.getShortcutItem('undo') || 'Z').toUpperCase();
}
//撤销
function handleUndo() {
App.undo();
}
//重做
function handleRedo() {
App.redo();
}
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,66 @@
<template>
<div class="flex items-center mr-5">
<n-tooltip trigger="hover" v-if="!isFullscreen">
<template #trigger>
<n-button text>
<template #icon>
<n-icon size="20" class="cursor-pointer" @click="fullscreen">
<ExpandOutline/>
</n-icon>
</template>
</n-button>
</template>
{{ t("layout.header.Fullscreen") }}
</n-tooltip>
<n-tooltip trigger="hover" v-if="isFullscreen">
<template #trigger>
<n-button text>
<template #icon>
<n-icon size="20" class="cursor-pointer" @click="fullscreen">
<ContractOutline/>
</n-icon>
</template>
</n-button>
</template>
{{ t("layout.header['Exit fullscreen']") }}
</n-tooltip>
</div>
</template>
<script setup lang="ts">
import {ref} from "vue";
import {NIcon, NTooltip} from "naive-ui";
import {ContractOutline, ExpandOutline} from "@vicons/ionicons5";
import {t} from "@/language";
const isFullscreen = ref(false);
//全屏 / 退出全屏
function fullscreen() {
if (document.fullscreenElement === null) {
document.documentElement.requestFullscreen();
isFullscreen.value = true;
} else if (document.exitFullscreen) {
document.exitFullscreen();
isFullscreen.value = false;
}
// Safari
//@ts-ignore
if (document.webkitFullscreenElement === null) {
//@ts-ignore
document.documentElement.webkitRequestFullscreen();
isFullscreen.value = true;
//@ts-ignore
} else if (document.webkitExitFullscreen) {
//@ts-ignore
document.webkitExitFullscreen();
isFullscreen.value = false;
}
}
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,155 @@
<template>
<n-dropdown :options="exportOptions" placement="bottom-start" trigger="click" @select="handleExportSelect">
<n-button class="mr-2">
<template #icon>
<n-icon size="22">
<DocumentExport/>
</n-icon>
</template>
{{ t("layout.header.Export") }}
</n-button>
</n-dropdown>
<n-button type="primary" @click="handleImport">
<template #icon>
<n-icon>
<DocumentImport/>
</n-icon>
</template>
{{ t("layout.header.Import") }}
</n-button>
</template>
<script setup lang="ts">
import {onMounted} from "vue";
import {DocumentImport, DocumentExport} from "@vicons/carbon";
import {t} from "@/language";
import {App, Export, Loader} from "@astral3d/engine";
const exportClass = new Export();
const exportOptions = [
{
label: t("layout.header['Export Object']"),
key: "exportObject",
children: [
{
label: "JSON",
key: "exportObjectToJSON"
},
{
label: "GLB",
key: "exportObjectToGlb"
},
{
label: "GLTF",
key: "exportObjectToGltf"
},
{
label: "OBJ",
key: "exportObjectToObj"
},
{
label: "PLY",
key: "exportObjectToPly"
},
{
label: t("layout.header['PLY (Binary)']"),
key: "exportObjectToPlyBinary"
},
{
label: "STL",
key: "exportObjectToStl"
},
{
label: t("layout.header['STL (Binary)']"),
key: "exportObjectToStlBinary"
},
{
label: "USDZ",
key: "exportObjectToUSDZ"
}
]
},
{
label: t("layout.header['Export Scene']"),
key: "exportScene",
children: [
{
label: "JSON",
key: "exportSceneToJSON"
},
{
label: "GLB",
key: "exportSceneToGlb"
},
{
label: "GLTF",
key: "exportSceneToGltf"
},
{
label: "OBJ",
key: "exportSceneToObj"
},
{
label: "PLY",
key: "exportSceneToPly"
},
{
label: t("layout.header['PLY (Binary)']"),
key: "exportSceneToPlyBinary"
},
{
label: "STL",
key: "exportSceneToStl"
},
{
label: t("layout.header['STL (Binary)']"),
key: "exportSceneToStlBinary"
},
{
label: "USDZ",
key: "exportSceneToUSDZ"
}
]
}
]
function handleImport() {
const form = document.createElement('form');
form.style.display = 'none';
document.body.appendChild(form);
const fileInput = document.createElement('input');
fileInput.multiple = true;
fileInput.type = 'file';
fileInput.addEventListener('change', function () {
Loader.loadFiles(fileInput.files, undefined)
.catch((err) => {
window.$message?.error(err);
})
.finally(() => {
form.reset();
});
});
form.appendChild(fileInput);
fileInput.click();
}
function handleExportSelect(key: string) {
if (key.startsWith("exportObject")) {
if (App.selected === null) {
window.$message?.error(window.$t("prompt['No object selected.']"));
return;
}
}
exportClass[key]();
}
onMounted(() => {
})
</script>
<style scoped lang="less"></style>
@@ -0,0 +1,122 @@
<script lang="ts" setup>
import {nextTick} from "vue";
import {Save} from "@vicons/carbon";
import {t} from "@/language";
import {App,Package} from "@astral3d/engine";
import { useGlobalConfigStore } from '@/store/modules/globalConfig';
import {fetchUpload} from "@/http/api/sys";
import {filterSize} from "@/utils/common/file";
import {fetchUpdateScene} from "@/http/api/scenes";
import {Service} from "~/network";
import {DefaultScreenshot} from "@/utils/common/constant";
const globalConfigStore = useGlobalConfigStore();
function save(){
const sceneInfo = App.project.getKey("sceneInfo");
// 检查对应sceneId的工程是否存在
if (!sceneInfo.id) {
window.$message?.error(window.$t("scene['The project does not exist!']"));
return;
}
window.$dialog.warning({
title: window.$t('other.warning'),
content: window.$t("prompt['Are you sure to update the scene?']"),
positiveText: window.$t('other.Ok'),
negativeText: window.$t('other.Cancel'),
onPositiveClick: async () => {
globalConfigStore.loadingText = window.$t("scene['Generate scene data, please wait']");
globalConfigStore.loading = true;
// 版本自动 +1
App.project.setKey("sceneInfo.sceneVersion",sceneInfo.sceneVersion + 1);
const biz = `${sceneInfo.id}-V${sceneInfo.sceneVersion}`;
// 如果没有封面,则先自动生成封面
if(!sceneInfo.coverPicture || sceneInfo.coverPicture === DefaultScreenshot){
const image = await window.viewer.getViewportImage() as HTMLImageElement;
sceneInfo.coverPicture = image.src;
App.project.setKey(`sceneInfo.coverPicture`,image.src);
}
// 上传封面
const f = await fetch(sceneInfo.coverPicture.startsWith("blob") ? sceneInfo.coverPicture : `file/static/${sceneInfo.coverPicture}`);
const blob = await f.blob();
const res = await fetchUpload({
file: new File([blob],`${sceneInfo.sceneName}-${Date.now()}.png`, { type: blob.type }),
biz: `upload/3DEditor/screenshot/${biz}`,
})
if(res.error === null){
App.project.setKey("sceneInfo.coverPicture",res.data);
}else{
window.$message?.error(window.$t("prompt['Failed to save the cover image']"));
}
await nextTick();
globalConfigStore.loadingText = window.$t("scene['Scene is being compressed...']");
const p = new Package(window.viewer);
p.pack({
// 首包名称
name:`${sceneInfo.sceneName}`,
// 拆分的最深层级 0:拆分至最深层
layer: 2,
// 压缩包上传接口函数,多压缩包
zipUploadFun:async (zipFile: File) => {
const res = await fetchUpload({
file: zipFile,
biz: `upload/3DEditor/scene/${biz}`,
})
if (res.error !== null) {
window.$message?.error(window.$t("scene['Failed to save project!']"));
return "";
}
return res.data;
},
// 打包进度回调
onProgress: (progress: number) => {
globalConfigStore.loadingText = progress + '%';
},
// 打包完成回调
onComplete: (data: { firstUploadResult: any, totalSize: number, totalZipNumber: number }) => {
const params = Object.assign(sceneInfo,{
zip: data.firstUploadResult,
zipSize: filterSize(data.totalSize)
})
fetchUpdateScene(sceneInfo.id,params).then((res: Service.SuccessResult<ISceneFetchData>) => {
globalConfigStore.loadingText = window.$t("prompt.Saved successfully!");
if(res.data){
App.project.setKey("sceneInfo",res.data);
}
setTimeout(() => {
globalConfigStore.loading = false;
p.dispose();
}, 500)
})
}
});
}
});
}
</script>
<template>
<n-button type="primary" @click="save">
<template #icon>
<n-icon>
<Save />
</n-icon>
</template>
{{ t("layout.header.Save") }}
</n-button>
</template>
<style lang="less" scoped>
</style>
@@ -0,0 +1,49 @@
<template>
<div class="flex items-center mr-2">
<n-tooltip trigger="hover" v-if="supportVR">
<template #trigger>
<n-icon size="20" class="cursor-pointer" @click="enterVR">
<Carbon3DMprToggle />
</n-icon>
</template>
VR
</n-tooltip>
<n-tooltip trigger="hover" v-if="supportAR">
<template #trigger>
<n-icon size="20" class="cursor-pointer" @click="enterAR">
<JoinOuter />
</n-icon>
</template>
AR
</n-tooltip>
</div>
</template>
<script setup lang="ts">
import {onMounted,ref} from "vue";
import {Carbon3DMprToggle, JoinOuter} from "@vicons/carbon";
import {NIcon, NTooltip} from "naive-ui";
import {Hooks} from "@astral3d/engine";
const supportAR = ref(false);
const supportVR = ref(false);
onMounted(() => {
navigator.xr?.isSessionSupported( 'immersive-ar' ).then((supported) => {
supportAR.value = supported;
});
navigator.xr?.isSessionSupported( 'immersive-vr' ).then((supported) => {
supportVR.value = supported;
});
})
function enterVR(){
Hooks.useDispatchSignal("enterXR",'immersive-ar');
}
function enterAR(){
Hooks.useDispatchSignal("enterXR",'immersive-vr');
}
</script>
@@ -0,0 +1,138 @@
<template>
<n-modal :show="visible" @update:show="emits('update:visible',$event)" display-directive="show"
class="w-66vw h-66vh min-w-1000px" @close="emits('update:visible',false)">
<n-card :title="asset.name" embedded size="small" content-class="!p-0 flex gap-x-15px" closable
@close="handleClose">
<div ref="assetPreviewRef" id="assetPreview"></div>
<div class="w-345px mr-15px h-full">
<n-descriptions label-placement="left" label-class="w-100px" bordered :column="1">
<n-descriptions-item :label="t('home.assets.Name')">
{{ asset.name }}
</n-descriptions-item>
<n-descriptions-item :label="t('home.assets.Type')">
{{ t(`home.assets.${asset.type}`) }}
</n-descriptions-item>
<n-descriptions-item :label="t('home.assets.Category')">
{{ asset.categoryName || asset.category }}
</n-descriptions-item>
<n-descriptions-item :label="t('bim.Thumbnail')">
<n-image
width="120"
:src="getServiceStaticFile(asset.thumbnail)"
/>
{{ }}
</n-descriptions-item>
<n-descriptions-item :label="t('home.assets.Size')">
{{ filterSize(asset.size) }}
</n-descriptions-item>
<n-descriptions-item :label="t('home.assets.Tags')">
<n-tag type="success" :bordered="false" class="ml-5px"
v-for="tag in (asset.tags ? asset.tags.split(',') : [])" :key="tag">
{{ tag }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item :label="t('scene.Create time')">
{{ asset.createTime }}
</n-descriptions-item>
<n-descriptions-item :label="t('scene.Update time')">
{{ asset.updateTime }}
</n-descriptions-item>
</n-descriptions>
<n-button type="primary" class="w-full" :loading="downloadLoading" @click="handleDownload">
{{ t('plugin.gltfHandler.Download') }}
</n-button>
</div>
</n-card>
</n-modal>
</template>
<script setup lang="ts">
import {ref, nextTick, watch, useTemplateRef, onBeforeUnmount} from "vue";
import {Preview} from "@astral3d/engine";
import {t} from "@/language";
import {downloadWithFetch, filterSize, getServiceStaticFile} from "@/utils/common/file";
const props = withDefaults(defineProps<{
visible: boolean,
asset: IAssets.Item
}>(), {
visible: false,
asset: () => ({
name: "",
type: 'Model',
category: "",
thumbnail: "",
size: 0,
file: '',
createTime: "",
updateTime: ""
})
})
const emits = defineEmits(['update:visible'])
let previewer:Preview | null = null;
const assetPreviewRef = useTemplateRef("assetPreviewRef");
watch(() => props.visible, async (newVal) => {
if (newVal) {
if(!previewer){
await nextTick();
previewer = new Preview({
container: assetPreviewRef.value,
hdr: "/static/resource/hdr/cloudy.hdr",
request: {
baseUrl:"/file/static/"
}
});
}
if(props.asset.file){
previewer.load(getServiceStaticFile(props.asset.file),props.asset.type);
}
} else {
disposePreviewer();
}
})
onBeforeUnmount(() => {
disposePreviewer();
})
function disposePreviewer() {
if(previewer){
previewer.dispose();
previewer = null;
}
}
function handleClose() {
emits('update:visible', false);
}
const downloadLoading = ref(false);
function handleDownload() {
downloadLoading.value = true;
downloadWithFetch(getServiceStaticFile(props.asset.file)).finally(() => {
downloadLoading.value = false;
})
}
</script>
<style scoped lang="less">
#assetPreview {
width: calc(100% - 360px);
height: 100%;
}
.n-descriptions {
height: calc(100% - 65px);
margin-bottom: 15px;
overflow-y: auto;
}
</style>
@@ -0,0 +1,98 @@
<template>
<n-modal :show="visible" @update:show="emits('update:visible',$event)" display-directive="show" class="w-66vh h-66vh min-w-740px" @close="emits('update:visible',false)">
<n-card :title="name" embedded size="small" content-class="!p-0 w-full h-full" closable @close="handleClose">
<div ref="commonPreviewRef" class="w-full h-full relative">
<n-button v-if="screenshotShow" class="w-20% absolute bottom-2 left-40%" @click="handleScreenshot">{{ t("scene.Screenshot") }}</n-button>
</div>
</n-card>
</n-modal>
</template>
<script setup lang="ts">
import {nextTick, watch, useTemplateRef, onBeforeUnmount} from "vue";
import {Preview} from "@astral3d/engine";
import {t} from "@/language";
import {getServiceStaticFile} from "@/utils/common/file";
const props = withDefaults(defineProps<{
visible: boolean,
screenshotShow?:boolean,
name: string,
fileOrUrl: File | string |null,
type?: string,
// 是否是编辑器内置
isBuiltin?: boolean,
}>(),{
visible: false,
screenshotShow:false,
name: t("home.Preview"),
fileOrUrl: null,
type: 'Model',
isBuiltin:false
})
const emits = defineEmits(['update:visible','screenshot'])
let previewer:Preview | null = null;
const assetPreviewRef = useTemplateRef("commonPreviewRef");
watch(() => props.visible, async (newVal) => {
if(newVal){
if(!previewer){
await nextTick();
previewer = new Preview({
container: assetPreviewRef.value,
hdr: "/static/resource/hdr/cloudy.hdr",
request: {
baseUrl:"/file/static/"
}
});
}
if(props.fileOrUrl){
let fu = props.fileOrUrl;
if(!(fu instanceof File) && !props.isBuiltin){
fu = getServiceStaticFile(fu);
}
previewer.load(fu,props.type);
}
}else{
disposePreviewer();
}
})
onBeforeUnmount(() => {
disposePreviewer();
})
function disposePreviewer(){
if(previewer){
previewer.dispose();
previewer = null;
}
}
function handleClose(){
emits('update:visible',false);
}
function handleScreenshot(){
if(!previewer) return;
previewer.getViewportImage().then((image:HTMLImageElement) => {
emits('screenshot',image);
}).catch(() => {
window.$message?.error(t("prompt.Screenshots fail"));
})
}
defineExpose({
getPreviewer: async () => {
await nextTick();
return previewer;
},
})
</script>
@@ -0,0 +1,86 @@
<template>
<n-modal :show="visible" @update:show="emits('update:visible',$event)" display-directive="show" class="w-66vh h-66vh min-w-740px" @close="emits('update:visible',false)">
<n-card :title="name" embedded size="small" content-class="!p-0 w-full h-full" closable @close="handleClose">
<div ref="commonPreviewRef" class="w-full h-full relative">
<n-button v-if="screenshotShow" class="w-20% absolute bottom-2 left-40%" @click="handleScreenshot">{{ t("scene.Screenshot") }}</n-button>
</div>
</n-card>
</n-modal>
</template>
<script setup lang="ts">
import {nextTick, watch, useTemplateRef, onBeforeUnmount} from "vue";
import {Preview} from "@astral3d/engine";
import {t} from "@/language";
import {getServiceStaticFile} from "@/utils/common/file";
const props = withDefaults(defineProps<{
visible: boolean,
screenshotShow:boolean,
name: string,
fileOrUrl: File | string |null,
type?: IAssets.SupportType
}>(),{
visible: false,
screenshotShow:false,
name: t("home.Preview"),
fileOrUrl: null,
type: 'Model',
})
const emits = defineEmits(['update:visible','screenshot'])
let previewer:Preview | null = null;
const assetPreviewRef = useTemplateRef("commonPreviewRef");
watch(() => props.visible, async (newVal) => {
if(newVal){
if(!previewer){
await nextTick();
previewer = new Preview({
container: assetPreviewRef.value,
hdr: "/static/resource/hdr/cloudy.hdr",
request: {
baseUrl:"/file/static/"
}
});
}
if(props.fileOrUrl){
let fu = props.fileOrUrl;
if(!(fu instanceof File)){
fu = getServiceStaticFile(fu);
}
previewer.load(fu,props.type);
}
}else{
if(previewer){
previewer.dispose();
previewer = null;
}
}
})
onBeforeUnmount(() => {
if(previewer){
previewer.dispose();
previewer = null;
}
})
function handleClose(){
emits('update:visible',false);
}
function handleScreenshot(){
if(!previewer) return;
previewer.getViewportImage().then((image:HTMLImageElement) => {
emits('screenshot',image);
}).catch(() => {
window.$message?.error(t("prompt.Screenshots fail"));
})
}
</script>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import {reactive, ref} from "vue";
import {FormInst} from "naive-ui";
import {t} from "@/language";
import {CESIUM_DEFAULT_MAP, CESIUM_DEFAULT_MAP_TYPE} from "@/config/cesium";
const formRef = ref<FormInst | null>(null);
const form = reactive({
token: "",
map: "Amap",
mapType: "satellite",
markMap: true
})
const rules = {
token: {required: true, message: t("cesium['Please Enter Cesium Token']"), trigger: ['input', 'blur']}
}
function getData(){
return {...form};
}
function validate(){
return new Promise((resolve,reject) => {
formRef.value?.validate((errors) => {
if (!errors) {
resolve('')
}
else {
reject(errors)
}
})
})
}
defineExpose({
getData,validate
})
</script>
<template>
<n-form :model="form" :rules="rules" label-placement="left" label-align="left" label-width="120" size="small" ref="formRef">
<!-- cesium token -->
<n-form-item label="Cesium Token" path="token">
<n-input v-model:value="form.token" :placeholder="t('cesium.Please Enter Cesium Token')" />
</n-form-item>
<!-- 默认底图 -->
<n-form-item :label="t('cesium.Default base map')">
<n-select v-model:value="form.map" :options="CESIUM_DEFAULT_MAP" />
</n-form-item>
<!-- 默认底图类型 -->
<n-form-item :label="t('cesium.Base map type')">
<n-select v-model:value="form.mapType" :options="CESIUM_DEFAULT_MAP_TYPE" />
</n-form-item>
<n-form-item label=" " v-if="form.mapType === 'satellite'">
<n-checkbox v-model:checked="form.markMap">
{{ t('cesium.Mark map') }}
</n-checkbox>
</n-form-item>
</n-form>
</template>
<style scoped lang="less">
</style>
@@ -0,0 +1,87 @@
<script lang="ts" setup>
import {ref, watch} from "vue";
import {t} from "@/language";
import {defaultProjectInfo} from "@astral3d/engine";
import {SCENE_TYPE} from "@/utils/common/constant";
const props = withDefaults(defineProps<{
value:ISceneFetchData | null
}>(),{
value:null
})
const DefaultSceneData = defaultProjectInfo().sceneInfo;
const formRef = ref();
const form = ref<ISceneFetchData>({...DefaultSceneData,hasDrawing: DefaultSceneData.hasDrawing ? 1 : 0});
const rules = {
sceneName: {required: true, message: t("layout.sider.project.please enter the scene name"), trigger: ['input', 'blur']}
}
watch(() => props.value,(newVal) => {
if(newVal === null){
Object.keys(DefaultSceneData).forEach(key => {
form.value[key] = DefaultSceneData[key];
})
}else{
Object.keys(newVal).forEach(key => {
// createTime & updateTime no replace
if(["createTime","updateTime"].includes(key)) return;
form.value[key] = newVal[key];
})
}
})
function getData():ISceneFetchData{
return {...form.value};
}
function validate(){
return new Promise((resolve,reject) => {
formRef.value?.validate((errors) => {
if (!errors) {
resolve('')
}
else {
reject(errors)
}
})
})
}
defineExpose({
getData,validate
})
</script>
<template>
<n-form ref="formRef" :model="form" :rules="rules" size="small" class="max-w-100%"
label-placement="left" label-width="100">
<!-- 场景名称 -->
<n-form-item :label="t('scene.Name')" path="sceneName">
<n-input v-model:value="form.sceneName"
:placeholder="t('layout.sider.project.please enter the scene name')" />
</n-form-item>
<!-- 场景分类 -->
<n-form-item :label="t('scene.Classification')">
<n-select v-model:value="form.sceneType" filterable tag :options="SCENE_TYPE" />
</n-form-item>
<!-- 场景描述 -->
<n-form-item :label="t('scene.Introduction')">
<n-input v-model:value="form.sceneIntroduction" type="textarea"
:placeholder="t('layout.sider.project[\'please enter the scene introduction\']')" />
</n-form-item>
<!-- 场景版本 -->
<n-form-item :label="t('other.Version')">
<n-input-number v-model:value="form.sceneVersion" button-placement="both" class="text-center" />
</n-form-item>
</n-form>
</template>
<style scoped lang="less">
</style>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import Theme from "./common/Theme.vue";
import Locale from "./common/Locale.vue";
import Color from "./common/Color.vue";
import SettingCenter from "./common/SettingCenter.vue";
withDefaults(defineProps<{
showSetting: boolean
}>(),{
showSetting: true
})
</script>
<template>
<!-- 国际化 -->
<Locale />
<!-- 主题 -->
<Theme />
<!-- 主色调 -->
<Color />
<!-- 设置中心 -->
<SettingCenter v-if="showSetting" />
</template>
<style scoped lang="less">
</style>
@@ -0,0 +1,157 @@
<template>
<n-tooltip trigger="hover">
<template #trigger>
<n-button quaternary @click="show = true">
<template #icon>
<n-icon>
<ColorPalette />
</n-icon>
</template>
</n-button>
</template>
{{ t("layout.header.Main color") }}
</n-tooltip>
<n-modal v-model:show="show" :title="t('layout.header.Main color')" preset="card"
class="w-300 main-colors" :segmented="{ content: 'soft'}" bordered>
<div class="flex h-80vh overflow-y-auto">
<div class="grid grid-cols-4 gap-4 w-70%">
<n-card v-for="color in recommendColors" :key="color.hex" :bordered="false" embedded
hoverable class="w-full cursor-pointer" @click="globalConfigStore.setPrimaryColor(color)">
<div class="w-6px h-full b-rd-3px mr-5px" :style="{backgroundColor: color.hex}"></div>
<div class="flex flex-col justify-between h-full">
<div class="flex items-end mb-1">
<h4 class="text-14px">{{color.name}}</h4>
<span class="text-10px text-gray-400 ml-5px">{{ color.pinyin.toUpperCase() }}</span>
</div>
<div class="text-13px">
{{color.hex}} <n-divider vertical /> {{`RGB(${color.RGB[0]},${color.RGB[1]},${color.RGB[2]})`}}
</div>
</div>
</n-card>
<n-divider class="grid-col-start-1 grid-col-end-5" />
<n-card v-for="color in colors" :key="color.hex" :bordered="false" embedded
hoverable class="w-full cursor-pointer" @click="globalConfigStore.setPrimaryColor(color)">
<div class="w-6px h-full b-rd-3px mr-5px" :style="{backgroundColor: color.hex}"></div>
<div class="flex flex-col justify-between h-full">
<div class="flex items-end mb-1">
<h4 class="text-14px">{{color.name}}</h4>
<span class="text-10px text-gray-400 ml-5px">{{ color.pinyin.toUpperCase() }}</span>
</div>
<div class="text-13px">
{{color.hex}} <n-divider vertical /> {{`RGB(${color.RGB[0]},${color.RGB[1]},${color.RGB[2]})`}}
</div>
</div>
</n-card>
</div>
<div class="w-30% h-full flex justify-center items-center absolute top-0 right-0">
<n-card class="w-60% h-60%" content-class="w-full h-full flex justify-around overflow-y-auto color-show" :content-style="{backgroundColor: primaryColor.hex}">
<div class="c-#FFF h-full flex flex-col justify-center items-center">
<h1 class="text-60px mb-5px w-60px ">{{primaryColor.name}}</h1>
<p>{{primaryColor.pinyin.toUpperCase()}}</p>
</div>
<div class="flex flex-col text-12px c-#fff">
<n-divider />
<span>C</span>
<n-progress type="circle" :stroke-width="5" class="!w-50px" unit=""
:percentage="primaryColor.CMYK[0]" color="#0093D3" indicator-text-color="#0093D3"/>
<n-divider />
<span>M</span>
<n-progress type="circle" :stroke-width="5" class="!w-50px" unit=""
:percentage="primaryColor.CMYK[1]" color="#CC006B" indicator-text-color="#CC006B"/>
<n-divider />
<span>Y</span>
<n-progress type="circle" :stroke-width="5" class="!w-50px" unit=""
:percentage="primaryColor.CMYK[2]" color="#FFF10C" indicator-text-color="#FFF10C"/>
<n-divider />
<span>K</span>
<n-progress type="circle" :stroke-width="5" class="!w-50px" unit=""
:percentage="primaryColor.CMYK[3]" color="#333" indicator-text-color="#333"/>
<n-divider />
<span>R</span>
<div class="text-16px text-end">{{primaryColor.RGB[0]}}</div>
<n-divider />
<span>G</span>
<div class="text-16px text-end">{{primaryColor.RGB[1]}}</div>
<n-divider />
<span>B</span>
<div class="text-16px text-end">{{primaryColor.RGB[2]}}</div>
<n-divider />
</div>
</n-card>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
import {onMounted,ref,computed} from "vue";
import {ColorPalette} from "@vicons/carbon";
import {useGlobalConfigStore} from "@/store/modules/globalConfig";
import {t} from "@/language";
import ChineseColors from "@/assets/color/ChineseColors.json";
import Recommend from "@/assets/color/recommend.json";
const globalConfigStore = useGlobalConfigStore();
const show = ref(false);
const recommendColors = ref<IConfig.Color[]>(Recommend);
const colors = ref<IConfig.Color[]>(ChineseColors);
const primaryColor = computed(() => globalConfigStore.mainColor as IConfig.Color);
onMounted(() => {
getColors();
})
function getColors() {
}
</script>
<style scoped lang="less">
.n-card{
:deep(&__content){
flex: unset;
display: flex;
padding: 10px;
&:first-child{
padding-top:10px;
}
}
}
.n-divider:not(.n-divider--vertical){
margin-top: 10px;
margin-bottom: 10px;
:deep(.n-divider__line){
background-color: #F3F3F3;
}
}
</style>
<style lang="less">
.main-colors{
background-color:var(--n-color) !important;
.n-card-header{
.n-base-close{
z-index: 9999;
}
}
}
.color-show{
background-image: url(/static/images/color-texture.png);
background-color: #ddd;
transition: background-color 1s ease-in-out;
}
</style>
@@ -0,0 +1,25 @@
<template>
<n-popselect v-model:value="value" :options="options" @update:value="setLocale">
<n-button quaternary class="mr-1">
<template #icon>
<n-icon>
<Language />
</n-icon>
</template>
</n-button>
</n-popselect>
</template>
<script setup lang="ts">
import {ref} from "vue";
import {Language} from "@vicons/carbon";
import {useGlobalConfigStore} from "@/store/modules/globalConfig";
import {setLocale} from "@/language";
const globalConfigStore = useGlobalConfigStore();
const value = ref(<string>globalConfigStore.locale);
const options = [
{ label: '中文', value: 'zh-CN' },
{ label: 'English', value: 'en-US' }
];
</script>
@@ -0,0 +1,41 @@
<template>
<n-tooltip trigger="hover">
<template #trigger>
<n-button quaternary @click="show = true">
<template #icon>
<n-icon>
<Settings />
</n-icon>
</template>
</n-button>
</template>
{{ t("setting.Setting") }}
</n-tooltip>
<n-modal v-model:show="show" display-directive="show" :z-index="zIndex" class="w-100 h-40vh">
<n-card size="small">
<SettingTabs />
</n-card>
</n-modal>
</template>
<script setup lang="ts">
import {ref,onMounted} from "vue";
import {Settings} from "@vicons/carbon";
import {t} from "@/language";
const show = ref(true);
const zIndex = ref<number | undefined>(-1);
onMounted(() => {
show.value = false;
zIndex.value = undefined;
});
</script>
<style scoped lang="less">
.n-tab-pane{
width: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,42 @@
<template>
<n-tooltip trigger="hover">
<template #trigger>
<n-button quaternary @click="globalConfigStore.setTheme()" class="mr-1">
<template #icon>
<n-icon>
<component :is="component"/>
</n-icon>
</template>
</n-button>
</template>
{{ tooltip }}
</n-tooltip>
</template>
<script setup lang="ts">
import {computed,ref} from "vue";
import {Contrast, LightFilled, AsleepFilled} from "@vicons/carbon";
import {useGlobalConfigStore} from "@/store/modules/globalConfig";
import {t} from "@/language";
const globalConfigStore = useGlobalConfigStore();
const tooltip = ref(t("layout.header['Use system theme']"));
const component = computed(() => {
switch (globalConfigStore.theme) {
case "osTheme":
tooltip.value = t("layout.header['Use system theme']");
return Contrast;
case "lightTheme":
tooltip.value = t('layout.header.Undertint');
return LightFilled;
case "darkTheme":
tooltip.value = t('layout.header.Dark');
return AsleepFilled;
}
})
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,59 @@
<template>
<n-form :model="form" label-placement="left" :label-width="120" class="mt-2" :style="{ minWidth: '300px' }">
<n-form-item :label="t('setting.preview.Roaming character')">
<n-radio-group v-model:value="form.roamingCharacter" name="roaming_character" @update:value="handleRoamingCharacterChange">
<n-space>
<n-radio v-for="character in characters" :key="character.value" :value="character.value">
<img :src="character.image" alt="" class="w-100px b-rd-6px" />
</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
</n-form>
</template>
<script setup lang="ts">
import {onMounted,reactive} from "vue";
import {t} from "@/language";
import {App,ROAMING_CHARACTERS} from "@astral3d/engine";
import {MenuOperation} from "@/utils/preview/menuOperation";
const form = reactive({
roamingCharacter: App.config.getKey("roamingCharacter"),
})
const characters = [
{
image: "/static/images/roaming/Jackie.jpg",
value: ROAMING_CHARACTERS.JACKIE,
},
{
image: "/static/images/roaming/Workman.jpg",
value: ROAMING_CHARACTERS.WORK_MAN,
},
{
image: "/static/images/roaming/X_Bot.jpg",
value: ROAMING_CHARACTERS.X_BOT,
},
{
image: "/static/images/roaming/Y_Bot.jpg",
value: ROAMING_CHARACTERS.Y_BOT,
}
];
// 漫游角色变更
function handleRoamingCharacterChange(){
App.config.setKey("roamingCharacter",form.roamingCharacter);
if(MenuOperation._roaming && MenuOperation._roaming.isRoaming){
MenuOperation.Roaming.reloadPerson();
}
}
onMounted(() => {
form.roamingCharacter = App.config.getKey("roamingCharacter");
})
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import {t} from "@/language";
</script>
<template>
<n-tabs type="line" animated default-value="shortcuts">
<n-tab-pane name="system" :tab="t('setting.System Setting')" display-directive="show">
<SystemSetting />
</n-tab-pane>
<n-tab-pane name="preview" :tab="t('setting.Preview Setting')" display-directive="show">
<PreviewSetting />
</n-tab-pane>
<n-tab-pane name="shortcuts" :tab="t('setting.Shortcuts')" display-directive="show">
<Shortcuts />
</n-tab-pane>
</n-tabs>
</template>
<style scoped lang="less">
</style>
@@ -0,0 +1,113 @@
<template>
<n-form :model="form" label-placement="left" :label-width="120" class="mt-2"
:style="{ minWidth: '300px' }">
<n-form-item :label="t('setting.shortcuts.Translate')">
<n-input v-model:value="form.translate" readonly maxlength="1"
:placeholder="t('setting.shortcuts.Please press a key')"
@keyup="shortcutsKeyup($event, 'translate')"/>
</n-form-item>
<n-form-item :label="t('setting.shortcuts.Rotate')">
<n-input v-model:value="form.rotate" readonly maxlength="1"
:placeholder="t('setting.shortcuts.Please press a key')"
@keyup="shortcutsKeyup($event, 'rotate')"/>
</n-form-item>
<n-form-item :label="t('setting.shortcuts.Scale')">
<n-input v-model:value="form.scale" readonly maxlength="1"
:placeholder="t('setting.shortcuts.Please press a key')"
@keyup="shortcutsKeyup($event, 'scale')"/>
</n-form-item>
<n-form-item :label="t('setting.shortcuts.Undo')">
<n-input v-model:value="form.undo" readonly maxlength="1"
:placeholder="t('setting.shortcuts.Please press a key')" @keyup="shortcutsKeyup($event, 'undo')"/>
<EsTip class="ml-1">
{{
`${!Utils.IS_MAC ? 'Ctrl' : 'Meta'} + ${form.undo.toUpperCase()} ${t('other.undo')},${!Utils.IS_MAC ? 'Ctrl' : 'Meta'} + Shift + ${form.undo.toUpperCase()} ${t('other.redo')}`
}}
</EsTip>
</n-form-item>
<n-form-item :label="t('setting.shortcuts.Focus')">
<n-input v-model:value="form.focus" readonly maxlength="1"
:placeholder="t('setting.shortcuts.Please press a key')"
@keyup="shortcutsKeyup($event, 'focus')"/>
</n-form-item>
</n-form>
</template>
<script setup lang="ts">
import {reactive,onMounted} from "vue";
import {t} from "@/language";
import EsTip from "@/components/es/EsTip.vue";
import {App,RemoveObjectCommand,Hooks,Utils} from "@astral3d/engine";
// 由于撤消/重做,目前不能使用z
const form = reactive({
// shortcuts
translate: App.config.getShortcutItem('translate'),
rotate: App.config.getShortcutItem('rotate'),
scale: App.config.getShortcutItem('scale'),
undo: App.config.getShortcutItem('undo'),
focus: App.config.getShortcutItem('focus'),
})
// 快捷键输入框keyup
const isValidKeyBinding = (key) => key.match(/^[A-Za-z0-9]$/i);
function shortcutsKeyup(event:KeyboardEvent, varName:string) {
//判断按下的是否是有效的键
if (!isValidKeyBinding(event.key)) return;
form[varName] = event.key;
App.config.setShortcutItem(varName, event.key.toLowerCase());
}
onMounted(() => {
document.addEventListener("keydown", (event) => {
// 如果事件目标是输入框(INPUT 或 TEXTAREA),则直接返回
if (event.target && ['INPUT', 'TEXTAREA'].includes((<HTMLElement>event.target).tagName.toUpperCase())) {
return;
}
switch (event.key.toLowerCase()) {
case 'delete':
const object = App.selected;
if (object === null) return;
const parent = object.parent;
if (parent !== null) App.execute(new RemoveObjectCommand(object));
break;
case App.config.getShortcutItem('translate'):
Hooks.useDispatchSignal('transformModeChanged', 'translate');
break;
case App.config.getShortcutItem('rotate'):
Hooks.useDispatchSignal('transformModeChanged', 'rotate');
break;
case App.config.getShortcutItem('scale'):
Hooks.useDispatchSignal('transformModeChanged', 'scale');
break;
case App.config.getShortcutItem('undo'):
// windows下:ctrl + App.config.getShortcutItem('shortcuts/undo') 撤销,同时按下shift重做
// mac下:meta + App.config.getShortcutItem('shortcuts/undo') 撤销,同时按下shift重做
if (Utils.IS_MAC ? event.metaKey : event.ctrlKey) {
//阻止特定于浏览器的热键
event.preventDefault();
if (event.shiftKey) {
App.redo();
} else {
App.undo();
}
}
break;
case App.config.getShortcutItem('focus'):
if (App.selected !== null) {
App.focus(App.selected);
}
break;
}
})
})
</script>
<style scoped lang="less">
.n-input{
width: 6rem !important;
text-align: center;
}
</style>
@@ -0,0 +1,11 @@
<template>
<n-result status="418" class="my-20" title="Empty" :description="t('setting.system.No system Settings are available')" />
</template>
<script setup lang="ts">
import {t} from "@/language";
</script>
<style scoped lang="less">
</style>
@@ -0,0 +1,444 @@
<script lang="ts" setup>
import {h, ref, onMounted, nextTick, onBeforeUnmount} from "vue";
import {TreeOption, TreeDropInfo, NIcon, NBadge, NEllipsis} from "naive-ui";
import {
Camera,
Schematics,
Cube,
ModelAlt,
HeatMap,
Folder,
Light,
Soccer,
Draw,
FloatingIp,
Network1,
Image,
LocationHeart,
LocationCompany,
ChoroplethMap
} from '@vicons/carbon';
import {t} from "@/language";
import {App,Hooks, MoveObjectCommand, RemoveObjectCommand, AddObjectCommand} from "@astral3d/engine";
import {escapeHTML, findSiblingsAndIndex} from "@/utils/common/utils";
import {getMaterialName} from "@/utils/common/scenes";
import EsContextmenu from "@/components/es/EsContextmenu.vue";
const sceneTreeRef = ref();
const pattern = ref("");
const sceneTreeData = ref<TreeOption[]>([
{
label: window.$cpt("core.editor['Default Camera']"),
key: 0,
isLeaf: true,
disabled: false,
prefix: getPrefixIcon("PerspectiveCamera"),
},
{
label: window.$cpt("core.editor['Default Scene']"),
key: 1,
isLeaf: true,
disabled: false,
prefix: getPrefixIcon("Scene"),
}
]);
const sceneTreeSelected = ref<Array<string | number>>([]);
const sceneTreeExpandedKeys = ref<number[]>([]);
function objectSelected(object) {
if (object !== null && object.parent !== null) {
sceneTreeSelected.value = [object.id];
// 将此id父级递归展开
sceneTreeExpandedKeys.value = [App.scene.id];
function getParentId(obj) {
if (obj.parent.id !== App.scene.id) {
sceneTreeExpandedKeys.value.push(obj.parent.id);
getParentId(obj.parent);
}
}
getParentId(object)
//在虚拟滚动模式下滚动到某个节点
sceneTreeRef.value?.scrollTo({key: object.id})
} else {
sceneTreeSelected.value = [];
}
}
// 更新树
function refreshUI() {
const camera = App.camera;
const scene = App.scene;
const _sceneTreeData: any = [];
_sceneTreeData.push({
label: window.$cpt("core.editor['Default Camera']"),
key: camera.id,
isLeaf: true,
disabled: (App.locked && App.locked.uuid !== camera.uuid),
prefix: getPrefixIcon(camera.type),
});
const sceneDisabled = (App.locked && App.locked.uuid !== scene.uuid) as boolean;
_sceneTreeData.push({
label: window.$cpt("core.editor['Default Scene']"),
key: scene.id,
isLeaf: false,
disabled: sceneDisabled,
prefix: getPrefixIcon(scene.type),
// children: App.locked ? (function (){
// if([scene.uuid,camera.uuid].includes(App.locked.uuid)){
// return [];
// }else {
// return [getTreeData(App.locked)];
// }
// })() : addObjects(scene)
children: addObjects(scene, sceneDisabled)
});
if (sceneTreeExpandedKeys.value.length === 0) {
sceneTreeExpandedKeys.value = [scene.id];
}
function getTreeData(object3D, disabled = true) {
const data: TreeOption = {
label: escapeHTML(object3D.name),
key: object3D.id,
// isLeaf: object3D.children.length === 0 && object3D.type !== "Group"
isLeaf: object3D.children.length === 0 || object3D.isTilesGroup,
disabled: App.locked ? disabled : false,
prefix: getPrefixIcon(object3D.type),
}
if (!data.isLeaf) {
data.children = addObjects(object3D, disabled);
}
if (object3D.isMesh) {
const geometry = object3D.geometry;
const material = object3D.material;
data.suffix = () => {
return h('div', {class: "ml-4 text-12px"}, [
h(
NBadge,
{dot: true, type: 'success'},
{},
),
h(NEllipsis, {class: "!max-w-100px"}, {
default: () => h("span", {class: 'ml-1 mr-2'}, {default: () => escapeHTML(geometry.name)})
}),
h(
NBadge,
{dot: true, type: 'warning'},
{},
),
h(NEllipsis, {class: "!max-w-100px"}, {
default: () => h("span", {class: 'ml-1 mr-2'}, {default: () => escapeHTML(getMaterialName(material))})
}),
])
}
}
return data;
}
function addObjects(object3D, disabled = true) {
const childArr: TreeOption[] = [];
if (object3D.ignore) return childArr;
//for循环 为大场景提升遍历效率
for (let i = 0, l = object3D.children.length; i < l; i++) {
let _disabled = disabled;
const child = object3D.children[i];
if (child.ignore) continue;
if (_disabled) {
_disabled = (App.locked && App.locked.uuid !== child.uuid) as boolean;
}
childArr.push(getTreeData(child, _disabled));
}
return childArr;
}
if (App.selected !== null) {
sceneTreeSelected.value = [App.selected.id];
}
sceneTreeData.value = _sceneTreeData;
}
// 获取节点前缀图标
function getPrefixIcon(type: string) {
const getIconRender = (icon: any) => {
return h(
NIcon,
{size: 16},
{default: () => h(icon)}
)
}
switch (type) {
case "PerspectiveCamera":
case "OrthographicCamera":
return () => getIconRender(Camera);
case "Light":
case "AmbientLight":
case "DirectionalLight":
case "HemisphereLight":
case "PointLight":
case "SpotLight":
case "RectAreaLight":
return () => getIconRender(Light);
case "Scene":
return () => getIconRender(Schematics);
case "Group":
return () => getIconRender(Folder);
case "Mesh":
case "SkinnedMesh":
return () => getIconRender(Cube);
case "BatchedMesh":
case "InstancedMesh":
return () => getIconRender(ModelAlt);
case "Line":
case "LineLoop":
case "LineSegments":
return () => getIconRender(Draw);
case "Points":
return () => getIconRender(Network1);
case "Bone":
return () => getIconRender(FloatingIp);
case "Skeleton":
return () => getIconRender(Soccer);
case 'Sprite':
return () => getIconRender(Image);
case "Particle":
return () => getIconRender(HeatMap);
case "Billboard":
return () => getIconRender(LocationHeart);
case "HtmlPanel":
case "HtmlSprite":
return () => getIconRender(LocationCompany);
case "TilesGroup":
case "Tile":
return () => getIconRender(ChoroplethMap);
default:
return () => getIconRender(Cube);
}
}
//移动模型
function moveObject(object, newParent, nextObject) {
if (nextObject === null) nextObject = undefined;
let newParentIsChild = false;
object.traverse(function (child) {
if (child === newParent) newParentIsChild = true;
});
if (newParentIsChild) return;
App.execute(new MoveObjectCommand(object, newParent, nextObject));
}
/**
* 处理树节点拖动
* @param node 拖动到的目标位置节点
* @param dragNode 被拖动的节点
* @param dropPosition 拖动到的相对于目标节点的位置
*/
function handleSceneTreeDrop({node, dragNode, dropPosition}: TreeDropInfo) {
//无法移动到默认场景之外
if (node.label === window.$t("core.editor['Default Camera']") || node.label === window.$t("core.editor['Default Scene']")) return;
// 要拖动到的目标模型
const targetParentObject3D = App.scene.getObjectById(Number(node.key));
if(!targetParentObject3D) return;
// 被拖动的模型
const dragObject3D = App.scene.getObjectById(Number(dragNode.key));
const [dragNodeSiblings, dragNodeIndex] = findSiblingsAndIndex(dragNode, sceneTreeData.value);
if (dragNodeSiblings === null || dragNodeIndex === null) return;
//在被拖动节点的父级中删除该节点
dragNodeSiblings.splice(dragNodeIndex, 1);
switch (dropPosition) {
case "inside":
if (node.children) {
node.children.unshift(dragNode)
} else {
node.children = [dragNode];
node.isLeaf = false;
}
// 移动模型
moveObject(dragObject3D, targetParentObject3D, null);
break;
case "before":
// 寻找目标位置节点的父级及该节点的索引
const [_nodeSiblings, _nodeIndex] = findSiblingsAndIndex(node, sceneTreeData.value);
if (_nodeSiblings === null || _nodeIndex === null) return;
_nodeSiblings.splice(_nodeIndex, 0, dragNode);
// 移动模型
moveObject(dragObject3D, targetParentObject3D.parent, targetParentObject3D);
break;
case "after":
const [nodeSiblings, nodeIndex] = findSiblingsAndIndex(node, sceneTreeData.value);
if (nodeSiblings === null || nodeIndex === null) return
nodeSiblings.splice(nodeIndex + 1, 0, dragNode);
// 移动模型
moveObject(dragObject3D, targetParentObject3D.parent, targetParentObject3D.parent?.children[targetParentObject3D.parent?.children.indexOf(targetParentObject3D) + 1]);
break;
}
sceneTreeData.value = Array.from(sceneTreeData.value);
}
// 判断树节点是否可拖动到对应选择位置(拖动到内部时只能是Group / Scene
function allowDrop({dropPosition, node}) {
if (dropPosition === "inside") {
// 要拖动到的目标模型
const targetParentObject3D = App.scene.getObjectById(Number(node.key));
if (targetParentObject3D?.type !== "Group" && targetParentObject3D?.type !== "Scene") {
return false
}
}
return true;
}
//场景树节点选中/取消选中事件
function handlerTreeSelectChange(keys: Array<number>, _: Array<TreeOption>, meta: {
node: TreeOption,
action: 'select' | 'unselect'
}) {
sceneTreeSelected.value = keys;
if (meta.action === "select") {
App.selectById(keys[0]);
} else {
App.deselect();
}
}
// 场景树节点点击事件,主要用于配合右键菜单
function nodeProps({option}: { option: TreeOption }) {
return {
onContextmenu(e: MouseEvent): void {
e.preventDefault();
if ([App.camera.id, App.scene.id].includes(option.key as number)) return;
contextmenuRef.value?.show(e.clientX, e.clientY);
contextmenuTreeOption.value = option;
}
}
}
/* 右键菜单 */
const contextmenuRef = ref();
const contextmenuOptions = [
{
label: t("other.Focus"),
key: 'focus'
}
];
const contextmenuTreeOption = ref<TreeOption | null>(null);
function handleContextmenuSelect(key: string) {
if (!contextmenuTreeOption) return;
const object = App.scene.getObjectById(contextmenuTreeOption.value?.key as number);
if(!object) return;
switch (key) {
case "focus":
App.focus(object);
break;
case "delete":
const parent = object.parent;
if (parent !== null) App.execute(new RemoveObjectCommand(object));
break;
case "clone":
const _object = object.clone();
App.execute(new AddObjectCommand(_object));
break;
}
}
onMounted(async () => {
// 此signal必须在nextTick方法前注册,否则会造成viewer中已分发此处却尚未监听
Hooks.useAddOnceSignal("viewerInitCompleted",(viewer) => {
if(viewer.enableEdit){
contextmenuOptions.push({
label: t("home.Delete"),
key: 'delete'
},{
label: t("layout.header.Clone"),
key: 'clone'
})
}
});
await nextTick();
refreshUI();
Hooks.useAddSignal("sceneCleared", refreshUI);
Hooks.useAddSignal("sceneTreeChange", refreshUI);
Hooks.useAddSignal("objectAdded", refreshUI);
Hooks.useAddSignal("objectRemoved", refreshUI);
Hooks.useAddSignal("objectSelected", objectSelected);
Hooks.useAddSignal("objectLocked", refreshUI);
Hooks.useAddSignal("objectUnlocked", refreshUI);
})
onBeforeUnmount(() => {
Hooks.useRemoveSignal("sceneCleared", refreshUI);
Hooks.useRemoveSignal("sceneTreeChange", refreshUI);
Hooks.useRemoveSignal("objectAdded", refreshUI);
Hooks.useRemoveSignal("objectRemoved", refreshUI);
Hooks.useRemoveSignal("objectSelected", objectSelected);
Hooks.useRemoveSignal("objectLocked", refreshUI);
Hooks.useRemoveSignal("objectUnlocked", refreshUI);
})
</script>
<template>
<n-input v-model:value="pattern" :placeholder="t('layout.sider.scene.Search')"/>
<n-tree ref="sceneTreeRef" virtual-scroll :pattern="pattern" :data="sceneTreeData" v-model:selected-keys="sceneTreeSelected"
:show-irrelevant-nodes="false" v-model:expanded-keys="sceneTreeExpandedKeys" draggable :allow-drop="allowDrop"
:node-props="nodeProps" @drop="handleSceneTreeDrop" @update:selected-keys="handlerTreeSelectChange" block-line
/>
<EsContextmenu ref="contextmenuRef" placement="right-start" trigger="manual" size="small"
:options="contextmenuOptions" @select="handleContextmenuSelect"/>
</template>
<style lang="less" scoped>
.n-input {
margin-bottom: 10px;
height: 34px;
}
.n-tree {
height: calc(100% - 44px);
width: 100%;
overflow-x: auto;
:deep(.n-tree-node-wrapper) {
white-space: nowrap;
}
:deep(.n-tree-node-content__text) {
flex-grow: unset;
}
}
</style>