diff --git a/src/designer/Viewport.ts b/src/designer/Viewport.ts new file mode 100644 index 0000000..8ed2e73 --- /dev/null +++ b/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 } + } +} \ No newline at end of file diff --git a/src/designer/WorldModel.ts b/src/designer/WorldModel.ts new file mode 100644 index 0000000..7eae62b --- /dev/null +++ b/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() + 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 = [] + } +} \ No newline at end of file diff --git a/src/designer/model2DEditor/Model2DEditor.vue b/src/designer/model2DEditor/Model2DEditor.vue index 92bf123..d1dcd97 100644 --- a/src/designer/model2DEditor/Model2DEditor.vue +++ b/src/designer/model2DEditor/Model2DEditor.vue @@ -4,36 +4,37 @@ - +
- +
+ :type="state.cursorMode==='normal'?'primary':''" + @click="()=>state.cursorMode = 'normal'"> + :type="state.cursorMode==='selectByRec'?'primary':''" + @click="()=>state.cursorMode = 'selectByRec'"> + :type="state.cursorMode==='ALink'?'primary':''" + @click="()=>state.cursorMode = 'ALink'"> + :type="state.cursorMode==='SLink'?'primary':''" + @click="()=>state.cursorMode = 'SLink'"> + :type="state.cursorMode==='Ruler'?'primary':''" + @click="()=>state.cursorMode = 'Ruler'">
00011 -
- {{ editorState.camera.position.x.toFixed(2) }},{{ editorState.camera.position.y.toFixed(2) }},{{ editorState.camera.position.z.toFixed(2) +
+ {{ state.camera.position.x.toFixed(2) }},{{ state.camera.position.y.toFixed(2) + }},{{ state.camera.position.z.toFixed(2) }}
diff --git a/src/designer/model2DEditor/Model2DEditorJs.js b/src/designer/model2DEditor/Model2DEditorJs.js new file mode 100644 index 0000000..5a2bfba --- /dev/null +++ b/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 + } + } +}) diff --git a/src/designer/model2DEditor/Model2DEditorJs.ts b/src/designer/model2DEditor/Model2DEditorJs.ts deleted file mode 100644 index c98b45a..0000000 --- a/src/designer/model2DEditor/Model2DEditorJs.ts +++ /dev/null @@ -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, - }, -} \ No newline at end of file diff --git a/src/designer/model2DEditor/ThreeJsEditor.vue b/src/designer/model2DEditor/ThreeJsEditor.vue deleted file mode 100644 index b6a62c5..0000000 --- a/src/designer/model2DEditor/ThreeJsEditor.vue +++ /dev/null @@ -1,49 +0,0 @@ - - \ No newline at end of file diff --git a/src/designer/viewWidgets/modeltree/ModeltreeViewJs.js b/src/designer/viewWidgets/modeltree/ModeltreeViewJs.js index 0104c9c..fbaab05 100644 --- a/src/designer/viewWidgets/modeltree/ModeltreeViewJs.js +++ b/src/designer/viewWidgets/modeltree/ModeltreeViewJs.js @@ -35,7 +35,7 @@ export default defineComponent({ }, computed: { allLevels() { - return designer.allLevels + return worldModel.allLevels } } }) diff --git a/src/views/ModelMainInit.ts b/src/views/ModelMainInit.ts index ec4d84f..d996274 100644 --- a/src/views/ModelMainInit.ts +++ b/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() {