Browse Source

WorldMode / Viewport / editor 设计模式重构

master
修宁 7 months ago
parent
commit
316c1900c4
  1. 336
      src/designer/Viewport.ts
  2. 130
      src/designer/WorldModel.ts
  3. 30
      src/designer/model2DEditor/Model2DEditor.vue
  4. 78
      src/designer/model2DEditor/Model2DEditorJs.js
  5. 40
      src/designer/model2DEditor/Model2DEditorJs.ts
  6. 49
      src/designer/model2DEditor/ThreeJsEditor.vue
  7. 2
      src/designer/viewWidgets/modeltree/ModeltreeViewJs.js
  8. 4
      src/views/ModelMainInit.ts

336
src/designer/Viewport.ts

@ -0,0 +1,336 @@
import _ from 'lodash'
import * as THREE from 'three'
import { AxesHelper, GridHelper, OrthographicCamera, Scene, WebGLRenderer } from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import Stats from 'three/examples/jsm/libs/stats.module'
import type WorldModel from '@/designer/WorldModel.ts'
import $ from 'jquery'
import { watch } from 'vue'
/**
*
* 使(使)
*/
export default class Viewport {
viewerDom: HTMLElement
scene: Scene
camera: OrthographicCamera
renderer: WebGLRenderer
axesHelper: AxesHelper
gridHelper: GridHelper
statsControls: Stats
controls: OrbitControls
worldModel: WorldModel
/**
*
*/
resizeObserver?: ResizeObserver
unwatchList: (() => void)[] = []
state: ViewportState = {
currentFloor: null,
isReady: false,
cursorMode: 'normal',
camera: {
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 }
}
}
constructor(worldModel: WorldModel) {
this.worldModel = worldModel
}
/**
* THREE
*/
initThree(viewerDom: HTMLElement) {
this.viewerDom = viewerDom
this.worldModel.registerViewport(this)
// 场景
const scene = this.worldModel.getSceneByFloor(this.state.currentFloor)
this.scene = scene
// 渲染器
const renderer = new THREE.WebGLRenderer({
logarithmicDepthBuffer: true,
antialias: true,
alpha: true,
precision: 'mediump',
premultipliedAlpha: true,
preserveDrawingBuffer: false,
powerPreference: 'high-performance'
})
//@ts-ignore
renderer.outputEncoding = THREE.SRGBColorSpace
renderer.clearDepth()
renderer.shadowMap.enabled = true
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setSize(viewerDom.getBoundingClientRect().width, viewerDom.getBoundingClientRect().height)
viewerDom.appendChild(renderer.domElement)
this.renderer = renderer
// 创建正交摄像机
this.initMode2DCamera()
// 辅助线
this.axesHelper = new THREE.AxesHelper(3)
this.scene.add(this.axesHelper)
this.gridHelper = new THREE.GridHelper(500, 500)
const gridHelper = this.gridHelper
gridHelper.material = new THREE.LineBasicMaterial({
color: 0x888888,
opacity: 0.8,
transparent: true
})
scene.add(gridHelper)
// 光照
const ambientLight = new THREE.AmbientLight(0xffffff, 1.5)
scene.add(ambientLight)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5)
directionalLight.position.set(5, 5, 5).multiplyScalar(3)
directionalLight.castShadow = true
scene.add(directionalLight)
const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0x444444, 1)
scene.add(hemisphereLight)
// 性能监控
const statsControls = new Stats()
this.statsControls = statsControls
statsControls.showPanel(0)
statsControls.dom.style.position = 'absolute'
statsControls.dom.style.top = '2px'
statsControls.dom.style.left = '0'
viewerDom.appendChild(statsControls.dom)
$(statsControls.dom).children().css('height', '28px')
// 创建几何体和材质
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshStandardMaterial({
color: 0xcccccc,
metalness: 0.9,
roughness: 0.1
})
const cube = new THREE.Mesh(geometry, material)
scene.add(cube)
this.camera.position.z = 5
this.animate()
const unWatchFn = watch(() => this.state.camera.position.y, (newVal) => {
if (this.state.isReady) {
this.updateGridVisibility()
}
})
this.unwatchList.push(unWatchFn)
if (this.resizeObserver) {
this.resizeObserver.unobserve(this.viewerDom)
}
this.resizeObserver = new ResizeObserver(this.handleResize.bind(this))
this.resizeObserver.observe(this.viewerDom)
this.state.isReady = true
}
/**
* 2D相机
*/
initMode2DCamera() {
if (this.camera) {
this.scene.remove(this.camera)
}
// ============================ 创建正交相机
const viewerDom = this.viewerDom
const cameraNew = new THREE.OrthographicCamera(
viewerDom.clientWidth / -2,
viewerDom.clientWidth / 2,
viewerDom.clientHeight / 2,
viewerDom.clientHeight / -2,
1,
500
)
cameraNew.position.set(0, 100, 0)
cameraNew.lookAt(0, 0, 0)
cameraNew.zoom = 30
this.camera = cameraNew
this.scene.add(this.camera)
// ============================ 创建控制器
const controlsNew = new OrbitControls(
this.camera,
this.renderer.domElement
)
controlsNew.enableDamping = false
controlsNew.enableZoom = true
controlsNew.enableRotate = false
controlsNew.mouseButtons = { LEFT: THREE.MOUSE.PAN } // 鼠标中键平移
controlsNew.screenSpacePanning = false // 定义平移时如何平移相机的位置 控制不上下移动
controlsNew.listenToKeyEvents(viewerDom) // 监听键盘事件
controlsNew.keys = { LEFT: 'KeyA', UP: 'KeyW', RIGHT: 'KeyD', BOTTOM: 'KeyS' }
controlsNew.panSpeed = 1
controlsNew.keyPanSpeed = 20 // normal 7
controlsNew.minDistance = 0.1
controlsNew.maxDistance = 1000
this.controls = controlsNew
controlsNew.addEventListener('change', this.syncCameraState.bind(this))
this.camera.updateProjectionMatrix()
this.syncCameraState()
}
/**
*
*/
animate() {
requestAnimationFrame(this.animate.bind(this))
this.renderView()
}
/**
*
*/
renderView() {
this.statsControls?.update()
this.renderer?.render(this.scene, this.camera)
}
/**
*
*/
syncCameraState() {
if (this.camera) {
const camera = this.camera
this.state.camera.position.x = camera.position.x
this.state.camera.position.y = this.getEffectiveViewDistance()
this.state.camera.position.z = camera.position.z
}
}
/**
*
*/
getEffectiveViewDistance() {
if (!this.camera) {
return 10
}
const camera = this.camera
const viewHeight = (camera.top - camera.bottom) / camera.zoom
// 假设我们希望匹配一个虚拟的透视相机(通常使用45度fov作为参考)
const referenceFOV = 45 // 参考视场角
return viewHeight / (2 * Math.tan(THREE.MathUtils.degToRad(referenceFOV) / 2))
}
handleResize(entries: any) {
for (let entry of entries) {
// entry.contentRect包含了元素的尺寸信息
console.log('Element size changed:', entry.contentRect)
const width = entry.contentRect.width
const height = entry.contentRect.height
if (this.camera instanceof THREE.PerspectiveCamera) {
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
} else if (this.camera instanceof THREE.OrthographicCamera) {
this.camera.left = width / -2
this.camera.right = width / 2
this.camera.top = height / 2
this.camera.bottom = height / -2
this.camera.updateProjectionMatrix()
}
this.renderer.setSize(width, height)
break
}
}
/**
*
*/
updateGridVisibility() {
const cameraDistance = this.state.camera.position.y
const maxVisibleDistance = 60 // 网格完全可见的最大距离
const fadeStartDistance = 15 // 开始淡出的距离
// 计算透明度(0~1)
let opacity = 1
if (cameraDistance > fadeStartDistance) {
opacity = 1 - Math.min((cameraDistance - fadeStartDistance) / (maxVisibleDistance - fadeStartDistance), 1)
}
// 修改网格材质透明度
this.gridHelper.material.opacity = opacity
this.gridHelper.visible = opacity > 0
console.log('opacity', opacity)
}
destroy() {
this.state.isReady = false
if (this.unwatchList) {
_.forEach(this.unwatchList, (unWatchFn => {
unWatchFn()
}))
this.unwatchList = []
}
if (this.resizeObserver) {
this.resizeObserver.unobserve(this.viewerDom)
this.resizeObserver.disconnect()
this.resizeObserver = undefined
}
this.worldModel.unregisterViewport(this)
if (this.statsControls) {
this.statsControls.dom.remove()
}
if (this.renderer) {
this.renderer.dispose()
this.renderer.forceContextLoss()
console.log('WebGL disposed, memory:', this.renderer.info.memory)
this.renderer.domElement = null
}
}
}
export interface ViewportState {
/**
*
*/
currentFloor: string
/**
*
*/
isReady: boolean
/**
*
*/
cursorMode: 'normal' | 'ALink' | 'SLink' | 'PointCallback' | 'PointAdd' | 'LinkAdd' | 'LinkAdd2' | 'Ruler' | 'selectByRec',
/**
*
*/
camera: {
position: { x: number, y: number, z: number },
rotation: { x: number, y: number, z: number }
}
}

