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
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts" setup>
import {computed} from 'vue';
import {GlobalThemeOverrides,zhCN, dateZhCN,enUS,dateEnUS} from "naive-ui";
import {useGlobalConfigStore} from "@/store/modules/globalConfig";
import Index from "@/views/index.vue";
// 全局配置相关
const globalConfigStore = useGlobalConfigStore();
const NaiveUILocale = computed(() => {
switch(globalConfigStore.locale){
case 'zh-CN':
return {
locale:zhCN,
dateLocale:dateZhCN
}
case 'en-US':
return {
locale:enUS,
dateLocale:dateEnUS
}
}
});
const themeOverrides = computed<GlobalThemeOverrides>(() => {
const mainColor = globalConfigStore.mainColor as IConfig.Color;
return {
common: {
primaryColor: mainColor.hex,
primaryColorHover: mainColor.hexHover,
primaryColorPressed: mainColor.hexPressed,
primaryColorSuppl: mainColor.hexSuppl,
successColor: mainColor.hex,
successColorHover: mainColor.hexHover,
successColorPressed: mainColor.hexPressed,
successColorSuppl: mainColor.hexSuppl,
fontWeightStrong: '600'
}
}
})
</script>
<template>
<!-- 调整 naive-ui 的字重配置 -->
<n-config-provider :locale="NaiveUILocale.locale" :date-locale="NaiveUILocale.dateLocale" :theme="globalConfigStore.getProviderTheme()" :theme-overrides="themeOverrides">
<n-loading-bar-provider>
<n-dialog-provider>
<n-modal-provider>
<n-notification-provider placement="bottom">
<n-message-provider>
<Index/>
</n-message-provider>
</n-notification-provider>
</n-modal-provider>
</n-dialog-provider>
</n-loading-bar-provider>
</n-config-provider>
</template>
<style scoped>
.n-config-provider {
width: 100%;
height: 100%;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
[
{
"CMYK": [56, 0, 19, 11],
"RGB": [99, 226, 183],
"hex": "#63E2B7",
"hexHover": "#63E2B7",
"hexPressed": "#63E2B7",
"hexSuppl": "#63E2B7",
"name": "\u9e23\u7fe0\u67f3",
"pinyin": "mingcuiliu"
},
{
"CMYK": [
95,
25,
20,
4
],
"RGB": [
21,
139,
184
],
"hex": "#158bb8",
"name": "\u9e22\u5c3e\u84dd",
"pinyin": "yuanweilan"
},
{
"CMYK": [
0,
84,
82,
0
],
"RGB": [
243,
59,
31
],
"hex": "#f33b1f",
"name": "\u6781\u5149\u7ea2",
"pinyin": "jiguanghong"
},
{
"CMYK": [
1,
28,
89,
0
],
"RGB": [
246,
196,
48
],
"hex": "#f6c430",
"name": "\u9e66\u9e49\u51a0\u9ec4",
"pinyin": "yingwuguanhuang"
}
]
@@ -0,0 +1,59 @@
.sidebar-config-item{
display: flex;
align-items: center;
padding: 0 1rem 0.5rem 1rem;
width: calc(100% - 2rem);
& > :first-child{
width: 30%;
}
& > :last-child{
width: 70%;
}
}
.es-upload-card {
.n-upload-file-list {
&--grid {
grid-template-columns:repeat(1, 100%);
width: 100%;
min-height: 120px;
}
.n-upload-trigger {
&--image-card {
width: 100%;
height: 100%;
border-radius: 0.25rem;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: all 0.3s ease-in-out;
.n-icon {
margin-bottom: 12px;
margin-left: 1rem;
}
.n-text {
font-size: 0.7rem;
margin-right: 1rem;
}
}
}
.n-upload-file {
width: 100%;
height: 100%;
}
}
&-dragger {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
}
@@ -0,0 +1,3 @@
@import './reset.less';
@import './common.less';
@import './viewport.cube.less';
@@ -0,0 +1,55 @@
:root {
--header-height: 50px;
--footer-height: 40px;
// scene下方扩展面板顶部高度
--scene-extend-header-height: 38px;
--animate-duration: .25s;
}
* {
padding: 0;
margin: 0;
}
html,
body:first-child,
#app {
width: 100vw;
height: 100vh;
overflow: hidden;
}
/*去除a标签所有默认样式*/
a,a:link,a:visited,a:hover,a:active{
text-decoration: none;
color:inherit;
}
/*顶部菜单弹出框样式*/
.n-dropdown-menu {
/* max-height: 25rem;
overflow-y: auto; */
}
/* 减小form表单item上下间距 */
.n-form-item-feedback-wrapper {
min-height: 0.6rem !important;
}
::-webkit-scrollbar {
width: .25rem;
height: .25rem;
}
/*定义滚动条轨道 内阴影+圆角*/
::-webkit-scrollbar-track {
border-radius: .5rem;
background-color: rgba(58, 51, 51, 0.075);
}
/*定义滑块 内阴影+圆角*/
::-webkit-scrollbar-thumb {
border-radius: 10px;
box-shadow: inset 0 0 .3rem rgba(0, 0, 0, .2);
background-color: rgba(0, 0, 0, 0.2);
}
@@ -0,0 +1,68 @@
#es-view-cube-container {
width: 120px;
height: 120px;
margin: 10px;
perspective: 600px;
position: absolute;
right: 0;
bottom: 0;
z-index: 1000;
display: block;
.es-view-cube {
width: 100px;
height: 100px;
position: relative;
transform-style: preserve-3d;
transform: translateZ(-300px);
text-transform: uppercase;
.es-view-cube-face {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
width: 120px;
height: 120px;
border: 1px dashed #808080;
line-height: 100px;
font-size: 25px;
font-weight: bold;
color: #7d7d7d;
text-align: center;
background: #fff;
transition: all 0.1s;
cursor: pointer;
user-select: none;
&:hover {
background: #adadad;
color: #fff;
}
}
.es-view-cube-face--top {
transform: rotateY(0deg) rotateX(90deg) translateZ(-60px);
}
.es-view-cube-face--bottom {
transform: rotateX(270deg) translateZ(-60px);
}
.es-view-cube-face--left {
transform: rotateY(-90deg) rotateX(180deg) rotateZ(0deg) translateZ(-60px);
}
.es-view-cube-face--right {
transform: rotateY(90deg) rotateX(180deg) rotateZ(0deg) translateZ(-60px);
}
.es-view-cube-face--front {
transform: rotateX(180deg) translateZ(-60px);
}
.es-view-cube-face--back {
transform: rotateZ(180deg) translateZ(-60px);
}
}
}
+238
View File
@@ -0,0 +1,238 @@
import MapLayer from "@/cesium/modules/mapLayer";
import CameraUtils from "@/cesium/modules/cameraUtils";
import * as Cesium from 'cesium';
import * as THREE from 'three';
import {App,Hooks,Utils} from "@astral3d/engine";
import {render, VNode} from "vue";
/**
* @Date 2023-03-07
* @Author 二三
* @Description: cesium核心
*/
const onDownPosition = new THREE.Vector2();
const onUpPosition = new THREE.Vector2();
export default class CesiumApp {
//挂载的 Dom的Id
dom: HTMLElement;
//canvas存放的父级dom
cesiumParentElement:HTMLElement;
//
viewer: Cesium.Viewer;
//
helper: Cesium.EventHelper;
//cesium模块
module: {
mapLayer: MapLayer,
cameraUtils:CameraUtils
}
//three对象
public _three:{
camera:THREE.PerspectiveCamera,
scene:THREE.Scene,
sceneHelpers:THREE.Scene,
showSceneHelpers:boolean,
renderer:THREE.WebGLRenderer
}
constructor(dom) {
this.dom = dom;
// 提前实例化相关模块
this.module = {
mapLayer: new MapLayer(),
cameraUtils:new CameraUtils()
};
Cesium.Ion.defaultAccessToken = App.project.getKey("sceneInfo.cesiumConfig.token");
/**
* 避免https://api.cesium.com/v1/assets/1/endpoint?access_token=xxxx请求
* 1、baseLayerPicker: false必须添加
* 2、必须给一个imageryProvider作为默认底图
*/
this.viewer = this.getViewer();
this.helper = new Cesium.EventHelper();
this.handleInitCesiumAfter();
/* 初始化threejs相关(使用threejs场景的camera、scene、renderer) */
this.cesiumParentElement = this.viewer.cesiumWidget.canvas.parentElement as HTMLElement;
this._three = {
camera: App.camera,
scene: App.scene,
sceneHelpers: new THREE.Scene(), //不使用window.editor.sceneHelpers,避免两个辅助场景内容重合
showSceneHelpers:true,
renderer:new THREE.WebGLRenderer({
antialias: true,
alpha: true,
// logarithmicDepthBuffer:false,//重叠闪烁
})
}
// 注意这里,直接把three容器(canvas 添加到 cesium中,在cesium的canvas之下),这样的话,两个canvas才会重叠起来。
this.cesiumParentElement.appendChild(this._three.renderer.domElement);
//添加场景的事件监听
this.eventListener();
}
getViewer(){
return new Cesium.Viewer(this.dom, {
animation: false, //是否创建动画小器件,左下角仪表
baseLayerPicker: true, //是否显示图层选择器
fullscreenButton: false, //是否显示全屏按钮
geocoder: true, //是否显示geocoder小器件,位置查找工具
homeButton: false, //是否显示Home按钮,返回初始位置
vrButton: false, // VR
infoBox: false, //是否显示点击要素之后显示的信息
sceneModePicker: false, //是否显示3D/2D选择器 - 选择视角的模式(球体、平铺、斜视平铺)
selectionIndicator: false, //是否显示选取指示器组件
timeline: false, //是否显示底部时间轴
navigationHelpButton: false, //是否显示右上角的帮助按钮
scene3DOnly: true, //如果设置为true,则所有几何图形以3D模式绘制以节约GPU资源
// @ts-ignore
clock: new Cesium.Clock(), //用于控制当前时间的时钟对象
selectedImageryProviderViewModel: undefined, //当前图像图层的显示模型,仅baseLayerPicker设为true有意义
selectedTerrainProviderViewModel: undefined, //当前地形图层的显示模型,仅baseLayerPicker设为true有意义
imageryProvider: this.module.mapLayer.getDefaultLayer(App.project.getKey("sceneInfo.cesiumConfig.mapType")),
terrainProvider: new Cesium.EllipsoidTerrainProvider(), //地形图层提供者,仅baseLayerPicker设为false有意义
fullscreenElement: document.body, //全屏时渲染的HTML元素,
useDefaultRenderLoop: false, //如果需要控制渲染循环,则设为true
targetFrameRate: undefined, //使用默认render loop时的帧率
showRenderLoopErrors: true, //如果设为true,将在一个HTML面板中显示错误信息
automaticallyTrackDataSourceClocks: true, //自动追踪最近添加的数据源的时钟设置
contextOptions: undefined, //传递给Scene对象的上下文参数(scene.options
sceneMode: Cesium.SceneMode.SCENE3D, //初始场景模式
mapProjection: new Cesium.WebMercatorProjection(), //地图投影体系
dataSources: new Cesium.DataSourceCollection(), //需要进行可视化的数据源的集合
});
}
/**
* 场景事件监听
*/
eventListener(){
this.cesiumParentElement.addEventListener('pointerdown',this.onMouseDown.bind(this),true);
this.cesiumParentElement.addEventListener('pointerup', this.onMouseUp.bind(this),true);
}
/**
* 鼠标按下处理
* @param event
*/
onMouseDown(event) {
event.preventDefault();
const array = Utils.getMousePosition(this.cesiumParentElement, event.clientX, event.clientY);
onDownPosition.fromArray( array );
}
/**
* 鼠标抬起处理
* @param event
*/
onMouseUp(event) {
const array = Utils.getMousePosition(this.cesiumParentElement, event.clientX, event.clientY );
onUpPosition.fromArray(array);
this.handleClick();
}
/**
* three 场景点击事件
*/
handleClick() {
if (onDownPosition.distanceTo(onUpPosition) === 0) {
const intersects = this.getIntersects(onUpPosition);
if(intersects.length === 0) return;
let clickObject = intersects[0].object;
Hooks.useDispatchSignal("cesium_clickThreeScene",clickObject);
}
}
/**
* 初始化cesium 场景后的处理操作
*/
handleInitCesiumAfter() {
// @ts-ignore 去除logo
this.viewer.cesiumWidget.creditContainer.style.display = 'none';
//实例化完图层再添加标记图层
App.project.getKey("sceneInfo.cesiumConfig.markMap") && this.viewer.imageryLayers.addImageryProvider(this.module.mapLayer.getMarkMapByDefaultLayer() as Cesium.UrlTemplateImageryProvider);
//为modules setViewer
this.module.mapLayer.setViewer(this.viewer);
this.module.cameraUtils.setViewer(this.viewer);
}
/**
* 添加中国地图遮罩
*/
addChinaMask() {
//使用GeoJsonDataSource加载json格式的数据
let geojsonOptions = {
clampToGround: true, //必须添加,否则添加实体对象会被覆盖
};
let dataSource = Cesium.GeoJsonDataSource.load(
'/upyun/assets/geojson/china.json',
geojsonOptions
);
dataSource.then(data => {
this.viewer.dataSources.add(data);
let entities = data.entities.values;
for (let i = 0; i < entities.length; i++) {
let entity = entities[i];
// @ts-ignore
entity.polygon.material = Cesium.Color.fromCssColorString('rgba(0,0,0,.2)');
}
});
}
/**
* 获取与鼠标点击位置射线相交的对象数组
*/
getIntersects(point) {
//声明 rayCaster 和 mouse 变量
let rayCaster = new THREE.Raycaster();
let mouse = new THREE.Vector2();
mouse.set( ( point.x * 2 ) - 1, - ( point.y * 2 ) + 1 );
//通过鼠标点击的位置(二维坐标)和当前相机的矩阵计算出射线位置
rayCaster.setFromCamera(mouse, this._three.camera);
//获取与射线相交的对象数组, 其中的元素按照距离排序,越近的越靠前。
//+true,是对其后代进行查找,这个在这里必须加,因为模型是由很多部分组成的,后代非常多。
return rayCaster.intersectObjects(this._three.scene.children, true);
}
/**
* 添加VNode至 viewer._toolbar
*/
addVNodeToViewer(vNode:VNode){
//@ts-ignore
render(vNode,this.viewer._toolbar);
}
/**
* 重置cesium场景
*/
reset(){
console.log("%c重置cesium场景", "background-color: #e0005a; color: #ffffff; font-weight: bold; padding: 4px;border-radius:3px;");
//销毁viewer
this.viewer.destroy();
// 新建viewer
this.viewer = this.getViewer();
}
/**
* 销毁
*/
destroy(){
// 移除事件监听
this.cesiumParentElement.removeEventListener('pointerdown', this.onMouseDown);
this.cesiumParentElement.removeEventListener('pointerup', this.onMouseUp);
//销毁modules
this.module.cameraUtils.destroy();
//销毁viewer
this.viewer.destroy();
}
}
@@ -0,0 +1,78 @@
import * as Cesium from 'cesium';
import {Hooks} from "@astral3d/engine";
/**
* @Date 2022-06-09
* @Author 二三
* @param {*} viewer Cesium.Viewer
* @Description: cesium相机类
*/
export default class CameraUtils {
viewer: Cesium.Viewer | null;
entity: Cesium.Entity | null;
constructor() {
this.viewer = null;
this.entity = null;
this.init();
}
init() {
Hooks.useAddSignal("cesium_flyTo", this.flyTo.bind(this))
}
setViewer(viewer) {
this.viewer = viewer;
//修改鼠标操作方式
this.changeMouseOperate()
}
/**
* 修改鼠标操作方式
*/
changeMouseOperate() {
let screenSpaceCameraController = (this.viewer as Cesium.Viewer).scene.screenSpaceCameraController;
//修改缩放操作
screenSpaceCameraController.zoomEventTypes = [Cesium.CameraEventType.WHEEL, Cesium.CameraEventType.PINCH];
//修改旋转操作
screenSpaceCameraController.tiltEventTypes = [Cesium.CameraEventType.PINCH, Cesium.CameraEventType.RIGHT_DRAG];
}
flyTo(lng, lat, distance, pitch = -15, heading = 0.0) {
const viewer = this.viewer as Cesium.Viewer;
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(lng, lat, distance),
complete: () => {
if (this.entity) viewer.entities.remove(this.entity);
this.entity = new Cesium.Entity({
id: 'flyToWJM',
position: Cesium.Cartesian3.fromDegrees(lng, lat),
//该实体关联的点
point: {
pixelSize: 1,
color: Cesium.Color.WHITE.withAlpha(0.9),
outlineColor: Cesium.Color.WHITE.withAlpha(0.9),
outlineWidth: 1
}
});
viewer.entities.add(this.entity);
viewer.flyTo(this.entity, {
offset: {
heading: Cesium.Math.toRadians(heading), //航向角(弧度)
pitch: Cesium.Math.toRadians(pitch), //俯仰角(弧度)
range: distance //距中心的距离,以米为单位
}
});
}
});
}
/**
* 销毁
*/
destroy() {
Hooks.useRemoveSignal("cesium_flyTo", this.flyTo.bind(this))
}
}
@@ -0,0 +1,82 @@
import * as Cesium from 'cesium';
import {App} from "@astral3d/engine";
/**
* @Date 2023-03-07
* @Author 二三
* @Description: cesium地图底图图层管理
*/
export default class MapLayer{
viewer: Cesium.Viewer | null;
//底图集合
layers:{
[s:string]:{
satellite?: Cesium.UrlTemplateImageryProvider | Cesium.WebMapTileServiceImageryProvider, //卫星影像图
mark?: Cesium.UrlTemplateImageryProvider , //标记图
vector?: Cesium.UrlTemplateImageryProvider //矢量图
};
}
constructor() {
this.viewer = null;
const cesiumConfig = App.project.getKey("sceneInfo.cesiumConfig");
this.layers = {
Amap:{//高德
satellite: new Cesium.UrlTemplateImageryProvider({
url: "https://webst01.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}",
minimumLevel: 3,
maximumLevel: 18
}),
mark: new Cesium.UrlTemplateImageryProvider({
url: "http://webst02.is.autonavi.com/appmaptile?x={x}&y={y}&z={z}&lang=zh_cn&size=1&scale=1&style=8",
minimumLevel: 3,
maximumLevel: 18
}),
vector: new Cesium.UrlTemplateImageryProvider({
url: "https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}",
minimumLevel: 3,
maximumLevel: 18
})
},
Tianditu:{//天地图
satellite: new Cesium.WebMapTileServiceImageryProvider({//球面墨卡托投影
url:`http://t{s}.tianditu.com/img_w/wmts?service=wmts&request=GetTile&version=1.0.0&LAYER=img&tileMatrixSet=w&TileMatrix={TileMatrix}&TileRow={TileRow}&TileCol={TileCol}&style=default&format=tiles&tk=${cesiumConfig.tiandituTk}`,
subdomains: ['0', '1', '2', '3', '4', '5', '6', '7'],
layer: 'tdtImgLayer',
style: 'default',
format: 'image/jpeg',
tileMatrixSetID: 'GoogleMapsCompatible', //使用谷歌的瓦片切片方式
}),
mark: new Cesium.UrlTemplateImageryProvider({
url: `http://t0.tianditu.gov.cn/cva_w/wmts?tk=${cesiumConfig.tiandituTk}`,
minimumLevel: 3,
maximumLevel: 18
}),
vector: new Cesium.UrlTemplateImageryProvider({
url: `http://t0.tianditu.gov.cn/vec_w/wmts?tk=${cesiumConfig.tiandituTk}`,
minimumLevel: 3,
maximumLevel: 18
})
}
}
}
setViewer(viewer){this.viewer = viewer;}
/**
* 获取默认底图
* @param layer 底图类型,默认卫星影像图 enum: satellite | vector
*/
getDefaultLayer(layer:'satellite' | 'vector' = "satellite"){
return this.layers[App.project.getKey("sceneInfo.cesiumConfig.map")][layer];
}
/**
* 获取默认底图对应得标记图
*/
getMarkMapByDefaultLayer(){
return this.layers[App.project.getKey("sceneInfo.cesiumConfig.map")].mark || this.layers.Amap.mark;
}
}
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
import * as Cesium from "cesium";
/**
* 笛卡尔坐标转换经纬度坐标
* @param {*} car3_ps
* @returns
*/
export function getLngLatByCartesian3(car3_ps) {
if (!(car3_ps instanceof Cesium.Cartesian3))
throw new Error("参数非 Cesium.Cartesian3 类型")
let _cartographic = Cesium.Cartographic.fromCartesian(car3_ps);
let _lat = Cesium.Math.toDegrees(_cartographic.latitude);
let _lng = Cesium.Math.toDegrees(_cartographic.longitude);
let _alt = _cartographic.height;
return {longitude: _lng, latitude: _lat, elevation: _alt};
}
+439
View File
@@ -0,0 +1,439 @@
import {h} from "vue";
import * as THREE from "three";
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
import {App,Hooks,Utils,SetPositionCommand,SetRotationCommand,SetScaleCommand} from "@astral3d/engine";
import {NIcon} from "naive-ui";
import { GolfOutline } from '@vicons/ionicons5';
import * as Cesium from 'cesium';
import CesiumApp from "@/cesium/cesiumApp";
/**
* @Date 2023-02-06
* @Author 二三
* @Description: cesium视图出口
*/
export default class ViewPort {
//核心类
app: CesiumApp;
//canvas存放的父级dom
cesiumParentElement: HTMLElement;
//three对象
public _three: {
camera: THREE.PerspectiveCamera,
scene: THREE.Scene,
sceneHelpers: THREE.Scene,
showSceneHelpers:boolean,
renderer: THREE.WebGLRenderer,
transformControls:TransformControls | null,
box: THREE.Box3,//包围盒
};
//选中的包围盒
threeSelectionBox: THREE.Box3Helper;
// threejs场景定位范围
minWGS84: Array<number>;
maxWGS84: Array<number>;
animationFrameID: number | null;
constructor(dom) {
this.app = new CesiumApp(dom);
this.cesiumParentElement = this.app.cesiumParentElement;
window.CesiumApp = this.app;
this.minWGS84 = [100.75483412680308, 22.026856925803223];
this.maxWGS84 = [100.86206068991605, 21.979558335999187];
this._three = {
...this.app._three,
transformControls:null,
box: new THREE.Box3()
}
this.threeSelectionBox = new THREE.Box3Helper(this._three.box);
(this.threeSelectionBox.material as THREE.Material).depthTest = false;
(this.threeSelectionBox.material as THREE.Material).transparent = true;
this.threeSelectionBox.visible = false;
this._three.sceneHelpers.add(this.threeSelectionBox);
this.animationFrameID = null;
//处理three canvas 的 transformControls,以便于场景融合;
this.cesiumParentElement.addEventListener("mousemove",this.handleThreeMouseMove.bind(this))
this.init();
}
init() {
// 将三维球定位到中国
this.app.viewer?.camera.setView({
// Cesium的坐标是以地心为原点,一向指向南美洲,一向指向亚洲,一向指向北极州
// fromDegrees()方法,将经纬度和高程转换为世界坐标
destination: Cesium.Cartesian3.fromDegrees(103.84, 31.15, 24000000),
orientation: {
// 指向
heading: Cesium.Math.toRadians(348.4202942851978),
// 视角
pitch: Cesium.Math.toRadians(-90),
roll: Cesium.Math.toRadians(0),
},
});
this.signalsRegister(true);
this.handleCesiumEvent();
this.app.addChinaMask();
this.addButtonToViewer();
this.initThreeTransformControls();
this.calcCenter();
this.fusionCanvas();
this.loop();
}
/**
* 相关signals注册
*/
signalsRegister(isAdd:boolean = true) {
let _this = this;
//停止requestAnimationFrame
function stopLoop(){
cancelAnimationFrame((_this.animationFrameID as number));
}
//显示three辅助
function handleShowHelpers(showHelpers:boolean){
_this._three.showSceneHelpers = showHelpers;
(_this._three.transformControls as TransformControls).enabled = showHelpers;
_this.renderThree();
}
const signals = {
"cesium_stopLoop":stopLoop,
"showHelpersChanged":handleShowHelpers,
"cesium_destroy":this.destroy.bind(this)
}
Object.keys(signals).forEach(name => {
isAdd ? Hooks.useAddSignal(name,signals[name]) : Hooks.useRemoveSignal(name,signals[name]);
})
}
/**
*
*/
addButtonToViewer(){
let _this = this;
const vNode = h("button",{
type:"button",
class:"cesium-button cesium-toolbar-button",
title:"飞行至ThreeJS场景",
onClick(){
_this.flyToThree();
}
},h(NIcon,{size:22},h(GolfOutline)))
this.app.addVNodeToViewer(vNode)
}
/**
* cesium 开始加载后的相关操作
*/
handleCesiumEvent() {
/* 监听cesium 加载完成事件 */
const removeOnloadCallback = this.app.helper.add(this.app.viewer.scene.globe.tileLoadProgressEvent, event => {
if (event == 0) {
console.log('-------------cesium 加载完成---------------');
this.flyToThree();
removeOnloadCallback();
}
});
// 绑定屏幕空间事件(单击)
// let handler = new Cesium.ScreenSpaceEventHandler(this.app.viewer.scene.canvas);
// handler.setInputAction(function (event) {
// console.log('左键单击', event);
// }, Cesium.ScreenSpaceEventType.LEFT_CLICK);
//监听Cesium相机高度变化
this.app.viewer.camera.changed.addEventListener(() => {
// 变化后高度
// let height= viewer.camera.positionCartographic.height;
});
//移除左键双击事件
this.app.viewer.cesiumWidget.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
}
/**
* 初始化three变换控制器
*/
initThreeTransformControls() {
let objectPositionOnDown: THREE.Vector3 | null = null;
let objectRotationOnDown: THREE.Euler | null = null;
let objectScaleOnDown: THREE.Vector3 | null = null;
this._three.transformControls = new TransformControls(this._three.camera, this._three.renderer.domElement);
let transformControls = this._three.transformControls;
transformControls.addEventListener('change', () => {
transformControls.updateMatrixWorld();
const object = transformControls.object;
if (object !== undefined) {
this._three.box.setFromObject(object, true);
const helper = App.helpers[object.id];
if (helper !== undefined && !helper.isSkeletonHelper) {
helper.update();
}
Hooks.useDispatchSignal("objectChanged", object);
// 绑定物体不能去到地下
if(object.position.y < 0){
object.position.setY(0);
}
}
this.renderThree();
});
transformControls.addEventListener('mouseDown', ()=>{
const object = transformControls.object as THREE.Object3D;
objectPositionOnDown = object.position.clone();
objectRotationOnDown = object.rotation.clone();
objectScaleOnDown = object.scale.clone();
// TODO 此时应该停止其他控制器和cesium渲染
// controls.enabled = false;
Hooks.useDispatchSignal("cesium_stopLoop");
});
//记录行为
transformControls.addEventListener('mouseUp', ()=>{
const object = transformControls.object;
if (object !== undefined) {
switch (transformControls.getMode()) {
case 'translate':
if (!objectPositionOnDown?.equals(object.position)) {
App.execute(new SetPositionCommand(object, object.position, objectPositionOnDown));
}
break;
case 'rotate':
if (!objectRotationOnDown?.equals(object.rotation)) {
App.execute(new SetRotationCommand(object, object.rotation, objectRotationOnDown));
}
break;
case 'scale':
if (!objectScaleOnDown?.equals(object.scale)) {
App.execute(new SetScaleCommand(object, object.scale, objectScaleOnDown));
}
break;
}
}
// TODO 此时应该重新启用其他控制器和cesium渲染
//controls.enabled = true;
this.loop()
});
transformControls.traverse(child => {
child.userData.isTransformControls = true;
})
this._three.sceneHelpers.add(transformControls);
//相关方法
const attachObject = (object) => {
// this.threeSelectionBox.visible = false;
transformControls.detach();
if (object !== null && object !== this._three.scene && object !== this._three.camera) {
// this._three.box.setFromObject(object, true);
// if (!this._three.box.isEmpty()) {
// this.threeSelectionBox.visible = true;
// }
// console.log("attach",object)
transformControls.attach(object);
}
this.renderThree();
}
/* transformControls相关的监听 */
//监听融合场景下的three scene下的点击
Hooks.useAddSignal("cesium_clickThreeScene", (object) => {
attachObject(object)
})
Hooks.useAddSignal("transformModeChanged", (mode) => {
transformControls.setMode(mode);
});
Hooks.useAddSignal("objectSelected", (object) => {
attachObject(object)
});
Hooks.useAddSignal("objectRemoved", (object) => {
if (object === transformControls.object) {
transformControls.detach();
}
});
}
/**
* 计算场景中心位置等信息
*/
calcCenter(){
let cartToVec = function (cart) {
return new THREE.Vector3(cart.x, cart.y, cart.z);
};
//将Three.js网格配置为与地球中心位置垂直向上
let minWGS84 = this.minWGS84;
let maxWGS84 = this.maxWGS84;
// 转换纬度/长中心位置为笛卡尔3
let center = cartToVec(Cesium.Cartesian3.fromDegrees((minWGS84[0] + maxWGS84[0]) / 2, (minWGS84[1] + maxWGS84[1]) / 2));
//得到面向模型的前向方向
let centerHigh = Cesium.Cartesian3.fromDegrees((minWGS84[0] + maxWGS84[0]) / 2, (minWGS84[1] + maxWGS84[1]) / 2, 1);
//使用从左下到左上的方向作为上向量
let bottomLeft = cartToVec(Cesium.Cartesian3.fromDegrees(minWGS84[0], minWGS84[1]));
let topLeft = cartToVec(Cesium.Cartesian3.fromDegrees(minWGS84[0], maxWGS84[1]));
let latDir = new THREE.Vector3().subVectors(bottomLeft, topLeft).normalize();
// 配置实体的位置和方向
this._three.scene.position.copy(center);
this._three.scene.lookAt(centerHigh.x, centerHigh.y, centerHigh.z);
this._three.scene.up.copy(latDir);
this._three.scene.rotateX(Math.PI / 2);
//sceneHelpers 也需要相同操作
// this._three.sceneHelpers.position.copy(center);
// this._three.sceneHelpers.lookAt(centerHigh.x, centerHigh.y, centerHigh.z);
// this._three.sceneHelpers.up.copy(latDir);
// this._three.sceneHelpers.rotateX(Math.PI / 2);
}
/**
* 处理three canvas 的 transformControls,以便于场景融合;
*/
handleThreeMouseMove(event){
//如果未绑定变换物体则不执行任何逻辑
if(!this._three.transformControls?.object) return;
const threeCanvas = this._three.renderer.domElement;
const onDownPosition = new THREE.Vector2();
let rayCaster = new THREE.Raycaster();
let mouse = new THREE.Vector2();
const array = Utils.getMousePosition(threeCanvas, event.clientX, event.clientY);
onDownPosition.fromArray(array);
mouse.set( ( onDownPosition.x * 2 ) - 1, - ( onDownPosition.y * 2 ) + 1 );
//通过鼠标点击的位置(二维坐标)和当前相机的矩阵计算出射线位置
rayCaster.setFromCamera(mouse, this._three.camera);
const intersects = rayCaster.intersectObjects(this._three.sceneHelpers.children, true);
if(intersects.length === 0) {
threeCanvas.style.pointerEvents = 'none';
return;
}
let _object = intersects[0].object;
if(!_object.userData.isTransformControls){
threeCanvas.style.pointerEvents = 'none';
return;
}
if(_object.type !== "TransformControlsPlane" && _object.type !== "Line" && _object.children.length === 0){
threeCanvas.style.pointerEvents = 'auto';
}else{
threeCanvas.style.pointerEvents = 'none';
}
}
/**
* 飞行至three场景
*/
flyToThree(){
Hooks.useDispatchSignal("cesium_flyTo", (this.minWGS84[0] + this.maxWGS84[0]) / 2,(this.minWGS84[1] + this.maxWGS84[1]) / 2,3000,-40)
}
/* ----------------------------------- 渲染相关 ----------------------------------------------*/
/**
* cesium 渲染
*/
renderCesium() {
this.app.viewer.render();
}
/**
* 两个画布相机融合
*/
fusionCanvas() {
// @ts-ignore 用Cesium注册Three.js场景
this._three.camera.fov = Cesium.Math.toDegrees(this.app.viewer.camera.frustum.fovy) //ThreeJS FOV是垂直的
//克隆Cesium Camera的投影位置
// Three.js对象将出现在相同的位置,因为Cesium Globe上面
this._three.camera.matrixAutoUpdate = false;
// 注意这里,three高版本这行代码需要放在 three.camera.matrixWorld 之前
this._three.camera.lookAt(0, 0, 0);
let w = this.cesiumParentElement.offsetWidth;
let h = this.cesiumParentElement.offsetHeight;
this._three.camera.aspect = w / h;
this._three.camera.updateProjectionMatrix();
this._three.renderer.setSize(w,h);
this._three.renderer.clear();
this._three.scene.updateMatrixWorld();
}
/**
* three 渲染器的渲染
*/
renderThree(){
let cvm = this.app.viewer.camera.viewMatrix;
let civm = this.app.viewer.camera.inverseViewMatrix;
this._three.camera.matrixWorld.set(
civm[0], civm[4], civm[8], civm[12],
civm[1], civm[5], civm[9], civm[13],
civm[2], civm[6], civm[10], civm[14],
civm[3], civm[7], civm[11], civm[15]
);
this._three.camera.matrixWorldInverse.set(
cvm[0], cvm[4], cvm[8], cvm[12],
cvm[1], cvm[5], cvm[9], cvm[13],
cvm[2], cvm[6], cvm[10], cvm[14],
cvm[3], cvm[7], cvm[11], cvm[15]
);
this._three.renderer.render(this._three.scene, this._three.camera);
if (this._three.camera === App.viewportCamera) {
this._three.renderer.autoClear = false;
if (this._three.showSceneHelpers) {
// this._three.sceneHelpers.updateMatrixWorld();
this._three.renderer.render(this._three.sceneHelpers, this._three.camera);
}
this._three.renderer.autoClear = true;
}
}
// 同步渲染
loop() {
this.animationFrameID = requestAnimationFrame(this.loop.bind(this));
this.renderCesium();
this.renderThree();
}
//销毁
destroy(){
//停止渲染
cancelAnimationFrame((this.animationFrameID as number));
//清除window的挂载
window.CesiumApp = undefined;
//销毁变换控制器
this._three.transformControls?.dispose();
// 移除signal
this.signalsRegister(false);
// 销毁app内容
this.app.destroy();
}
}
@@ -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>
+12
View File
@@ -0,0 +1,12 @@
import {t} from '@/language';
// 新建cesium场景时配置的默认底图
export const CESIUM_DEFAULT_MAP = [
{label:t("cesium.map.Amap"),value:"Amap",coordinateSystem:"GCJ-02"}, //高德
]
// 新建cesium场景时配置的默认底图类型 satellite:影像图,vector:矢量图
export const CESIUM_DEFAULT_MAP_TYPE = [
{label:t("cesium['Image base map']"),value:"satellite"},
{label:t("cesium['Vector base map']"),value:"vector"}
]
+43
View File
@@ -0,0 +1,43 @@
/** 错误信息的显示时间 */
export const ERROR_MSG_DURATION = 3 * 1000;
/** 默认的请求错误code */
export const DEFAULT_REQUEST_ERROR_CODE = 'DEFAULT';
/** 默认的请求错误文本 */
export const DEFAULT_REQUEST_ERROR_MSG = '请求错误~';
/** 请求超时的错误code(为固定值:ECONNABORTED) */
export const REQUEST_TIMEOUT_CODE = 'ECONNABORTED';
/** 请求超时的错误文本 */
export const REQUEST_TIMEOUT_MSG = '请求超时~';
/** 网络不可用的code */
export const NETWORK_ERROR_CODE = 'NETWORK_ERROR';
/** 网络不可用的错误文本 */
export const NETWORK_ERROR_MSG = '网络不可用~';
/** 请求不成功各种状态的错误 */
export const ERROR_STATUS = {
400: '400: 请求出现语法错误~',
401: '401: 用户未授权~',
403: '403: 服务器拒绝访问~',
404: '404: 请求的资源不存在~',
405: '405: 请求方法未允许~',
408: '408: 网络请求超时~',
500: '500: 服务器内部错误~',
501: '501: 服务器未实现请求功能~',
502: '502: 错误网关~',
503: '503: 服务不可用~',
504: '504: 网关超时~',
505: '505: http版本不支持该请求~',
[DEFAULT_REQUEST_ERROR_CODE]: DEFAULT_REQUEST_ERROR_MSG
};
/** 不弹出错误信息的code */
export const NO_ERROR_MSG_CODE: (string | number)[] = [];
/** token失效需要刷新token的code */
export const REFRESH_TOKEN_CODE: (string | number)[] = [978, 998, 999];
+59
View File
@@ -0,0 +1,59 @@
import {Preview,Utils} from "@astral3d/engine";
export class AssetPreview {
static #instance:AssetPreview; // 静态私有属性存储唯一实例
previewer: Preview;
private container: HTMLElement;
static getInstance(container?:HTMLElement) {
if (!AssetPreview.#instance) {
AssetPreview.#instance = new AssetPreview(container);
}else if(container && container !== AssetPreview.#instance.container){
AssetPreview.#instance.updateContainer(container);
}
return AssetPreview.#instance;
}
constructor(container?:HTMLElement) {
if (AssetPreview.#instance) {
throw new Error("Use AssetPreview.getInstance() instead");
}
this.container = container || Utils.createDivContainer();
this.previewer = new Preview({
container: this.container,
hdr: "/static/resource/hdr/cloudy.hdr",
request: {
baseUrl:"/file/static/"
}
});
}
updateContainer(container:HTMLElement){
if(container === this.previewer.container) return;
this.container = container;
this.previewer.container = container;
}
/**
* 加载预览项
*/
load(fileOrUrl:string | File,type:string = "Model"){
return this.previewer.load(fileOrUrl,type);
}
clear(){
this.previewer.clear();
}
dispose(){
this.previewer.dispose();
//@ts-ignore
this.previewer = null;
//@ts-ignore
AssetPreview.#instance = null;
}
}
+4
View File
@@ -0,0 +1,4 @@
import useBoolean from './useBoolean';
import useLoading from './useLoading';
export { useBoolean, useLoading };
+30
View File
@@ -0,0 +1,30 @@
import { ref } from 'vue';
/**
* boolean组合式函数
* @param initValue 初始值
*/
export default function useBoolean(initValue = false) {
const bool = ref(initValue);
function setBool(value: boolean) {
bool.value = value;
}
function setTrue() {
setBool(true);
}
function setFalse() {
setBool(false);
}
function toggle() {
setBool(!bool.value);
}
return {
bool,
setBool,
setTrue,
setFalse,
toggle
};
}
+11
View File
@@ -0,0 +1,11 @@
import useBoolean from './useBoolean';
export default function useLoading(initValue = false) {
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(initValue);
return {
loading,
startLoading,
endLoading
};
}
+125
View File
@@ -0,0 +1,125 @@
/**
* @author MaHaiBing
* @email mlt131220@163.com
* @date 2023/11/29 10:10
* @description websocket hook;使用store管理websocket保证全局唯一性
*/
import {unref} from 'vue';
import { useWebSocket as $useWebSocket } from '@vueuse/core'
import {App} from '@astral3d/engine';
import {useWebsocketStoreWithOut} from "@/store/modules/websocket";
import {MathUtils} from 'three';
const websocketStore = useWebsocketStoreWithOut();
const listeners = new Map();
/**
* 开启 WebSocket 链接,全局只需执行一次
* @param url
*/
export function connectWebSocket(url) {
if(url.substring(0,3).indexOf('ws') === -1){
if(url.indexOf('http') !== -1){
url = url.replace("http", "ws")
}else{
url = location.origin.replace("http", "ws") + url;
}
}
if (!websocketStore.getIsOpen()) {
// 设置订阅者标识
let wsUname;
if(!websocketStore.uname){
wsUname = MathUtils.generateUUID();
websocketStore.setUname(wsUname);
}else{
wsUname = websocketStore.uname;
}
const ws = $useWebSocket(`${url}?uname=${wsUname}`, {
// 在错误时自动重连
autoReconnect:{
// 最大重试次数。
retries: 3,
// 重新连接的延迟,以毫秒为单位
delay:2000,
// 已达到最大重试次数。
onFailed(){
App.log.error("[WebSocket] 连接重试次数超限,连接失败!");
}
},
// 定时发送 ping 消息
heartbeat: {
// @ts-ignore
message: new Uint8Array([0x9]),
interval: 10 * 1000,
pongTimeout: 10 * 1000,
},
onConnected: () => {
App.log.info('[WebSocket] 连接成功');
},
onDisconnected: () => {
App.log.info('[WebSocket] 连接关闭');
},
onMessage: (_,event) => {
onMessage(event);
},
onError: () => {
App.log.error('[WebSocket] 连接错误');
}
});
websocketStore.setWebsocket(ws);
}
}
function onMessage(e: MessageEvent) {
if (e.data === 'ping' || e.data === 'heartbeat' || e.data === 'pong' || e.data instanceof Blob) {
return;
}
App.log.debug(`[WebSocket] 收到消息: ${e.data}`);
try {
const data = JSON.parse(e.data);
for (const callback of listeners.keys()) {
try {
callback(data);
} catch (err) {
console.error(err);
}
}
} catch (err) {
App.log.error(`[WebSocket] data解析失败: ${err}`);
}
}
/**
* 添加 WebSocket 消息监听
* @param callback
*/
export function onWebSocket(callback: (data: object) => any) {
if (!listeners.has(callback)) {
if (typeof callback === 'function') {
listeners.set(callback, null);
} else {
console.debug('[WebSocket] 添加 WebSocket 消息监听失败:传入的参数不是一个方法');
}
}
}
/**
* 解除 WebSocket 消息监听
*
* @param callback
*/
export function offWebSocket(callback: (data: object) => any) {
listeners.delete(callback);
}
export function useWebSocket() {
return unref(websocketStore.ws);
}
export function send(message: string | ArrayBuffer | Blob) {
websocketStore.send(message);
}
@@ -0,0 +1,8 @@
import {request} from "@/http/request";
/**
* 获取资产分类树
*/
export function fetchAssetsCategoryTreeList(params = {type: ''}){
return request.get<IAssets.Category[]>(`/assets/assetsCategory/treeList`,{params});
}
@@ -0,0 +1,42 @@
import {request} from "@/http/request";
/**
* 获取资产列表
*/
export function fetchGetAssetsList(params:Service.ListPageQueryParams){
return request.get<Service.ListPageResult<IAssets.Item>>(`/assets/assetsInfo/getAll`,{params});
}
/**
* 新增资产
*/
export function fetchAddAsset(data:IAssets.Item){
return request.post<IAssets.Item>(`/assets/assetsInfo`,data);
}
/**
* 更新资产
*/
export function fetchUpdateAsset(data:IAssets.Item){
return request.put<IAssets.Item>(`/assets/assetsInfo`,data);
}
/**
* 移除资产
*/
export function fetchRemoveAsset(id:IAssets.Item['id']){
return request.delete(`/assets/assetsInfo/${id}`,{});
}
/**
* 获取分类下的资产tags
*/
export function fetchGetAssetCategoryTags(type:IAssets.SupportType, category: string){
return request.get<string[]>(`/assets/assetsInfo/selectTags`, {
params:{
type,
category
}
});
}
+25
View File
@@ -0,0 +1,25 @@
import {request} from "@/http/request";
import {useWebsocketStore} from "@/store/modules/websocket";
const websocketStore = useWebsocketStore();
/**
* 获取bim转换列表
*/
export function fetchGetBim2GltfList(params) {
return request.get<Service.ListPageResult<IBIMData>>('/editor3d/bim2gltf/getAll',{params});
}
/**
* 上传bim文件
*/
export function fetchUploadRvt(data) {
return request.post('/editor3d//bim2gltf/uploadRvt',data,{headers:{"Content-Type":"multipart/form-data"}});
}
/**
* 添加数据并启动revit转换( 需传入接收结果的websocket uname
*/
export function fetchAddBim2Gltf(data) {
return request.post('/editor3d/bim2gltf/addAndConversion',data,{params:{uname:websocketStore.uname}});
}
+18
View File
@@ -0,0 +1,18 @@
import {request} from "@/http/request";
import {useWebsocketStore} from "@/store/modules/websocket";
const websocketStore = useWebsocketStore();
/**
* 获取cad列表
*/
export function fetchGetCadList(params) {
return request.get<Service.ListPageResult<ICad.Data>>('/editor3d/cad/getAll',{params});
}
/**
* 添加数据并启动cad解析( 需传入接收结果的websocket uname
*/
export function fetchAddDwg2dxf(data) {
return request.post<ICad.Data>('/editor3d/cad/dwg2dxf',data,{params:{uname:websocketStore.uname},headers:{"Content-Type":"multipart/form-data"}});
}
@@ -0,0 +1,37 @@
/**
* @author MaHaiBing
* @email mlt131220@163.com
* @date 2024/7/28 14:54
* @description 示例场景
*/
import {request} from "@/http/request";
import {Service} from "~/network";
/**
* 获取所有示例场景
*/
export function fetchSceneExampleList(params) {
return request.get<Service.ListPageResult<ISceneFetchData>>("/editor3d/sceneExample",{params});
}
/**
* 获取示例场景
*/
export function fetchSceneExample(id) {
return request.get(`/editor3d/sceneExample/${id}`);
}
/**
* 新增示例场景
*/
export function fetchAddSceneExample(data) {
return request.post(`/editor3d/sceneExample`,data);
}
/**
* 删除示例场景
* @param {number} id
*/
export function fetchDeleteSceneExample(id: number) {
return request.delete(`/editor3d/sceneExample/${id}`,{});
}
+37
View File
@@ -0,0 +1,37 @@
import {request} from "@/http/request";
/**
* 获取所有工程信息
*/
export function fetchGetAllScenes(params:Service.ListPageQueryParams) {
return request.get<Service.ListPageResult<ISceneFetchData>>("/editor3d/scenes/getAll",{params});
}
/**
* 获取工程
*/
export function fetchGetOneScene(id:string) {
return request.get<ISceneFetchData>(`/editor3d/scenes/get/${id}`);
}
/**
* 保存工程
*/
export function fetchAddScene(data) {
return request.post<ISceneFetchData>(`/editor3d/scenes/add`,data);
}
/**
* 更新工程
*/
export function fetchUpdateScene(id:string,data:ISceneFetchData) {
return request.put<ISceneFetchData>(`/editor3d/scenes/update/${id}`,data);
}
/**
* 删除工程
* @param {number} id
*/
export function fetchDeleteScenes(id: string) {
return request.delete(`/editor3d/scenes/del/${id}`,{});
}
+8
View File
@@ -0,0 +1,8 @@
import {request} from "@/http/request";
/**
* 上传
*/
export function fetchUpload(data:{file:File,biz:string,type?:string}) {
return request.post<string>(`/common/upload`,data,{headers:{"Content-Type":"multipart/form-data"}});
}
@@ -0,0 +1,60 @@
const ConcurrencyManager = (axios, MAX_CONCURRENT = 10) => {
if (MAX_CONCURRENT < 1)
throw "并发管理器错误: 最小并发请求数为 1";
let instance = {
queue: <any>[],
running: <any>[],
shiftInitial: () => {
setTimeout(() => {
if (instance.running.length < MAX_CONCURRENT) {
instance.shift();
}
}, 0);
},
push: reqHandler => {
instance.queue.push(reqHandler);
instance.shiftInitial();
},
shift: () => {
if (instance.queue.length) {
const queued = instance.queue.shift();
queued.resolver(queued.request);
instance.running.push(queued);
}
},
// 用作拦截器。将传出请求排队
requestHandler: req => {
return new Promise(resolve => {
instance.push({ request: req, resolver: resolve });
});
},
// 用作拦截器。在收到响应时执行排队的请求
responseHandler: res => {
instance.running.shift();
instance.shift();
return res;
},
responseErrorHandler: res => {
return Promise.reject(instance.responseHandler(res));
},
interceptors: {
request: null,
response: null
},
detach: () => {
axios.interceptors.request.eject(instance.interceptors.request);
axios.interceptors.response.eject(instance.interceptors.response);
}
};
// 对并发请求进行排队
instance.interceptors.request = axios.interceptors.request.use(
instance.requestHandler
);
instance.interceptors.response = axios.interceptors.response.use(
instance.responseHandler,
instance.responseErrorHandler,
);
return instance;
};
export default ConcurrencyManager;
@@ -0,0 +1,3 @@
import {createRequest} from "./request";
export const request = createRequest({ baseURL:"/api" });
@@ -0,0 +1,90 @@
import axios from 'axios';
import type {AxiosError, AxiosInstance, AxiosRequestConfig} from 'axios';
import {
handleAxiosError,
handleBackendError,
handleResponseError,
handleServiceResult,
} from '@/utils/service';
import {Service} from "../../../types/network";
import ConcurrencyManager from "./ConcurrencyManager";
/**
* 封装axios请求类
* @author Soybean<honghuangdc@gmail.com>
*/
const MAX_CONCURRENT_REQUESTS = 6;
export default class CustomAxiosInstance {
instance: AxiosInstance;
backendConfig: Service.BackendResultConfig;
manager: any;
/**
*
* @param axiosConfig - axios配置
* @param backendConfig - 后端返回的数据配置
*/
constructor(
axiosConfig: AxiosRequestConfig,
backendConfig: Service.BackendResultConfig = {
codeKey: 'code',
dataKey: 'result',
msgKey: 'message',
successCode: 200
}
) {
this.backendConfig = backendConfig;
this.instance = axios.create(axiosConfig);
this.manager = ConcurrencyManager(this.instance, MAX_CONCURRENT_REQUESTS);
this.setInterceptor();
}
/** 设置请求拦截器 */
setInterceptor() {
this.instance.interceptors.request.use(
async config => {
const handleConfig = {...config};
if (handleConfig.headers) {
}
return handleConfig;
},
(axiosError: AxiosError) => {
const error = handleAxiosError(axiosError);
return handleServiceResult(error, null);
}
);
this.instance.interceptors.response.use(
async response => {
const {status} = response;
if (status === 200 || status < 300 || status === 304) {
const backend = response.data;
// 判断返回文件流的情况
if (response.headers['content-type'].includes('application/octet-stream')) {
return handleServiceResult(null, backend);
}
const {codeKey, dataKey, successCode} = this.backendConfig;
// 请求成功
if (backend[codeKey] === successCode) {
const keyArr = Object.values(this.backendConfig);
const other = {};
Object.keys(backend).forEach(item => {
if (!keyArr.includes(item)) {
other[item] = backend[item];
}
});
return handleServiceResult(null, backend[dataKey], other);
}
const error = handleBackendError(backend, this.backendConfig);
return handleServiceResult(error, null);
}
const error = handleResponseError(response);
return handleServiceResult(error, null);
},
(axiosError: AxiosError) => {
const error = handleAxiosError(axiosError);
return handleServiceResult(error, null);
}
);
}
}
+209
View File
@@ -0,0 +1,209 @@
import { ref } from 'vue';
import type { Ref } from 'vue';
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import { useBoolean, useLoading } from '@/hooks/index';
import CustomAxiosInstance from './instance';
import {Service} from "../../../types/network";
type RequestMethod = 'get' | 'post' | 'put' | 'delete';
interface RequestParam {
url: string;
method?: RequestMethod;
data?: any;
axiosConfig?: AxiosRequestConfig;
}
/**
* 创建请求
* @param axiosConfig - axios配置
* @param backendConfig - 后端接口字段配置
*/
export function createRequest(axiosConfig: AxiosRequestConfig, backendConfig?: Service.BackendResultConfig) {
const customInstance = new CustomAxiosInstance(axiosConfig, backendConfig);
/**
* 异步promise请求
* @param param - 请求参数
* - url: 请求地址
* - method: 请求方法(默认get)
* - data: 请求的body的data
* - axiosConfig: axios配置
*/
async function asyncRequest<T>(param: RequestParam): Promise<Service.RequestResult<T>> {
const { url } = param;
const method = param.method || 'get';
const { instance } = customInstance;
const res = (await getRequestResponse({
instance,
method,
url,
data: param.data,
config: param.axiosConfig
})) as Service.RequestResult<T>;
return res;
}
/**
* get请求
* @param url - 请求地址
* @param config - axios配置
*/
function get<T>(url: string, config?: AxiosRequestConfig) {
return asyncRequest<T>({ url, method: 'get', axiosConfig: config });
}
/**
* post请求
* @param url - 请求地址
* @param data - 请求的body的data
* @param config - axios配置
*/
function post<T>(url: string, data?: any, config?: AxiosRequestConfig) {
return asyncRequest<T>({ url, method: 'post', data, axiosConfig: config });
}
/**
* put请求
* @param url - 请求地址
* @param data - 请求的body的data
* @param config - axios配置
*/
function put<T>(url: string, data?: any, config?: AxiosRequestConfig) {
return asyncRequest<T>({ url, method: 'put', data, axiosConfig: config });
}
/**
* delete请求
* @param url - 请求地址
* @param config - axios配置
*/
function handleDelete<T>(url: string, config: AxiosRequestConfig) {
return asyncRequest<T>({ url, method: 'delete', axiosConfig: config });
}
return {
get,
post,
put,
delete: handleDelete
};
}
interface RequestResultHook<T = any> {
data: Ref<T | null>;
error: Ref<Service.RequestError | null>;
loading: Ref<boolean>;
network: Ref<boolean>;
}
/**
* 创建hooks请求
* @param axiosConfig - axios配置
* @param backendConfig - 后端接口字段配置
*/
export function createHookRequest(axiosConfig: AxiosRequestConfig, backendConfig?: Service.BackendResultConfig) {
const customInstance = new CustomAxiosInstance(axiosConfig, backendConfig);
/**
* hooks请求
* @param param - 请求参数
* - url: 请求地址
* - method: 请求方法(默认get)
* - data: 请求的body的data
* - axiosConfig: axios配置
*/
function useRequest<T>(param: RequestParam): RequestResultHook<T> {
const { loading, startLoading, endLoading } = useLoading();
const { bool: network, setBool: setNetwork } = useBoolean(window.navigator.onLine);
startLoading();
const data = ref<T | null>(null) as Ref<T | null>;
const error = ref<Service.RequestError | null>(null);
function handleRequestResult(response: any) {
const res = response as Service.RequestResult<T>;
data.value = res.data;
error.value = res.error;
endLoading();
setNetwork(window.navigator.onLine);
}
const { url } = param;
const method = param.method || 'get';
const { instance } = customInstance;
getRequestResponse({ instance, method, url, data: param.data, config: param.axiosConfig }).then(
handleRequestResult
);
return {
data,
error,
loading,
network
};
}
/**
* get请求
* @param url - 请求地址
* @param config - axios配置
*/
function get<T>(url: string, config?: AxiosRequestConfig) {
return useRequest<T>({ url, method: 'get', axiosConfig: config });
}
/**
* post请求
* @param url - 请求地址
* @param data - 请求的body的data
* @param config - axios配置
*/
function post<T>(url: string, data?: any, config?: AxiosRequestConfig) {
return useRequest<T>({ url, method: 'post', data, axiosConfig: config });
}
/**
* put请求
* @param url - 请求地址
* @param data - 请求的body的data
* @param config - axios配置
*/
function put<T>(url: string, data?: any, config?: AxiosRequestConfig) {
return useRequest<T>({ url, method: 'put', data, axiosConfig: config });
}
/**
* delete请求
* @param url - 请求地址
* @param config - axios配置
*/
function handleDelete<T>(url: string, config: AxiosRequestConfig) {
return useRequest<T>({ url, method: 'delete', axiosConfig: config });
}
return {
get,
post,
put,
delete: handleDelete
};
}
async function getRequestResponse(params: {
instance: AxiosInstance;
method: RequestMethod;
url: string;
data?: any;
config?: AxiosRequestConfig;
}) {
const { instance, method, url, data, config } = params;
let res: any;
if (method === 'get' || method === 'delete') {
res = await instance[method](url, config);
} else {
res = await instance[method](url, data, config);
}
return res;
}
+117
View File
@@ -0,0 +1,117 @@
import type { App } from 'vue';
import { computed,ComputedRef } from 'vue';
import { createI18n } from 'vue-i18n';
import {useGlobalConfigStoreWithOut} from "@/store/modules/globalConfig";
const globalConfigStore = useGlobalConfigStoreWithOut();
//引入同级目录下所有语言文件
const modules = (import.meta as any).glob('./*.ts', {eager: true});
type Message = {
[s: string]: {} | undefined
}
type ReturnMessage = {
'zh-CN': {},
'en-US': {},
[s: string]: {}
}
const getLangAll = (): ReturnMessage => {
let message: Message = {};
getLangFiles(modules, message);
return message as ReturnMessage;
}
type LangFilesList = {
[s: string]: { default: { [s: string]: string } }
}
/**
* 获取所有语言文件
* @param {Object} langFilesList
* @param {Object} msg
*/
const getLangFiles = (langFilesList: LangFilesList, msg: Message) => {
for (let path in langFilesList) {
if (langFilesList[path].default) {
//判断中英文混合文件是否存在
if (/zh-CN-en-US/.test(path)) {
type LocaleObj = {
[s: string]: any
}
let localeObj: LocaleObj = {
'zh-CN': langFilesList[path].default,
'en-US': {}
}
let enUSLangList = JSON.parse(JSON.stringify(langFilesList[path].default));
recursionSetLeaf(enUSLangList);
localeObj['en-US'] = enUSLangList;
// 合并
for (let i in localeObj) {
msg[i] = msg[i] ? {
...msg[i],
...localeObj[i]
} : localeObj[i];
}
} else {
//获取文件名
const fileName = path.substr(path.lastIndexOf('/') + 1, 5);
msg[fileName] = msg[fileName] ? {
...langFilesList[fileName],
...langFilesList[path].default
} : langFilesList[path].default;
}
}
}
}
type LangObj = {
[propName:string]:any
}
/**
* 递归设置叶子节点的值为键名,即英文
* @param {LangObj} obj
*
*/
const recursionSetLeaf = (obj:LangObj) =>{
for (let i in obj) {
if(typeof obj[i] === 'string'){
obj[i] = i;
}else{
recursionSetLeaf(obj[i]);
}
}
}
//注册i8n实例并引入语言文件
const i18n = createI18n({
legacy: false,
locale: globalConfigStore.locale,
messages: getLangAll(),
sync: true
})
//ts/js文件中使用
window.$t = i18n.global.t;
window.$cpt = (key: string):ComputedRef<string> => computed(() => i18n.global.t(key));
export function setupI18n(app: App) {
app.use(i18n);
}
export function t(key: string):string {
return i18n.global.t(key);
}
export function cpt(key: string): ComputedRef<string> {
return computed(() => i18n.global.t(key));
}
export function setLocale(locale: IConfig.Locale) {
i18n.global.locale.value = locale;
globalConfigStore.setLocale(locale);
}
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
import '@/utils/common/init';
import { createApp } from 'vue';
import App from './App.vue';
import { setupStore } from '@/store';
import 'virtual:uno.css';
import "animate.css/animate.min.css";
import '@/assets/less/index.less';
import {setupI18n} from '@/language';
import {setupRouter} from "@/router";
import DisableDevtool from 'disable-devtool';
async function main() {
const app = createApp(App);
setupRouter(app);
// 配置存储
setupStore(app);
// 多语言配置
setupI18n(app);
app.mount('#app');
if (import.meta.env.PROD){
DisableDevtool({
clearLog:true
});
}
}
main();
@@ -0,0 +1,46 @@
/**
* @author ErSan
* @email mlt131220@163.com
* @date 2024/9/18 22:24
* @description
*/
export class ListrTask {
// @ts-ignore
private title: any;
private taskFn: any;
isFailed: boolean;
constructor(title, taskFn) {
this.title = title;
this.taskFn = taskFn;
this.isFailed = false;
}
async run() {
try {
await this.taskFn(this);
} catch (error) {
this.isFailed = true;
throw error;
}
}
}
export class Listr {
private tasks: ListrTask[];
constructor(tasks: { title:string,task:(task: any) => Promise<void> }[]) {
this.tasks = tasks.map(task => new ListrTask(task.title, task.task));
}
async run() {
for (const task of this.tasks) {
await task.run();
if (task.isFailed) {
break; // 如果任务失败,停止执行后续任务
}
}
}
}
@@ -0,0 +1,247 @@
/**
* @author ErSan
* @email mlt131220@163.com
* @date 2024/9/16 23:19
* @description glTF处理器插件
*/
import {h, ref} from "vue";
import type {ModalReactive} from "naive-ui";
import {t} from "@/language";
import type {Plugin} from "@astral3d/engine";
import GLTFHandlerComponent from "@/components/es/plugin/builtin/GLTFHandler.vue";
import { MeshoptEncoder, MeshoptSimplifier } from 'meshoptimizer';
// @ts-ignore
import { ready as resampleReady, resample as resampleWASM } from 'keyframe-resample';
import { Logger,WebIO, Transform } from '@gltf-transform/core';
import {
dedup,
instance,
prune,
quantize,
resample,
weld,
meshopt,
draco,
simplify,
textureCompress,
flatten,
join,
sparse,
palette,
} from '@gltf-transform/functions';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
import {Session} from "./session";
import {loadScript} from "@/utils/common/utils";
//使用'micromatch',因为'contains: true'没有像预期的那样在minimatch中工作。需要确保'*'匹配的模式,如'image/png'。
export const MICROMATCH_OPTIONS = { nocase: true, contains: true };
export default class GLTFHandler implements Plugin{
icon= "";
name = "glTF处理器";
version = 1;
logger = new Logger(Logger.Verbosity.INFO);
io = new WebIO({credentials: 'include'}).registerExtensions(ALL_EXTENSIONS);
modalInstance:ModalReactive | undefined = undefined;
GLTFHandlerComponentRef = ref();
dracoScript = new Proxy({
encoder:false,
decoder:false,
failMsg:""
},{
set:(target: {decoder: boolean;encoder: boolean;failMsg: string;}, p: string | symbol, newValue: any): boolean => {
target[p] = newValue;
if(p === "failMsg"){
window.$message?.error(newValue)
return true;
}
if(target.encoder && target.decoder){
this.registerDependencies()
}
return true;
}
})
async install() {
// console.log(`%c glTF处理器 %c 版本:1.0.0`, 'background: #35495e; padding: 4px; border-radius: 3px 0 0 3px; color: #fff',
// 'background: #41b883; padding: 4px; border-radius: 0 3px 3px 0; color: #fff');
}
async run() {
// 运行时再加载draco相关js
if(!this.dracoScript.encoder){
loadScript("/libs/draco/draco_encoder.js",false).then(() => {
this.dracoScript.encoder = true;
}).catch(() => {
this.dracoScript.failMsg = t("plugin.gltfHandler['Draco encoder load fail,Refresh the page and try again.']");
})
}
if(!this.dracoScript.decoder) {
loadScript("/libs/draco/draco_decoder.js", false).then(() => {
this.dracoScript.decoder = true;
}).catch(() => {
this.dracoScript.failMsg = t("plugin.gltfHandler['Draco decoder load fail,Refresh the page and try again.']");
})
}
this.GLTFHandlerComponentRef = ref();
const finishFn = this.finish.bind(this);
this.modalInstance = window.$modal.create({
title: this.name,
preset:"card",
maskClosable:false,
style: {
width: '90%',
maxWidth: '800px'
},
onAfterLeave: finishFn,
content: () => {
return h(GLTFHandlerComponent,{
onOptimize:this.optimize.bind(this),
onFinish: finishFn,
ref:this.GLTFHandlerComponentRef
},"")
},
})
}
// 关闭插件
finish(){
this.modalInstance && this.modalInstance.destroy();
this.GLTFHandlerComponentRef = ref();
}
uninstall(): void {}
setLogger(log:string){
if(!this.GLTFHandlerComponentRef.value) return;
this.GLTFHandlerComponentRef.value.addLog(log);
}
async registerDependencies(){
this.io.registerDependencies({
// @ts-ignore
'draco3d.encoder': await new DracoEncoderModule(),
// @ts-ignore
'draco3d.decoder': await new DracoDecoderModule(),
})
}
/* 下面是实现的自定义的处理器方法 */
async optimize(opts:IPlugin.GLTFHandlerOptimizeModel,inputFile:File,outputFileName = ""){
// console.log("调用优化处理器,",opts,inputFile)
this.setLogger(`Optimize ${inputFile.name}`);
if(this.dracoScript.failMsg){
window.$message?.error(this.dracoScript.failMsg);
return;
}
if(!this.dracoScript.encoder || !this.dracoScript.decoder){
setTimeout(()=>this.optimize(opts,inputFile,outputFileName),1000)
return;
}
/* 文件准备就绪,开始优化 */
const transforms: Transform[] = [dedup()];
if (opts.instance) transforms.push(instance({ min: opts.instanceMin }));
if (opts.palette) {
transforms.push(
palette({
min: opts.paletteMin,
keepAttributes: !opts.prune || !opts.pruneAttributes,
}),
);
}
if (opts.flatten) transforms.push(flatten());
if (opts.join) transforms.push(join());
if (opts.weld) transforms.push(weld());
if (opts.simplify) {
transforms.push(
simplify({
simplifier: MeshoptSimplifier,
// simplifyError 用%显示时扩大了100倍,需要高精度计算减小100倍
error: opts.simplifyError / 100,
ratio: opts.simplifyRatio,
lockBorder: opts.simplifyLockBorder,
}),
);
}
// 重新采样动画通道,无损地删除重复的关键帧以减小文件大小。重复的关键帧通常出现在由创作软件“烘焙”的动画中,以应用 IK 约束或其他软件特定功能。
transforms.push(resample({ ready: resampleReady, resample: resampleWASM }));
if (opts.prune) {
transforms.push(
prune({
keepAttributes: !opts.pruneAttributes,
keepIndices: false,
keepLeaves: !opts.pruneLeaves,
keepSolidTextures: !opts.pruneSolidTextures,
}),
);
}
transforms.push(sparse());
if (opts.textureCompress !== 'none') {
transforms.push(
textureCompress({
resize: [opts.textureSize, opts.textureSize],
targetFormat: opts.textureCompress === 'auto' ? undefined : opts.textureCompress,
// limitInputPixels: options.limitInputPixels as boolean,
limitInputPixels: false,
}),
);
}
// 最后进行网格压缩
if (opts.compress === 'draco') {
transforms.push(draco());
} else if (opts.compress === 'meshopt') {
transforms.push(meshopt({ encoder: MeshoptEncoder, level: opts.meshoptLevel }));
} else if (opts.compress === 'quantize') {
transforms.push(quantize());
}
// 设置输出文件名
if(!outputFileName){
const format = inputFile.name.split(".").pop();
outputFileName = inputFile.name.replace(`.${format}`,`_astral3d.optimize.${format}`)
}
// 生成临时 URL
const inputFileUrl = URL.createObjectURL(inputFile);
let outputFile:File | undefined = undefined
try {
outputFile = await Session.create(this,inputFileUrl, inputFile.name, outputFileName)
.setDisplay(true)
.transform(...transforms);
this.setLogger(`Optimize ${inputFile.name} success!`);
}catch (e:unknown){
if (e instanceof Error) {
window.$message?.error(e.message);
this.setLogger(`Optimize ${inputFile.name} error: ${e.message}`);
} else {
window.$message?.error(e as string);
this.setLogger(`Optimize ${inputFile.name} error: ${e}`);
}
}
return outputFile;
}
}
@@ -0,0 +1,125 @@
import {Document, WebIO, FileUtils, Transform, Format, Logger} from '@gltf-transform/core';
import type { Packet, KHRXMP } from '@gltf-transform/extensions';
import { unpartition } from '@gltf-transform/functions';
import {Listr} from "./Listr";
import { formatBytes, XMPContext } from './util.js';
import GLTFHandler from "./glTFHandler";
export class Session {
private _outputFormat: Format;
private _display = false;
constructor(
private _io: WebIO,
private _logger: Logger,
private setLogger: (log:string) => void,
private _input: string,
private _inputName: string,
private _output: string,
) {
_io.setLogger(_logger);
this._outputFormat = FileUtils.extension(_output) === 'glb' ? Format.GLB : Format.GLTF;
}
public static create(handler:GLTFHandler, inputFileUrl: string,inputName:string, output: string): Session {
return new Session(handler.io, handler.logger, handler.setLogger.bind(handler),inputFileUrl, inputName,output);
}
public setDisplay(display: boolean): this {
this._display = display;
return this;
}
public async transform(...transforms: Transform[]): Promise<File> {
this.setLogger("Start");
let _document = this._input
? (await this._io.read(this._input)).setLogger(this._logger)
: new Document().setLogger(this._logger);
// Warn and remove lossy compression, to avoid increasing loss on round trip.
for (const extensionName of ['KHR_draco_mesh_compression', 'EXT_meshopt_compression']) {
const extension = _document
.getRoot()
.listExtensionsUsed()
.find((extension) => extension.extensionName === extensionName);
if (extension) {
extension.dispose();
this._logger.warn(`Decoded ${extensionName}. Further compression will be lossy.`);
}
}
if (this._display) {
const tasks = [] as { title:string,task:(task: any) => Promise<void> }[];
for (const transform of transforms) {
tasks.push({
title: transform.name,
task: async (task) => {
try{
this.setLogger(task.title)
let time = performance.now();
_document = await _document.transform(transform);
time = Math.round(performance.now() - time);
this.setLogger(task.title.padEnd(20) + ` ${time}ms`)
}catch (error:unknown){
// @ts-ignore
this.setLogger(`${task.title} run fail: ${error?.message || error}`)
}
},
});
}
await new Listr(tasks).run();
} else {
await _document.transform(...transforms);
}
await _document.transform(updateMetadata);
if (this._outputFormat === Format.GLB) {
await _document.transform(unpartition());
}
const outputUint8Array = await this._io.writeBinary(_document);
// Uint8Array转file
const mimeType = this._outputFormat === Format.GLB ? "model/gltf-binary" : "model/gltf+json";
const blob = new Blob([outputUint8Array], { type: mimeType});
const outputFile = new File([blob], this._output, { type: mimeType });
const { lastReadBytes, lastWriteBytes } = this._io;
if (!this._input) {
const output = FileUtils.basename(this._output) + '.' + FileUtils.extension(this._output);
this._logger.info(`${output} (${formatBytes(lastWriteBytes)})`);
} else {
const input = FileUtils.basename(this._inputName) + '.' + FileUtils.extension(this._inputName);
const output = FileUtils.basename(this._output) + '.' + FileUtils.extension(this._output);
this._logger.info(
`${input} (${formatBytes(lastReadBytes)})` + `${output} (${formatBytes(lastWriteBytes)})`,
);
}
this.setLogger("Done")
return outputFile;
}
}
function updateMetadata(_document: Document): void {
const root = _document.getRoot();
const xmpExtension = root
.listExtensionsUsed()
.find((ext) => ext.extensionName === 'KHR_xmp_json_ld') as KHRXMP | null;
// 不要将KHR_xmp_json_ld添加到尚未使用它的资产中。
if (!xmpExtension) return;
const rootPacket = root.getExtension<Packet>('KHR_xmp_json_ld') || xmpExtension.createPacket();
// xmp:MetadataDate should be the same as, or more recent than, xmp:ModifyDate.
// https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/xmp.md
const date = new Date().toISOString().substring(0, 10);
rootPacket
.setContext({ ...rootPacket.getContext(), xmp: XMPContext.xmp })
.setProperty('xmp:ModifyDate', date)
.setProperty('xmp:MetadataDate', date);
}
@@ -0,0 +1,27 @@
export const XMPContext: Record<string, string> = {
dc: 'http://purl.org/dc/elements/1.1/',
model3d: 'https://schema.khronos.org/model3d/xsd/1.0/',
rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
xmp: 'http://ns.adobe.com/xap/1.0/',
xmpRights: 'http://ns.adobe.com/xap/1.0/rights/',
};
export function formatLong(x: number): string {
return x.toString();
}
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes';
const k = 1000;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
export function dim(str: string): string {
return `\x1b[2m${str}\x1b[0m`;
}
+10
View File
@@ -0,0 +1,10 @@
import GLTFHandler from "./glTFHandler/glTFHandler";
import PointCloudReconstructor from "./pointCloudReconstructor/PointCloudReconstructor";
// 注册内置插件
export const installBuiltinPlugin = (viewer) => {
//glTF处理器
viewer.modules.plugin.use(new GLTFHandler());
// 语义化点云重建
viewer.modules.plugin.use(new PointCloudReconstructor());
}
@@ -0,0 +1,55 @@
/**
* @author ErSan
* @email mlt131220@163.com
* @date 2025/07/03 14:20
* @description 语义化点云重建
*/
import { h, ref } from "vue";
import type { ModalReactive } from "naive-ui";
import type { Plugin } from "@astral3d/engine";
import PointCloudReconstructorComponent from "@/components/es/plugin/builtin/PointCloudReconstructor.vue";
export default class PointCloudReconstructor implements Plugin {
icon = "";
name = "语义化点云重建";
version = 0.1;
modalInstance: ModalReactive | undefined = undefined;
componentRef = ref();
async install() {
// console.log(`%c 语义化点云重建 %c 版本:0.1.0`, 'background: #35495e; padding: 4px; border-radius: 3px 0 0 3px; color: #fff',
// 'background: #41b883; padding: 4px; border-radius: 0 3px 3px 0; color: #fff');
}
async run() {
this.componentRef = ref();
this.modalInstance = window.$modal.create({
title: this.name,
preset: "card",
maskClosable: false,
style: {
width: '90%',
maxWidth: '800px'
},
onAfterLeave: () => {
this.componentRef.value.handleClose();
this.finish();
},
content: () => {
return h(PointCloudReconstructorComponent, {
ref: this.componentRef
}, "")
},
})
}
// 关闭插件
finish() {
this.modalInstance && this.modalInstance.destroy();
this.componentRef = ref();
}
uninstall(): void { }
}
+15
View File
@@ -0,0 +1,15 @@
import type { App } from 'vue';
import {createRouter, createWebHashHistory} from 'vue-router';
import {routes} from "@/router/routes";
export const router = createRouter({
history: createWebHashHistory(),
routes,
// 是否应该禁止尾部斜杠。默认为假
strict: true,
scrollBehavior: () => ({left: 0, top: 0}),
});
export function setupRouter(app: App<Element>) {
app.use(router);
}
+17
View File
@@ -0,0 +1,17 @@
export const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/home/index.vue')
},
{
path: '/editor/:id',
name: 'Editor',
component: () => import('@/views/editor/index.vue')
},
{
path: '/preview/:id',
name: 'Preview',
component: () => import('@/views/preview/index.vue')
}
];
+10
View File
@@ -0,0 +1,10 @@
import type { App } from 'vue';
import { createPinia } from 'pinia';
const store = createPinia();
export function setupStore(app: App<Element>) {
app.use(store);
}
export { store };
@@ -0,0 +1,335 @@
import {reactive, toRefs, h,computed} from "vue";
import {NIcon} from 'naive-ui';
import type {TreeOption} from 'naive-ui'
import {defineStore} from 'pinia';
import {ConditionPoint} from '@vicons/carbon';
import {store} from '@/store';
import {t} from "@/language";
import type {TimelineTrack,ITimelineRow} from "@astral3d/engine";
import {App} from "@astral3d/engine";
export interface IAnimationItem {
name: string,
uuid: string,
isRunning: boolean,
isPaused: boolean,
}
interface IAnimationState {
// 选中模型的动画列表
list: Array<IAnimationItem>,
// 动画编辑器当前选中的动画
current: IAnimationItem | null,
// 当前选中动画的所有轨道
trackTree: TreeOption[],
// 全局的动画播放速率
mixerTimeScale: number,
// 当前播放的时间(毫秒)
currentTime: number,
// 当前action总时长(毫秒)
duration: number,
}
// 动画轨道类实例
let timelineInstance: TimelineTrack, waitRows: ITimelineRow[] = [];
/**
* 模型动画(动画编辑器)
*/
export const useAnimationStore = defineStore('model-animation', () => {
const state = reactive<IAnimationState>({
list: [],
current: null,
trackTree: [],
mixerTimeScale: 1.0,
currentTime: 0,
duration: 0
})
/**
* getter
**/
// 动画时间帧一步的长度,默认一步走过一个像素代表1000ms (即1px代表多少ms)
const getStepVal = () => {
if (timelineInstance) {
return (<number>timelineInstance.timeline._options.stepVal) * timelineInstance.timeline._currentZoom / (<number>timelineInstance.timeline._options.zoom);
} else {
return 1000;
}
};
// 获取格式化后的总时长 00:00:00
const getFormattedDuration = computed(() => {
const _time = state.duration / 1000;
const hours = Math.floor(_time / 3600);
const minutes = Math.floor((_time - hours * 3600) / 60);
const seconds = _time - hours * 3600 - minutes * 60;
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toFixed(0).padStart(2, "0")}`;
});
// 获取格式化后的播放时间 00:00:00
const getFormattedCurrentTime = computed(() => {
const _time = state.currentTime / 1000;
const hours = Math.floor(_time / 3600);
const minutes = Math.floor((_time - hours * 3600) / 60);
const seconds = (_time- hours * 3600 - minutes * 60).toFixed(0);
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.padStart(2, "0")}`;
});
/**
* actions
**/
const setTimelineInstance = (instance: TimelineTrack) => {
timelineInstance = instance;
if (waitRows.length) {
timelineInstance.setRows(waitRows);
waitRows = [];
const action = App.animationManager.actionMap.get(state.current?.uuid as string);
if (!action) return;
timelineInstance.bindAction = action;
}
}
// 设置动画列表
const setList = (_list: Array<IAnimationItem>, _current: IAnimationItem | null = null) => {
state.list = _list;
if (_current) {
setCurrent(_current);
} else {
setCurrent(_list.length ? _list[0] : null);
}
}
// 设置当前选中的动画(会刷新树列表及动画轨道)
const setCurrent = (value: IAnimationItem | null) => {
state.current = value;
if (!value) {
state.trackTree = [];
state.currentTime = 0;
state.duration = 0;
if (timelineInstance) {
timelineInstance.setRows([]);
timelineInstance.bindAction = null;
waitRows = [];
}
return;
}
// 取出当前动画下的所有轨道组织rows关系
const action = App.animationManager.actionMap.get(value.uuid);
if (!action) return;
const actionClip = action.getClip();
const actionObject = action.getRoot();
state.duration = actionClip.duration * 1000;
// 取出所有轨道并按名称排序
const tracks = [...actionClip.tracks].sort((a, b) => a.name.localeCompare(b.name));
const _trackTree: TreeOption = {
key: actionObject.uuid,
label: actionObject.name,
children: [],
isLeaf: false
};
waitRows = [{
id: actionObject.uuid,
name: actionObject.name
}];
const pathMap = new Map(); // 用于记录已有路径
// 处理每个轨道
tracks.forEach(track => {
const parts = track.name.split('.');
let currentPath = '';
let parentNode: TreeOption | null = null;
parts.forEach((part, index) => {
const isLeaf = index === parts.length - 1;
const nodePath = currentPath ? `${currentPath}!!${part}` : part;
if (index === 0 && (part === actionObject.name || part === actionObject.uuid)) return;
// 如果节点已存在则直接使用
if (pathMap.has(nodePath)) {
parentNode = pathMap.get(nodePath);
currentPath = nodePath;
return;
}
// 记录到rows
const _row: ITimelineRow = {
id: nodePath,
name: part
}
// 创建新节点
const newNode: TreeOption = {
key: nodePath,
label: part,
children: [],
...(isLeaf && {
isLeaf: true,
suffix: () =>
h(
NIcon,
{
size: 16,
onClick: () => {
// 取最后一段(如 'nodeName.property[accessor]'
const lastSegment = parts[parts.length - 1];
// 再按 '[' 分割,取第一部分(如 'property'
let attr = lastSegment?.split('[')[0];
// 如果parts倒数第二个元素为material,则添加至前面
if(parts.length > 1 && parts[parts.length - 2] ==='material'){
attr = `material.${attr}`;
}
addKeyframe(attr);
}
},
{default: () => h(ConditionPoint)}
),
})
};
// 如果是叶子节点,则写入关键帧值
if (isLeaf) {
_row.keyframes = [];
// 每个关键帧的值的长度
const dataLength = Math.floor(track.values.length / track.times.length);
track.times.forEach((time, i) => {
_row.keyframes?.push({
val: time * 1000,
// @ts-ignore
data: track.values.slice(i * dataLength, (i + 1) * dataLength)
})
})
_row.track = track;
}
waitRows.push(_row);
// 挂载到父节点或根
if (parentNode) {
parentNode.children?.push(newNode);
} else {
_trackTree.children?.push(newNode);
}
// 更新路径记录
pathMap.set(nodePath, newNode);
parentNode = newNode;
currentPath = nodePath;
});
});
state.trackTree = [_trackTree];
if (timelineInstance) {
timelineInstance.setRows(waitRows);
timelineInstance.bindAction = action;
waitRows = [];
state.duration = timelineInstance._maxDuration;
}
}
const play = () => {
if (!state.current) return;
if (timelineInstance) {
timelineInstance.play();
} else {
const action = App.animationManager.actionMap.get(state.current.uuid);
if (!action) return;
action.play();
action.paused = false;
}
state.current.isPaused = false;
state.current.isRunning = true;
}
const pause = () => {
if (!state.current) return;
if (timelineInstance) {
timelineInstance.pause();
} else {
const action = App.animationManager.actionMap.get(state.current.uuid);
if (!action) return;
action.paused = !state.current.isPaused;
}
state.current.isPaused = !state.current.isPaused;
}
const stop = () => {
if (!state.current) return;
if (timelineInstance) {
timelineInstance.stop();
} else {
const action = App.animationManager.actionMap.get(state.current.uuid);
if (!action) return;
action.stop();
}
state.current.isPaused = false;
state.current.isRunning = false;
}
// 跳转至开始
const jumpToStart = () => {
if (!timelineInstance) return;
timelineInstance.timeline.setTime(0);
}
// 跳转至最后
const jumpToEnd = () => {
if (!timelineInstance) return;
timelineInstance.timeline.setTime(state.duration);
}
// 添加关键帧
const addKeyframe = (attr: string) => {
if (!state.current) {
window.$message?.error(t("prompt.Please select an animation"));
return;
}
if (state.current.isRunning) {
window.$message?.error(t("prompt['The animation is running, please stop the animation before performing operations']"));
return;
}
if (timelineInstance) {
timelineInstance.addKeyframe(attr);
state.duration = timelineInstance._maxDuration;
}
}
// 设置动画播放时间(单位:ms)
const setPlayTime = (time: number) => {
if (timelineInstance) {
timelineInstance.timeline.setTime(time);
}
};
return {
...toRefs(state),
getStepVal,
getFormattedDuration,
getFormattedCurrentTime,
setTimelineInstance,
setList,
setCurrent,
play,
pause,
stop,
jumpToStart,
jumpToEnd,
addKeyframe,
setPlayTime
}
});
// setup 之外使用
export function useAnimationStoreWithOut() {
return useAnimationStore(store);
}

Some files were not shown because too many files have changed in this diff Show More