You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
505 lines
14 KiB
505 lines
14 KiB
import _ from 'lodash'
|
|
import * as THREE from 'three'
|
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
|
import EsDragControls from './model2DEditor/EsDragControls'
|
|
import Stats from 'three/examples/jsm/libs/stats.module'
|
|
import type WorldModel from '@/model/WorldModel.ts'
|
|
import $ from 'jquery'
|
|
import { reactive, watch } from 'vue'
|
|
import MouseMoveInspect from '@/designer/model2DEditor/tools/MouseMoveInspect.ts'
|
|
import type { ITool } from '@/designer/model2DEditor/tools/ITool.ts'
|
|
import { CSS3DRenderer } from 'three/examples/jsm/renderers/CSS3DRenderer'
|
|
import { CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer'
|
|
import { getAllItemTypes } from '@/runtime/DefineItemType.ts'
|
|
import type { ItemTypeDefineOption } from '@/model/itemType/ItemTypeDefine.ts'
|
|
import type Toolbox from '@/model/itemType/Toolbox.ts'
|
|
import { calcPositionUseSnap } from '@/model/ModelUtils.ts'
|
|
|
|
/**
|
|
* 编辑器对象
|
|
* 这是非双向绑定的设计器对象,不记录状态,只记录全局使用到的对象,(实体类使用)
|
|
*/
|
|
export default class Viewport {
|
|
viewerDom: HTMLElement
|
|
scene: THREE.Scene
|
|
camera: THREE.OrthographicCamera
|
|
renderer: THREE.WebGLRenderer
|
|
axesHelper: THREE.GridHelper
|
|
gridHelper: THREE.GridHelper
|
|
statsControls: Stats
|
|
controls: OrbitControls
|
|
worldModel: WorldModel
|
|
raycaster: THREE.Raycaster
|
|
dragControl: EsDragControls
|
|
animationFrameId: any = null
|
|
|
|
//搭配 state.cursorMode = xxx 之后, currentTool.start(第一个参数) 使用
|
|
toolStartObject: THREE.Object3D | null = null
|
|
currentTool: Toolbox | null = null
|
|
tools: ITool[] = [
|
|
new MouseMoveInspect()
|
|
]
|
|
toolbox: Record<string, Toolbox> = {}
|
|
|
|
/**
|
|
* 监听窗口大小变化
|
|
*/
|
|
resizeObserver?: ResizeObserver
|
|
|
|
/**
|
|
* vue 的 watcher
|
|
*/
|
|
watchList: (() => void)[] = []
|
|
|
|
css2DRenderer: CSS2DRenderer = new CSS2DRenderer()
|
|
css3DRenderer: CSS3DRenderer = new CSS3DRenderer()
|
|
|
|
//@ts-ignore
|
|
state: ViewportState = reactive({
|
|
currentFloor: '',
|
|
isReady: false,
|
|
cursorMode: 'normal',
|
|
camera: {
|
|
position: { x: 0, y: 0, z: 0 },
|
|
rotation: { x: 0, y: 0, z: 0 }
|
|
},
|
|
mouse: {
|
|
x: 0,
|
|
y: 0
|
|
}
|
|
})
|
|
|
|
constructor(worldModel: WorldModel) {
|
|
this.worldModel = worldModel
|
|
}
|
|
|
|
dispatchSignal(signal: string, data?: any) {
|
|
// console.log('signal', signal, data)
|
|
}
|
|
|
|
/**
|
|
* 初始化 THREE 渲染器
|
|
*/
|
|
initThree(viewerDom: HTMLElement, floor: string) {
|
|
console.log('viewport on floor', floor)
|
|
this.state.currentFloor = floor
|
|
this.viewerDom = viewerDom
|
|
const rect = viewerDom.getBoundingClientRect()
|
|
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'
|
|
})
|
|
renderer.debug.checkShaderErrors = true
|
|
//@ts-ignore
|
|
renderer.outputEncoding = THREE.SRGBColorSpace
|
|
renderer.clearDepth()
|
|
renderer.shadowMap.enabled = true
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
|
renderer.setPixelRatio(Math.max(Math.ceil(window.devicePixelRatio), 1))
|
|
renderer.setViewport(0, 0, this.viewerDom.offsetWidth, this.viewerDom.offsetHeight)
|
|
renderer.setSize(this.viewerDom.offsetWidth, this.viewerDom.offsetHeight)
|
|
|
|
viewerDom.appendChild(renderer.domElement)
|
|
|
|
renderer.domElement.style.touchAction = 'none'
|
|
|
|
// 防止重复添加
|
|
if (this.css2DRenderer.domElement.parentNode !== this.viewerDom) {
|
|
this.css2DRenderer.setSize(this.viewerDom.offsetWidth, this.viewerDom.offsetHeight)
|
|
this.css2DRenderer.domElement.setAttribute('id', 'astral-3d-preview-css2DRenderer')
|
|
this.css2DRenderer.domElement.style.position = 'absolute'
|
|
this.css2DRenderer.domElement.style.top = '0px'
|
|
this.css2DRenderer.domElement.style.pointerEvents = 'none'
|
|
|
|
this.viewerDom.appendChild(this.css2DRenderer.domElement)
|
|
}
|
|
|
|
// 防止重复添加
|
|
if (this.css3DRenderer.domElement.parentNode !== this.viewerDom) {
|
|
this.css3DRenderer.setSize(this.viewerDom.offsetWidth, this.viewerDom.offsetHeight)
|
|
this.css3DRenderer.domElement.setAttribute('id', 'astral-3d-preview-css3DRenderer')
|
|
this.css3DRenderer.domElement.style.position = 'absolute'
|
|
this.css3DRenderer.domElement.style.top = '0px'
|
|
this.css3DRenderer.domElement.style.pointerEvents = 'none'
|
|
|
|
this.viewerDom.appendChild(this.css3DRenderer.domElement)
|
|
}
|
|
|
|
this.renderer = renderer
|
|
|
|
// 创建正交摄像机
|
|
this.initMode2DCamera()
|
|
|
|
// 辅助线
|
|
const gridOption = this.worldModel.gridOption
|
|
const axesHelper = new THREE.GridHelper(gridOption.axesSize, gridOption.axesDivisions)
|
|
axesHelper.material.color.setHex(gridOption.axesColor)
|
|
axesHelper.material.linewidth = 2
|
|
axesHelper.material.opacity = gridOption.gridOpacity
|
|
axesHelper.material.transparent = true
|
|
if (!gridOption.axesEnabled) {
|
|
axesHelper.visible = false
|
|
}
|
|
|
|
// @ts-ignore
|
|
axesHelper.material.vertexColors = false
|
|
this.axesHelper = axesHelper
|
|
this.scene.add(this.axesHelper)
|
|
|
|
const gridHelper = new THREE.GridHelper(gridOption.gridSize, gridOption.gridDivisions)
|
|
gridHelper.material.color.setHex(gridOption.gridColor)
|
|
gridHelper.material.opacity = gridOption.gridOpacity
|
|
gridHelper.material.transparent = true
|
|
// @ts-ignore
|
|
gridHelper.material.vertexColors = false
|
|
if (!gridOption.gridEnabled) {
|
|
gridHelper.visible = false
|
|
}
|
|
|
|
this.gridHelper = gridHelper
|
|
this.scene.add(this.gridHelper)
|
|
|
|
// 光照
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.8)
|
|
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 = '0'
|
|
statsControls.dom.style.left = '0'
|
|
viewerDom.parentElement.parentElement.appendChild(statsControls.dom)
|
|
$(statsControls.dom).children().css('height', '28px')
|
|
|
|
this.animate()
|
|
|
|
// 监听事件
|
|
this.watchList.push(watch(() => this.state.camera.position.y, (newVal) => {
|
|
if (!this.state.isReady) {
|
|
return
|
|
}
|
|
this.updateGridVisibility()
|
|
}))
|
|
this.watchList.push(watch(() => this.state.cursorMode, (newVal: CursorMode) => {
|
|
if (!this.state.isReady) {
|
|
return
|
|
}
|
|
if (this.currentTool) {
|
|
this.currentTool.stop()
|
|
this.currentTool = null
|
|
}
|
|
if (newVal === 'normal' || !newVal) {
|
|
this.dragControl.dragControls.enabled = true
|
|
return
|
|
}
|
|
|
|
const currentTool = this.toolbox[newVal]
|
|
if (currentTool) {
|
|
// 选择标尺工具
|
|
this.currentTool = currentTool
|
|
this.dragControl.dragControls.enabled = false
|
|
|
|
} else {
|
|
system.showErrorDialog(`当前鼠标模式 ${newVal} 不支持`)
|
|
}
|
|
|
|
if (this.currentTool) {
|
|
this.currentTool.start(this.toolStartObject)
|
|
this.toolStartObject = null
|
|
}
|
|
}))
|
|
|
|
if (this.resizeObserver) {
|
|
this.resizeObserver.unobserve(this.viewerDom)
|
|
}
|
|
this.resizeObserver = new ResizeObserver(this.handleResize.bind(this))
|
|
this.resizeObserver.observe(this.viewerDom)
|
|
|
|
// 初始化射线投射器
|
|
this.raycaster = new THREE.Raycaster()
|
|
|
|
// 初始化所有常驻工具
|
|
for (const tool of this.tools) {
|
|
tool.init(this)
|
|
}
|
|
|
|
// 注册拖拽组件
|
|
this.dragControl = new EsDragControls(this)
|
|
|
|
_.forEach(getAllItemTypes(), (itemType: ItemTypeDefineOption) => {
|
|
itemType.clazz.afterAddViewport(this)
|
|
})
|
|
|
|
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.addEventListener('change', this.syncCameraState.bind(this))
|
|
controlsNew.panSpeed = 1
|
|
controlsNew.keyPanSpeed = 20 // normal 7
|
|
controlsNew.minDistance = 0.1
|
|
controlsNew.maxDistance = 1000
|
|
this.controls = controlsNew
|
|
|
|
this.camera.updateProjectionMatrix()
|
|
|
|
this.syncCameraState()
|
|
}
|
|
|
|
/**
|
|
* 动画循环
|
|
*/
|
|
animate() {
|
|
this.animationFrameId = requestAnimationFrame(this.animate.bind(this))
|
|
this.renderView()
|
|
}
|
|
|
|
/**
|
|
* 渲染视图
|
|
*/
|
|
renderView() {
|
|
this.statsControls?.update()
|
|
this.renderer?.render(this.scene, this.camera)
|
|
|
|
this.css2DRenderer.render(this.scene, this.camera)
|
|
this.css3DRenderer.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)
|
|
this.css2DRenderer.setSize(width, height)
|
|
this.css3DRenderer.setSize(width, height)
|
|
break
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 根据可视化范围更新网格的透明度
|
|
*/
|
|
updateGridVisibility() {
|
|
const cameraDistance = this.state.camera.position.y
|
|
const maxVisibleDistance = 60 // 网格完全可见的最大距离
|
|
const fadeStartDistance = 15 // 开始淡出的距离
|
|
|
|
// 计算透明度(0~1)
|
|
let opacity = 0.8
|
|
if (cameraDistance > fadeStartDistance) {
|
|
opacity = 0.8 - Math.min((cameraDistance - fadeStartDistance) / (maxVisibleDistance - fadeStartDistance) * 0.8, 0.8)
|
|
}
|
|
|
|
// 修改网格材质透明度
|
|
this.gridHelper.material.opacity = opacity
|
|
this.gridHelper.visible = opacity > 0
|
|
}
|
|
|
|
destroy() {
|
|
this.state.isReady = false
|
|
|
|
if (this.animationFrameId !== null) {
|
|
cancelAnimationFrame(this.animationFrameId)
|
|
this.animationFrameId = null
|
|
}
|
|
|
|
if (this.watchList) {
|
|
_.forEach(this.watchList, (unWatchFn => {
|
|
if (typeof unWatchFn === 'function') {
|
|
unWatchFn()
|
|
}
|
|
}))
|
|
this.watchList = []
|
|
}
|
|
|
|
if (this.tools) {
|
|
for (const tool of this.tools) {
|
|
if (tool.destory) {
|
|
tool.destory()
|
|
}
|
|
}
|
|
this.tools = []
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
getIntersects(point: THREE.Vector2) {
|
|
const mouse = new THREE.Vector2()
|
|
mouse.set((point.x * 2) - 1, -(point.y * 2) + 1)
|
|
this.raycaster.setFromCamera(mouse, this.camera)
|
|
|
|
return this.raycaster.intersectObjects([this.gridHelper], false)
|
|
}
|
|
|
|
/**
|
|
* 获取鼠标所在的 x,y,z 位置。
|
|
* 鼠标坐标是相对于 canvas 元素 (renderer.domElement) 元素的
|
|
*/
|
|
getClosestIntersection(e: MouseEvent) {
|
|
const _point = new THREE.Vector2()
|
|
_point.x = e.offsetX / this.renderer.domElement.offsetWidth
|
|
_point.y = e.offsetY / this.renderer.domElement.offsetHeight
|
|
|
|
const intersects = this.getIntersects(_point)
|
|
if (intersects && intersects.length > 2) {
|
|
const point = new THREE.Vector3(intersects[0].point.x, 0.1, intersects[1].point.z)
|
|
|
|
return calcPositionUseSnap(e, point)
|
|
}
|
|
return null
|
|
}
|
|
}
|
|
|
|
export interface ViewportState {
|
|
/**
|
|
* 当前楼层
|
|
*/
|
|
currentFloor: string
|
|
|
|
/**
|
|
* 是否准备完成
|
|
*/
|
|
isReady: boolean
|
|
|
|
/**
|
|
* 鼠标模式
|
|
*/
|
|
cursorMode: CursorMode,
|
|
|
|
/**
|
|
* 相机状态
|
|
*/
|
|
camera: {
|
|
position: { x: number, y: number, z: number },
|
|
rotation: { x: number, y: number, z: number }
|
|
}
|
|
|
|
/**
|
|
* 鼠标位置(归一化坐标)
|
|
*/
|
|
mouse: {
|
|
/**
|
|
* 鼠标在设计图上的坐标
|
|
*/
|
|
x: number,
|
|
z: number
|
|
}
|
|
}
|