130
src/designer/WorldModel.ts

@ -0,0 +1,130 @@
import _ from 'lodash'
import Example1 from './example1'
import { markRaw, reactive } from 'vue'
import { Scene } from 'three'
import type Viewport from '@/designer/Viewport.ts'
import * as THREE from 'three'
/**
*
*/
export default class WorldModel {
data: any = null
allLevels: any = null
sceneMap = new Map<string, Scene>()
viewPorts: Viewport[] = []
constructor() {
this.init()
this.open()
}
init() {
window['worldModel'] = this
}
open() {
if (this.sceneMap.size > 0) {
// 释放旧场景
this.sceneMap.forEach((scene: Scene) => {
this.sceneDispose(scene)
})
}
if (this.viewPorts.length > 0) {
// 注销视口
this.viewPorts.forEach((viewport: Viewport) => {
this.unregisterViewport(viewport)
})
}
system.msg('打开世界地图完成')
this.data = markRaw(Example1)
this.allLevels = reactive(this.data.allLevels)
}
/**
* ,
* @param floor
*/
getSceneByFloor(floor: string) {
if (this.sceneMap.has(floor)) {
return this.sceneMap.get(floor)
} else {
const scene = this.createScene(floor)
this.sceneMap.set(floor, scene)
return scene
}
}
/**
*
*/
createScene(floor: string) {
const scene = new Scene()
scene.background = new THREE.Color(0xeeeeee)
return scene
}
/**
*
*/
registerViewport(viewport: Viewport) {
this.viewPorts = this.viewPorts || []
this.viewPorts.push(viewport)
}
/**
*
*/
unregisterViewport(viewport: Viewport) {
const index = this.viewPorts.indexOf(viewport)
if (index > -1) {
this.viewPorts.splice(index, 1)
}
}
/**
* , WebGL
*/
sceneDispose(scene: Scene = null) {
// 移除旧模型
if (!scene) {
return
}
scene.traverse((obj: any) => {
// 释放几何体
if (obj.geometry) {
obj.geometry.dispose()
}
// 释放材质
if (obj.material) {
if (Array.isArray(obj.material)) {
obj.material.forEach(m => m.dispose())
} else {
obj.material.dispose()
}
}
// 释放纹理
if (obj.texture) {
obj.texture.dispose()
}
// 释放渲染目标
if (obj.renderTarget) {
obj.renderTarget.dispose()
}
// 移除事件监听(如 OrbitControls)
if (obj.dispose) {
obj.dispose()
}
})
// 清空场景
scene.children = []
}
}

30
src/designer/model2DEditor/Model2DEditor.vue

@ -4,36 +4,37 @@
<span class="section-toolbar-line" style="margin-left: 85px;"></span>
<el-button :icon="renderIcon('antd ClusterOutlined')" link></el-button>
<span class="section-toolbar-line"></span>
<el-cascader placeholder="选择楼层" size="small" v-model="view.currentLevel" :options="allLevels" filterable />
<el-cascader placeholder="选择楼层" size="small" v-model="state.currentFloor" :options="allLevels" filterable />
</div>
<div class="section-content">
<ThreeJsEditor v-if="view.currentLevel" :key="view.currentLevel" />
<div :key="state.currentFloor"
class="canvas-container" ref="canvasContainer" tabindex="1" />
</div>
<div class="section-bottom-toolbar section-toolbar">
<div class="section-toolbar-left">
<el-button title="鼠标状态 (ESC)" :icon="renderIcon('fa MousePointer')" link
:type="oper.currentMode==='normal'?'primary':''"
@click="()=>oper.currentMode = 'normal'"></el-button>
:type="state.cursorMode==='normal'?'primary':''"
@click="()=>state.cursorMode = 'normal'"></el-button>
<span class="section-toolbar-line"></span>
<el-button title="框选模式 (T)" :icon="renderIcon('FullScreen')" link
:type="oper.currentMode==='selectByRec'?'primary':''"
@click="()=>oper.currentMode = 'selectByRec'"></el-button>
:type="state.cursorMode==='selectByRec'?'primary':''"
@click="()=>state.cursorMode = 'selectByRec'"></el-button>
<span class="section-toolbar-line"></span>
<el-button title="物理流动线 (Z)" :icon="renderIcon('antd EnterOutlined')" link
:type="oper.currentMode==='ALink'?'primary':''"
@click="()=>oper.currentMode = 'ALink'"></el-button>
:type="state.cursorMode==='ALink'?'primary':''"
@click="()=>state.cursorMode = 'ALink'"></el-button>
<span class="section-toolbar-line"></span>
<el-button title="逻辑关联 (X)" :icon="renderIcon('antd LinkOutlined')" link
:type="oper.currentMode==='SLink'?'primary':''"
@click="()=>oper.currentMode = 'SLink'"></el-button>
:type="state.cursorMode==='SLink'?'primary':''"
@click="()=>state.cursorMode = 'SLink'"></el-button>
<span class="section-toolbar-line"></span>
<el-button title="测量工具" :icon="renderIcon('fa Ruler')" link
:type="oper.currentMode==='Ruler'?'primary':''"
@click="()=>oper.currentMode = 'Ruler'"></el-button>
:type="state.cursorMode==='Ruler'?'primary':''"
@click="()=>state.cursorMode = 'Ruler'"></el-button>
</div>
<div class="section-toolbar-right">
<el-input v-model="view.searchKeyword" size="small" style="width: 110px; margin-right: 5px;"
@ -46,8 +47,9 @@
<span class="section-toolbar-line"></span>
<el-text type="danger">00011</el-text>
<span class="section-toolbar-line"></span>
<div v-if="editorState.ready">
{{ editorState.camera.position.x.toFixed(2) }},{{ editorState.camera.position.y.toFixed(2) }},{{ editorState.camera.position.z.toFixed(2)
<div v-if="state.ready">
{{ state.camera.position.x.toFixed(2) }},{{ state.camera.position.y.toFixed(2)
}},{{ state.camera.position.z.toFixed(2)
}}
</div>
<span class="section-toolbar-line"></span>

78
src/designer/model2DEditor/Model2DEditorJs.js

@ -0,0 +1,78 @@
import { renderIcon } from '@/utils/webutils.ts'
import { defineComponent, markRaw } from 'vue'
import Viewport from '@/designer/Viewport.ts'
export default defineComponent({
name: 'Model2DEditor',
data() {
const viewport = new Viewport(worldModel)
return {
viewport: viewport,
view: {
searchKeyword: ''
}
}
},
mounted() {
window['editor'] = this
},
beforeMount() {
this.initByFloor('')
delete window['editor']
},
methods: {
renderIcon,
initByFloor(floor) {
const viewportOrigin = this.viewport
if (viewportOrigin && viewportOrigin.state.isReady) {
viewportOrigin.destroy()
this.viewport = null
return
}
delete window['editor']
delete window['viewport']
delete window['scene']
delete window['renderer']
delete window['camera']
delete window['renderer']
delete window['controls']
if (!floor) {
return
}
const viewerDom = this.$refs.canvasContainer
const viewport = markRaw(new Viewport(worldModel))
this.viewport = viewport
viewport.initThree(viewerDom)
window['viewport'] = viewport
window['scene'] = viewport.scene
window['renderer'] = viewport.renderer
window['camera'] = viewport.camera
window['renderer'] = viewport.renderer
window['controls'] = viewport.controls
viewerDom?.focus()
}
},
watch: {
'state.currentFloor'(newVal, oldVal) {
this.initByFloor(newVal)
}
},
computed: {
/**
* @returns {ViewportState|{}}
*/
state() {
return this.viewport?.state || {}
},
allLevels() {
return worldModel.allLevels
}
}
})

40
src/designer/model2DEditor/Model2DEditorJs.ts

@ -1,40 +0,0 @@
import { renderIcon } from '@/utils/webutils.ts'
import { defineComponent } from 'vue'
import ThreeJsEditor from './ThreeJsEditor.vue'
export default defineComponent({
name: 'Model2DEditor',
components: { ThreeJsEditor },
data() {
return {
oper: {
currentMode: 'normal'
},
view: {
currentLevel: '',
searchKeyword: ''
}
} as IData
},
methods: {
renderIcon
},
computed: {
editorState() {
return designer.editorState
},
allLevels() {
return designer.allLevels
}
}
})
export interface IData {
oper: {
currentMode: 'normal' | 'ALink' | 'SLink' | 'PointCallback' | 'PointAdd' | 'LinkAdd' | 'LinkAdd2' | 'Ruler' | 'selectByRec',
},
view: {
currentLevel: string,
searchKeyword: string,
},
}

49
src/designer/model2DEditor/ThreeJsEditor.vue

@ -1,49 +0,0 @@
<template>
<div class="canvas-container" ref="canvasContainer" tabindex="1" />
</template>
<script setup lang="ts">
import { getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import Viewport from '@/designer/Viewport.ts'
// DOM refs
const canvasContainer = ref<HTMLElement>(null)
// Three.js
let viewport: Viewport
onMounted(() => {
const instance = getCurrentInstance()
nextTick(() => {
const viewerDom = canvasContainer.value
if (!viewerDom) {
system.showErrorDialog('viewerDom is null')
return
}
viewport = new Viewport(worldModel)
viewport.initThree(viewerDom)
window['editor'] = instance
window['viewport'] = viewport
window['scene'] = viewport.scene
window['renderer'] = viewport.renderer
window['camera'] = viewport.camera
window['renderer'] = viewport.renderer
window['controls'] = viewport.controls
viewerDom?.focus()
})
})
onBeforeUnmount(() => {
viewport.destroy()
delete window['editor']
delete window['viewport']
delete window['scene']
delete window['renderer']
delete window['camera']
delete window['renderer']
delete window['controls']
})
</script>

2
src/designer/viewWidgets/modeltree/ModeltreeViewJs.js

@ -35,7 +35,7 @@ export default defineComponent({
},
computed: {
allLevels() {
return designer.allLevels
return worldModel.allLevels
}
}
})

4
src/views/ModelMainInit.ts

@ -15,7 +15,7 @@ import ToolsMenu from '@/designer/menus/Tools.ts'
import Model3DView from '@/designer/menus/Model3DView.ts'
import { forEachMenu } from '@/runtime/DefineMenu.ts'
import { normalizeShortKey } from '@/utils/webutils.ts'
import Designer from '@/designer/Designer.ts'
import WorldModel from '@/designer/WorldModel.ts'
/**
*
@ -35,7 +35,7 @@ export function ModelMainInit() {
ToolsMenu.install()
Model3DView.install()
new Designer().init()
new WorldModel().init()
}
export function ModelMainMounted() {

Loading…
Cancel
Save