From f045753d52552cd99d46775fbc837fc9f7c08e56 Mon Sep 17 00:00:00 2001 From: wang_yp <357754663@qq.com> Date: Tue, 15 Sep 2026 11:21:06 +0800 Subject: [PATCH] fix(3d) --- src/services/hikvisionWebSdk.ts | 150 ++- src/types/hikvision-web-sdk.d.ts | 1 + src/views/ops/pages/alert/tackle/index.vue | 5 +- .../components/CameraPreviewDialog.vue | 282 +++-- .../datacenter/three-machine-room/index.vue | 147 ++- .../three-machine-room/scene/ThreeMap.js | 1044 ++++++++++++++++- 6 files changed, 1456 insertions(+), 173 deletions(-) diff --git a/src/services/hikvisionWebSdk.ts b/src/services/hikvisionWebSdk.ts index c38185e..ab0bd65 100644 --- a/src/services/hikvisionWebSdk.ts +++ b/src/services/hikvisionWebSdk.ts @@ -16,9 +16,17 @@ export interface HikvisionPreviewConfig { /** 海康播放器运行事件。 */ export interface HikvisionPlayerCallbacks { onError?: (message: string) => void + onWindowError?: (windowIndex: number, message: string) => void onPerformanceLack?: () => void } +/** 多窗口实时预览启动结果。 */ +export interface HikvisionMultiPreviewResult { + started: number + failed: number + errors: string[] +} + const SDK_SCRIPT_PATHS = [ 'jsPlugin/jquery.min.js', 'encryption/AES.js', @@ -42,7 +50,7 @@ const PLAYER_ERROR_MESSAGES: Record = { let scriptsPromise: Promise | null = null let initializationPromise: Promise | null = null let initializedContainerId = '' -let activeDeviceIdentify = '' +const activeDeviceIdentifies = new Set() let runtimeCallbacks: HikvisionPlayerCallbacks = {} let previewOperation = 0 @@ -137,8 +145,10 @@ export async function initializeHikvisionPlayer(containerId: string, callbacks: const result = controller.I_InsertOBJECTPlugin(containerId) finish(result === 0 ? undefined : new Error('海康播放器挂载失败')) }, - cbPluginErrorHandler: (_windowIndex, errorCode) => { - runtimeCallbacks.onError?.(PLAYER_ERROR_MESSAGES[errorCode] || `播放器异常(错误码 ${errorCode})`) + cbPluginErrorHandler: (windowIndex, errorCode) => { + const message = PLAYER_ERROR_MESSAGES[errorCode] || `播放器异常(错误码 ${errorCode})` + runtimeCallbacks.onWindowError?.(windowIndex, message) + runtimeCallbacks.onError?.(message) }, cbPerformanceLack: () => runtimeCallbacks.onPerformanceLack?.(), cbSecretKeyError: () => runtimeCallbacks.onError?.('码流加密密钥错误'), @@ -153,15 +163,29 @@ export async function initializeHikvisionPlayer(containerId: string, callbacks: return initializationPromise } -/** 停止当前窗口中的实时预览。 */ -async function stopWindow(): Promise { +/** 根据画面数量返回海康 SDK 的分屏类型。 */ +function getWindowType(windowCount: number): number { + if (windowCount <= 1) return 1 + if (windowCount <= 4) return 2 + if (windowCount <= 9) return 3 + return 4 +} + +/** 按画面数量自动切换为单屏、四宫格、九宫格或十六宫格。 */ +async function changeWindowCount(windowCount: number): Promise { const controller = getController() - if (!controller.I_GetWindowStatus(0)) return + await controller.I_ChangeWndNum(getWindowType(Math.max(1, Math.min(16, windowCount)))) +} + +/** 停止指定窗口中的实时预览。 */ +async function stopWindow(windowIndex: number): Promise { + const controller = getController() + if (!controller.I_GetWindowStatus(windowIndex)) return await new Promise((resolve) => { const timeout = window.setTimeout(resolve, 3000) controller.I_Stop({ - iIndex: 0, + iIndex: windowIndex, success: () => { window.clearTimeout(timeout) resolve() @@ -174,22 +198,29 @@ async function stopWindow(): Promise { }) } -/** 停止预览并注销当前设备。 */ -export async function stopHikvisionPreview(): Promise { - previewOperation += 1 - if (!window.WebVideoCtrl || !initializedContainerId) return - await stopWindow() - if (activeDeviceIdentify) { - getController().I_Logout(activeDeviceIdentify) - activeDeviceIdentify = '' +/** 停止全部分屏;SDK 不支持批量停止时逐个窗口回退处理。 */ +async function stopAllWindows(): Promise { + const controller = getController() + try { + await controller.I_StopAll() + } catch (_error) { + await Promise.all(Array.from({ length: 16 }, (_item, index) => stopWindow(index))) } } -/** 登录摄像头并开始实时预览。 */ -export async function startHikvisionPreview(config: HikvisionPreviewConfig): Promise { +/** 停止全部预览并注销本次使用的设备。 */ +export async function stopHikvisionPreview(): Promise { + previewOperation += 1 + if (!window.WebVideoCtrl || !initializedContainerId) return + await stopAllWindows() + const controller = getController() + activeDeviceIdentifies.forEach((deviceIdentify) => controller.I_Logout(deviceIdentify)) + activeDeviceIdentifies.clear() +} + +/** 登录一台摄像机或录像机,并返回 SDK 设备标识。 */ +async function loginHikvisionDevice(config: HikvisionPreviewConfig, operation: number): Promise { const controller = getController() - await stopHikvisionPreview() - const operation = ++previewOperation const deviceIdentify = `${config.host}_${config.httpPort}` await new Promise((resolve, reject) => { @@ -222,7 +253,18 @@ export async function startHikvisionPreview(config: HikvisionPreviewConfig): Pro controller.I_Logout(deviceIdentify) throw new Error('摄像头播放已取消') } - activeDeviceIdentify = deviceIdentify + activeDeviceIdentifies.add(deviceIdentify) + return deviceIdentify +} + +/** 在指定 SDK 分屏中启动一路实时预览。 */ +async function startPreviewWindow( + config: HikvisionPreviewConfig, + deviceIdentify: string, + windowIndex: number, + operation: number +): Promise { + const controller = getController() try { await new Promise((resolve, reject) => { @@ -238,7 +280,7 @@ export async function startHikvisionPreview(config: HikvisionPreviewConfig): Pro error ? reject(error) : resolve() } controller.I_StartRealPlay(deviceIdentify, { - iWndIndex: 0, + iWndIndex: windowIndex, iRtspPort: config.rtspPort, iWSPort: config.webSocketPort, iStreamType: config.streamType, @@ -247,7 +289,7 @@ export async function startHikvisionPreview(config: HikvisionPreviewConfig): Pro bProxy: config.proxyEnabled, success: () => { if (settled || operation !== previewOperation) { - void stopWindow().then(() => controller.I_Logout(deviceIdentify)) + void stopWindow(windowIndex) if (settled) return finish(new Error('摄像头播放已取消')) return @@ -258,13 +300,73 @@ export async function startHikvisionPreview(config: HikvisionPreviewConfig): Pro }) }) if (operation !== previewOperation) throw new Error('摄像头播放已取消') - if (config.secretKey) await controller.I_SetSecretKey(config.secretKey, 0) + if (config.secretKey) await controller.I_SetSecretKey(config.secretKey, windowIndex) } catch (error) { - await stopHikvisionPreview() + await stopWindow(windowIndex) throw error } } +/** 自动分屏并同时启动多路海康实时预览。 */ +export async function startHikvisionPreviews(configs: HikvisionPreviewConfig[]): Promise { + const previewConfigs = configs.slice(0, 16) + if (!previewConfigs.length) throw new Error('没有可播放的摄像头') + await stopHikvisionPreview() + const operation = ++previewOperation + await changeWindowCount(previewConfigs.length) + + const uniqueDevices = new Map() + previewConfigs.forEach((config) => { + const deviceIdentify = `${config.host}_${config.httpPort}` + if (!uniqueDevices.has(deviceIdentify)) uniqueDevices.set(deviceIdentify, config) + }) + const loginResults = await Promise.allSettled( + Array.from(uniqueDevices.entries()).map(async ([deviceIdentify, config]) => { + await loginHikvisionDevice(config, operation) + return deviceIdentify + }) + ) + if (operation !== previewOperation) throw new Error('摄像头播放已取消') + + const loggedInDevices = new Set( + loginResults.filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled').map((result) => result.value) + ) + const loginErrors = new Map() + Array.from(uniqueDevices.keys()).forEach((deviceIdentify, index) => { + const result = loginResults[index] + if (result.status === 'rejected') { + loginErrors.set(deviceIdentify, result.reason instanceof Error ? result.reason.message : '摄像头登录失败') + } + }) + + const previewResults = await Promise.allSettled( + previewConfigs.map(async (config, windowIndex) => { + const deviceIdentify = `${config.host}_${config.httpPort}` + if (!loggedInDevices.has(deviceIdentify)) throw new Error(loginErrors.get(deviceIdentify) || '摄像头登录失败') + await startPreviewWindow(config, deviceIdentify, windowIndex, operation) + }) + ) + if (operation !== previewOperation) throw new Error('摄像头播放已取消') + + const errors = previewResults.flatMap((result, windowIndex) => { + if (result.status === 'fulfilled') return [] + const message = result.reason instanceof Error ? result.reason.message : '摄像头取流失败' + runtimeCallbacks.onWindowError?.(windowIndex, message) + return [`画面 ${windowIndex + 1}:${message}`] + }) + const started = previewResults.length - errors.length + if (!started) { + await stopHikvisionPreview() + throw new Error(errors[0] || '全部摄像头播放失败') + } + return { started, failed: errors.length, errors } +} + +/** 登录摄像头并在单窗口中开始实时预览。 */ +export async function startHikvisionPreview(config: HikvisionPreviewConfig): Promise { + await startHikvisionPreviews([config]) +} + /** 调整播放器画布尺寸。 */ export function resizeHikvisionPlayer(width: number, height: number): void { if (!window.WebVideoCtrl || !initializedContainerId || width <= 0 || height <= 0) return diff --git a/src/types/hikvision-web-sdk.d.ts b/src/types/hikvision-web-sdk.d.ts index 229bccb..4c40863 100644 --- a/src/types/hikvision-web-sdk.d.ts +++ b/src/types/hikvision-web-sdk.d.ts @@ -42,6 +42,7 @@ interface HikvisionWebVideoCtrl { ) => number | void I_Logout: (deviceIdentify: string) => number I_StartRealPlay: (deviceIdentify: string, options: HikvisionRealPlayOptions) => void + I_ChangeWndNum: (windowType: number) => Promise I_GetWindowStatus: (windowIndex: number) => { szDeviceIdentify?: string } | null I_Stop: (options: HikvisionSdkCallbacks & { iIndex?: number }) => void I_StopAll: () => Promise | void diff --git a/src/views/ops/pages/alert/tackle/index.vue b/src/views/ops/pages/alert/tackle/index.vue index 78a1311..c4da6ec 100644 --- a/src/views/ops/pages/alert/tackle/index.vue +++ b/src/views/ops/pages/alert/tackle/index.vue @@ -155,7 +155,7 @@ diff --git a/src/views/ops/pages/datacenter/three-machine-room/index.vue b/src/views/ops/pages/datacenter/three-machine-room/index.vue index b1a959f..849e5be 100644 --- a/src/views/ops/pages/datacenter/three-machine-room/index.vue +++ b/src/views/ops/pages/datacenter/three-machine-room/index.vue @@ -6,6 +6,7 @@ :data-room-id="roomId" :data-mapped-rack-count="sceneSummary.mappedRackCount" :data-rack-device-count="sceneSummary.equipmentCount" + :data-model-camera-count="sceneSummary.importedCameraCount" :data-active-alert-count="signalSummary.activeAlertCount" >
@@ -21,6 +22,12 @@ {{ exteriorDoorsOpen ? '关闭外墙门' : '打开外墙门' }} + + {{ aisleDoorsOpen ? '关闭过道门' : '打开过道门' }} + + + {{ powerCabinetDoorsOpen ? '关闭配电箱透明门' : '打开配电箱透明门' }} + {{ cabinetDoorsOpen ? '关闭当前机柜门' : '打开机柜并查看设备' }} @@ -45,7 +52,11 @@ 活动告警 {{ signalSummary.activeAlertCount }} - {{ insideAisle ? '当前斜向查看机柜正面;滚轮移动,拖拽转向' : '双击机柜门可开门聚焦,并查看柜内设备信息' }} + {{ + insideAisle + ? '当前为机柜过道第一视角;点击设备可近距离查看,双击柜内设备可抽出或归位' + : '双击门板可开合;机柜开门后双击设备可抽出或归位,点击摄像头播放监控、长按可拖动' + }}
@@ -114,7 +125,14 @@ :scene="roomScene" @success="handleRoomDevicePlacementSuccess" /> - + (null) const selectedRack = ref(null) const selectedDevice = ref(null) +const selectedCameraIndex = ref() const activeRack = ref(null) const rackPlacementVisible = ref(false) const rackLayoutVisible = ref(false) @@ -199,6 +239,7 @@ const sceneSummary = reactive({ rackCount: 0, mappedRackCount: 0, equipmentCount: 0, + importedCameraCount: 0, }) const signalSummary = reactive({ @@ -220,6 +261,7 @@ const roomId = resolveRoomId() const roomName = computed(() => roomScene.value?.room?.name || `机房 ${roomId}`) const layoutVersion = computed(() => Number(roomScene.value?.room?.layout_version) || 0) const activeRackDevices = computed(() => (Array.isArray(activeRack.value?.devices) ? activeRack.value.devices : [])) +const sceneCameraDevices = computed(() => getSceneCameraDevices()) const loadingText = computed(() => { if (modelLoading.value) { return modelProgress.value > 0 ? `3D 模型加载中 ${modelProgress.value}%` : '3D 模型加载中' @@ -395,10 +437,38 @@ function isCameraDevice(device: ThreeMachineRoomDevice): boolean { return ['camera', 'cctv', '摄像', '监控'].some((keyword) => searchableText.includes(keyword)) || categoryCode === 'ipc' } +/** 获取当前场景中可作为模型摄像头播放源的资产,并按资产 ID 保持稳定顺序。 */ +function getSceneCameraDevices(): ThreeMachineRoomDevice[] { + const deviceMap = new Map() + const devices = [ + ...(Array.isArray(roomScene.value?.room_devices) ? roomScene.value.room_devices : []), + ...(Array.isArray(roomScene.value?.racks) + ? roomScene.value.racks.flatMap((rack) => (Array.isArray(rack.devices) ? rack.devices : [])) + : []), + ] + devices.forEach((device) => { + const assetId = Number(device.asset_id) + if (isCameraDevice(device) && Number.isInteger(assetId) && assetId > 0 && !deviceMap.has(assetId)) { + deviceMap.set(assetId, device) + } + }) + return Array.from(deviceMap.values()).sort((left, right) => Number(left.asset_id) - Number(right.asset_id)) +} + +/** 点击模型内置摄像头时,按序关联场景摄像头资产并打开对应通道。 */ +function handleImportedCameraSelected(camera: ImportedCameraContext): void { + const cameraIndex = Number(camera.index) + if (!Number.isInteger(cameraIndex) || cameraIndex <= 0) return + selectedCameraIndex.value = cameraIndex + selectedDevice.value = sceneCameraDevices.value[cameraIndex - 1] || null + cameraPreviewVisible.value = true +} + /** 点击摄像头时打开预览,其他设备进入详情管理。 */ function handleDeviceSelected(device: ThreeMachineRoomDevice): void { selectedDevice.value = device if (isCameraDevice(device)) { + selectedCameraIndex.value = undefined cameraPreviewVisible.value = true return } @@ -410,6 +480,59 @@ function handleDeviceSelected(device: ThreeMachineRoomDevice): void { deviceManagementVisible.value = true } +/** 从设备告警弹窗进入告警受理处理页面。 */ +function handleAlertCalloutClick(context: DeviceAlertCalloutContext): void { + const query: Record = { + source: 'three-machine-room', + room_id: String(roomId), + asset_id: String(context.assetId), + } + const keyword = context.alert?.alert_name || context.alert?.summary + if (keyword) query.keyword = keyword + if (context.alert?.id) query.alert_record_id = String(context.alert.id) + void router.push({ path: '/alert/tackle', query }) +} + +/** 保存用户在场景中拖拽后的独立摄像头位置。 */ +async function handleRoomDeviceMoved(device: ThreeMachineRoomDevice, transform: ThreeMachineRoomTransform): Promise { + if (usingMockData.value) { + Message.warning('演示数据不支持保存摄像头位置') + return false + } + const assetId = Number(device.asset_id) + const expectedVersion = layoutVersion.value + if (!Number.isInteger(assetId) || assetId <= 0 || expectedVersion <= 0) { + Message.error('摄像头或布局版本无效,请刷新场景后重试') + return false + } + try { + const result = await saveThreeMachineRoomDevicePlacement(roomId, assetId, { + expectedVersion, + positionX: transform.position_x, + positionY: transform.position_y, + positionZ: transform.position_z, + rotationX: Number(transform.rotation_x) || 0, + rotationY: Number(transform.rotation_y) || 0, + rotationZ: Number(transform.rotation_z) || 0, + sceneWidth: Math.max(0.01, Number(device.size?.width) || 1), + sceneHeight: Math.max(0.01, Number(device.size?.height) || 1), + sceneDepth: Math.max(0.01, Number(device.size?.depth) || 1), + }) + device.transform = transform + if (roomScene.value?.room) roomScene.value.room.layout_version = result.layout_version + Message.success('摄像头位置已保存') + return true + } catch (error) { + Message.error(getErrorMessage(error, '摄像头位置保存失败,已恢复原位置')) + return false + } +} + +/** 提示模型内置摄像头已在当前场景中完成移动。 */ +function handleImportedCameraMoved(camera: { index: number }): void { + Message.success(`模型摄像头 ${camera.index} 位置已调整`) +} + /** 从摄像头预览切换到设备详情管理。 */ function handleOpenDeviceManagement(): void { cameraPreviewVisible.value = false @@ -459,10 +582,20 @@ async function initializeMachineRoom(): Promise { assetBasePath, onRoomSceneApplied: (summary: SceneSummary) => Object.assign(sceneSummary, summary), onDeviceSelected: (device: ThreeMachineRoomDevice) => handleDeviceSelected(device), + onAlertCalloutClick: (context: DeviceAlertCalloutContext) => handleAlertCalloutClick(context), onRackSelected: (rack: ThreeMachineRoomRack) => handleRackSelected(rack), onCabinetDoorChanged: (rack: ThreeMachineRoomRack | null) => { activeRack.value = rack }, + onDoorStateChanged: (states: { exteriorOpen: boolean; aisleOpen: boolean; powerCabinetOpen: boolean; cabinetOpen: boolean }) => { + exteriorDoorsOpen.value = states.exteriorOpen + aisleDoorsOpen.value = states.aisleOpen + powerCabinetDoorsOpen.value = states.powerCabinetOpen + cabinetDoorsOpen.value = states.cabinetOpen + }, + onRoomDeviceMoved: (device: ThreeMachineRoomDevice, transform: ThreeMachineRoomTransform) => handleRoomDeviceMoved(device, transform), + onImportedCameraSelected: (camera: ImportedCameraContext) => handleImportedCameraSelected(camera), + onImportedCameraMoved: (camera: { index: number }) => handleImportedCameraMoved(camera), onModelLoading: (loading: boolean) => { modelLoading.value = loading }, @@ -507,6 +640,16 @@ function toggleExteriorDoors(): void { if (machineRoomMap) exteriorDoorsOpen.value = machineRoomMap.toggleExteriorDoors() } +/** 切换封闭过道两端的双开门。 */ +function toggleAisleDoors(): void { + if (machineRoomMap) aisleDoorsOpen.value = machineRoomMap.toggleAisleDoors() +} + +/** 切换配电箱透明门并查看原始电气面板贴图。 */ +function togglePowerCabinetDoors(): void { + if (machineRoomMap) powerCabinetDoorsOpen.value = machineRoomMap.togglePowerCabinetDoors() +} + /** 打开有设备的机柜并聚焦,或关闭当前机柜门。 */ function toggleCabinetDoors(): void { if (machineRoomMap) cabinetDoorsOpen.value = machineRoomMap.toggleCabinetDoors() diff --git a/src/views/ops/pages/datacenter/three-machine-room/scene/ThreeMap.js b/src/views/ops/pages/datacenter/three-machine-room/scene/ThreeMap.js index c10f27e..50f5e06 100644 --- a/src/views/ops/pages/datacenter/three-machine-room/scene/ThreeMap.js +++ b/src/views/ops/pages/datacenter/three-machine-room/scene/ThreeMap.js @@ -11,6 +11,19 @@ import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader' import { TGALoader } from 'three/examples/jsm/loaders/TGALoader' import TWEEN from '@tweenjs/tween.js' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' //r100及以上 + +const ROOM_DEVICE_LONG_PRESS_DURATION = 300 +const ROOM_DEVICE_DRAG_MOVE_THRESHOLD = 6 +const IMPORTED_CAMERA_NAME_PATTERN = /^Obj3d66_8099876_1_91[3-8]$/ +const VIEW_OCCLUSION_UPDATE_INTERVAL = 60 +const VIEW_OCCLUDER_OPACITY = 0.055 +const RACK_EQUIPMENT_PULL_OUT_RATIO = 0.58 +const RACK_EQUIPMENT_SLIDE_DURATION = 360 +const EQUIPMENT_STATUS_LIGHT_COLORS = { + yellow: 0xffc928, + green: 0x20e6b2, + red: 0xff304f, +} // var OrbitControls = require('three-orbit-controls')(THREE) //r100 以下 export default class ThreeMap { constructor(props, dataJson) { @@ -32,6 +45,7 @@ export default class ThreeMap { this.dbclick = 0 this.mouseClick = new THREE.Vector2() this.raycaster = new THREE.Raycaster() + this.viewOcclusionRaycaster = new THREE.Raycaster() this.lastElement = null //存放最后一个设备 this.tipTimer = null //显示提示用的timer this.tooltip = null @@ -49,8 +63,14 @@ export default class ThreeMap { this.overviewView = null this.cameraViewTween = null this.viewMode = 'overview' + this.viewOcclusionCandidates = [] + this.viewOcclusionTarget = null + this.viewOccluderStates = new Map() + this._lastViewOcclusionUpdate = 0 this.outerWallMeshes = [] this.exteriorDoorPanels = [] + this.aisleDoorPanels = [] + this.powerCabinetDoorPanels = [] this.cabinetDoorPanels = [] this.cabinetDisplayPanels = [] this.cabinetRowPairs = [] @@ -60,6 +80,8 @@ export default class ThreeMap { this.rackPanelMap = new Map() this.wallTransparent = true this.serverEquipment = [] + this.equipmentStatusLights = [] + this.importedCameras = [] this.serverEquipmentVisible = true this.deviceSignals = new Map() this.deviceObservability = new Map() @@ -70,6 +92,8 @@ export default class ThreeMap { this.activeRackId = null this._singleClickTimer = null this._dbClickResetTimer = null + this._onMouseUp = null + this.roomDeviceDrag = null this.progressSuccess = 0 this.loadtimer = null @@ -138,8 +162,10 @@ export default class ThreeMap { this.InitData() //添加3D对象、事件等 this._onMouseDown = this.onDocumentMouseDown.bind(this) this._onMouseMove = this.onDocumentMouseMove.bind(this) + this._onMouseUp = this.onDocumentMouseUp.bind(this) this.renderer.domElement.addEventListener('mousedown', this._onMouseDown, false) this.renderer.domElement.addEventListener('mousemove', this._onMouseMove, false) + window.addEventListener('mouseup', this._onMouseUp, false) this._onResize = this.onWindowResize.bind(this) window.addEventListener('resize', this._onResize, false) } @@ -280,6 +306,9 @@ export default class ThreeMap { } this._rafId = requestAnimationFrame(this.render.bind(this)) TWEEN.update() + const timestamp = performance.now() + this.updateEquipmentStatusLights(timestamp) + this.updateViewOcclusion(timestamp) this.renderer.render(this.scene, this.camera) this.updateDeviceCallouts() } @@ -334,6 +363,10 @@ export default class ThreeMap { window.removeEventListener('resize', this._onResize, false) this._onResize = null } + if (this._onMouseUp) { + window.removeEventListener('mouseup', this._onMouseUp, false) + this._onMouseUp = null + } if (this.renderer && this.renderer.domElement) { if (this._onMouseDown) { @@ -362,6 +395,7 @@ export default class ThreeMap { clearTimeout(this._dbClickResetTimer) this._dbClickResetTimer = null } + this.clearViewOcclusionTarget() this.clearServerEquipment() if (this.tooltip && this.tooltip.parentNode === this.dom) { @@ -383,10 +417,12 @@ export default class ThreeMap { this.scene = null this.camera = null this.controls = null + this.viewOcclusionRaycaster = null this.importedModel = null this.importedModelBox = null this.importedModelOptions = null this.overviewView = null + this.viewOcclusionCandidates = [] this.roomSceneData = null this.roomSignalData = null this.deviceSignals.clear() @@ -395,7 +431,9 @@ export default class ThreeMap { this.cabinetDisplayPanels = [] this.cabinetRowPairs = [] this.cabinetShellMeshes = [] + this.importedCameras = [] this.activeRackId = null + this.roomDeviceDrag = null this._onMouseDown = null this._onMouseMove = null } @@ -796,6 +834,199 @@ export default class ThreeMap { }) } + /** 为机柜门克隆独立材质,避免淡化当前门板时影响同材质的其他机柜。 */ + prepareCabinetDoorMaterials(panel) { + panel.inspectionMaterialStates = [] + panel.meshes.forEach((mesh) => { + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + const clonedMaterials = materials.map((material) => material.clone()) + mesh.material = Array.isArray(mesh.material) ? clonedMaterials : clonedMaterials[0] + panel.inspectionMaterialStates.push( + clonedMaterials.map((material) => ({ + opacity: material.opacity, + transparent: material.transparent, + depthWrite: material.depthWrite, + })) + ) + }) + } + + /** 打开机柜后淡化伸入过道的门板,避免近距离观察设备时遮挡视线。 */ + setCabinetDoorInspectionAppearance(panel, open) { + if (!panel || !panel.inspectionMaterialStates) return + panel.meshes.forEach((mesh, meshIndex) => { + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + const states = panel.inspectionMaterialStates[meshIndex] || [] + materials.forEach((material, materialIndex) => { + const state = states[materialIndex] + if (!state) return + material.transparent = open ? true : state.transparent + material.opacity = open ? 0.12 : state.opacity + material.depthWrite = open ? false : state.depthWrite + material.needsUpdate = true + }) + }) + } + + /** 返回网格使用的全部材质名称。 */ + getMeshMaterialNames(mesh) { + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + return materials.map((material) => String((material && material.name) || '')) + } + + /** + * 配电柜内部没有单独建模,模型提供的 t1/t2/t5 正面贴图就是可查看内容。 + * 保留贴图原位,并在其外侧生成可开合的透明门,避免开门后只剩空壳。 + */ + createPowerCabinetDoorPanels(object, meshEntries, modelBox) { + const powerTextureMaterials = new Set(['15___Default', '16___Default', '18___Default']) + const modelSize = modelBox.getSize(new THREE.Vector3()) + const modelCenter = modelBox.getCenter(new THREE.Vector3()) + const minimumHeight = modelSize.y * 0.48 + const doorThickness = Math.max(modelSize.x, modelSize.z) * 0.002 + + return meshEntries + .filter((entry) => { + const size = entry.box.getSize(new THREE.Vector3()) + return size.y >= minimumHeight && this.getMeshMaterialNames(entry.mesh).some((name) => powerTextureMaterials.has(name)) + }) + .map((entry, index) => { + const box = entry.box + const size = box.getSize(new THREE.Vector3()) + const center = box.getCenter(new THREE.Vector3()) + const isXPlane = size.x <= size.z + const glassGeometry = new THREE.BoxGeometry( + isXPlane ? doorThickness : Math.max(size.x, doorThickness), + size.y, + isXPlane ? Math.max(size.z, doorThickness) : doorThickness + ) + const glassMaterial = new THREE.MeshPhongMaterial({ + color: 0x70cce8, + emissive: 0x092c3b, + transparent: true, + opacity: 0.22, + depthWrite: false, + side: THREE.DoubleSide, + }) + const glassDoor = new THREE.Mesh(glassGeometry, glassMaterial) + glassDoor.name = 'power_cabinet_glass_' + (index + 1) + glassDoor.position.copy(center) + object.add(glassDoor) + + let hingeX = box.min.x + let hingeZ = center.z + let openAngle = center.z >= modelCenter.z ? -0.52 * Math.PI : 0.52 * Math.PI + if (isXPlane) { + hingeX = center.x + hingeZ = box.min.z + openAngle = center.x >= modelCenter.x ? 0.52 * Math.PI : -0.52 * Math.PI + } + + const glassBox = new THREE.Box3().setFromObject(glassDoor) + const panel = this.createImportedDoorPanel( + object, + 'power_cabinet_door_' + (index + 1), + [{ mesh: glassDoor, box: glassBox }], + hingeX, + hingeZ, + openAngle, + 'power' + ) + if (!panel) return null + panel.box = box.clone() + panel.contentMeshes = [entry.mesh] + panel.contentEntry = entry + entry.mesh.userData.importedDoorPanel = panel + entry.mesh.userData.powerCabinetContent = true + return panel + }) + .filter(Boolean) + } + + /** 判断模型网格是否位于配电柜体积内,防止其被误识别为普通机柜门。 */ + isPowerCabinetMeshEntry(entry, powerPanels, modelSize) { + const entryCenter = entry.box.getCenter(new THREE.Vector3()) + return powerPanels.some((panel) => { + const panelCenter = panel.box.getCenter(new THREE.Vector3()) + const panelSize = panel.box.getSize(new THREE.Vector3()) + const isXPlane = panelSize.x <= panelSize.z + const alongNormal = isXPlane ? Math.abs(entryCenter.x - panelCenter.x) : Math.abs(entryCenter.z - panelCenter.z) + const alongWidth = isXPlane ? Math.abs(entryCenter.z - panelCenter.z) : Math.abs(entryCenter.x - panelCenter.x) + const normalLimit = (isXPlane ? modelSize.x : modelSize.z) * 0.095 + const widthLimit = (isXPlane ? panelSize.z : panelSize.x) * 0.62 + Math.max(modelSize.x, modelSize.z) * 0.012 + return alongNormal <= normalLimit && alongWidth <= widthLimit + }) + } + + /** 识别封闭冷/热过道两端的双开门及门把、边框组件。 */ + createAisleDoorPanels(object, meshEntries, modelBox) { + const mainMaterial = 'Mat3d66_11807706_10_61673' + const componentMaterials = new Set([mainMaterial, 'Mat3d66_11807706_7_95731', 'Mat3d66_11807706_8_21117', 'Mat3d66_11807706_9_83349']) + const modelSize = modelBox.getSize(new THREE.Vector3()) + const modelCenter = modelBox.getCenter(new THREE.Vector3()) + const mainEntries = meshEntries.filter((entry) => { + const size = entry.box.getSize(new THREE.Vector3()) + return ( + this.getMeshMaterialNames(entry.mesh).includes(mainMaterial) && + size.y > modelSize.y * 0.6 && + size.x > modelSize.x * 0.035 && + size.x < modelSize.x * 0.08 && + size.z < modelSize.z * 0.01 + ) + }) + const groupsByZ = [] + mainEntries.forEach((entry) => { + const center = entry.box.getCenter(new THREE.Vector3()) + let group = groupsByZ.find((item) => Math.abs(item.centerZ - center.z) < modelSize.z * 0.025) + if (!group) { + group = { centerZ: center.z, entries: [] } + groupsByZ.push(group) + } + group.entries.push(entry) + }) + + const assignedComponents = new Set() + const panels = [] + groupsByZ.forEach((group) => { + const orderedDoors = group.entries + .slice() + .sort((a, b) => a.box.getCenter(new THREE.Vector3()).x - b.box.getCenter(new THREE.Vector3()).x) + orderedDoors.forEach((mainEntry, doorIndex) => { + const mainCenter = mainEntry.box.getCenter(new THREE.Vector3()) + const componentEntries = meshEntries.filter((entry) => { + if (assignedComponents.has(entry.mesh)) return false + const center = entry.box.getCenter(new THREE.Vector3()) + return ( + this.getMeshMaterialNames(entry.mesh).some((name) => componentMaterials.has(name)) && + center.x >= mainEntry.box.min.x - modelSize.x * 0.001 && + center.x <= mainEntry.box.max.x + modelSize.x * 0.001 && + Math.abs(center.z - mainCenter.z) <= modelSize.z * 0.012 && + entry.box.max.y >= mainEntry.box.min.y - modelSize.y * 0.02 && + entry.box.min.y <= mainEntry.box.max.y + modelSize.y * 0.02 + ) + }) + componentEntries.forEach((entry) => assignedComponents.add(entry.mesh)) + if (!componentEntries.some((entry) => entry.mesh === mainEntry.mesh)) componentEntries.push(mainEntry) + + const isLeftDoor = doorIndex === 0 + const isFrontDoor = group.centerZ >= modelCenter.z + const hingeX = isLeftDoor ? mainEntry.box.min.x : mainEntry.box.max.x + const openAngle = isFrontDoor ? (isLeftDoor ? -0.5 * Math.PI : 0.5 * Math.PI) : isLeftDoor ? 0.5 * Math.PI : -0.5 * Math.PI + const panel = this.createImportedDoorPanel( + object, + 'aisle_door_' + (panels.length + 1), + componentEntries, + hingeX, + mainCenter.z, + openAngle, + 'aisle' + ) + if (panel) panels.push(panel) + }) + }) + return panels + } + createExteriorDoorPanels(object, meshEntries) { const meshByName = {} meshEntries.forEach((entry) => { @@ -804,19 +1035,28 @@ export default class ThreeMap { const definitions = [ { name: 'exterior_door_left', - names: ['Obj3d66_7859414_31_920', 'Obj3d66_7859414_32_804', 'Obj3d66_7859414_33_785', 'Obj3d66_7859414_34_583'], + names: [ + 'Obj3d66_7859414_31_920', + 'Obj3d66_7859414_32_804', + 'Obj3d66_7859414_33_785', + 'Obj3d66_7859414_34_583', + 'Obj3d66_7859414_31_921', + 'Obj3d66_7859414_32_805', + 'Obj3d66_7859414_33_786', + 'Obj3d66_7859414_34_584', + ], hinge: 'max', angle: 0.5 * Math.PI, }, { name: 'exterior_door_center_left', - names: ['Obj3d66_10994759_47_778', 'Obj3d66_10994759_48_388'], + names: ['Obj3d66_10994759_47_778', 'Obj3d66_10994759_48_388', 'Obj3d66_10994759_46_286', 'Obj3d66_10994759_45_485'], hinge: 'min', angle: -0.5 * Math.PI, }, { name: 'exterior_door_center_right', - names: ['Obj3d66_10994759_46_285', 'Obj3d66_10994759_45_484'], + names: ['Obj3d66_10994759_46_285', 'Obj3d66_10994759_45_484', 'Obj3d66_10994759_47_779', 'Obj3d66_10994759_48_389'], hinge: 'max', angle: 0.5 * Math.PI, }, @@ -847,7 +1087,7 @@ export default class ThreeMap { return panels } - createCabinetDoorPanels(object, meshEntries, modelBox) { + createCabinetDoorPanels(object, meshEntries, modelBox, powerPanels) { const modelSize = new THREE.Vector3() modelBox.getSize(modelSize) const candidates = meshEntries.filter((entry) => { @@ -855,6 +1095,7 @@ export default class ThreeMap { entry.box.getSize(size) return ( entry.mesh.name.indexOf('Obj3d66_11807706_') === 0 && + !this.isPowerCabinetMeshEntry(entry, powerPanels, modelSize) && size.y > modelSize.y * 0.42 && size.x < modelSize.x * 0.015 && size.z > modelSize.z * 0.025 && @@ -889,7 +1130,7 @@ export default class ThreeMap { return clusters .map((cluster, index) => { const box = this.getCombinedBox(cluster.entries) - return this.createImportedDoorPanel( + const panel = this.createImportedDoorPanel( object, 'cabinet_door_' + (index + 1), cluster.entries, @@ -898,6 +1139,8 @@ export default class ThreeMap { 0.5 * Math.PI, 'cabinet' ) + if (panel) this.prepareCabinetDoorMaterials(panel) + return panel }) .filter(Boolean) } @@ -1146,6 +1389,7 @@ export default class ThreeMap { getDeviceStatusInfo(signal) { const status = signal && signal.status ? String(signal.status) : 'unknown' const statusMap = { + normal: { label: '运行正常', color: '#20E6B2', hex: 0x20e6b2 }, healthy: { label: '运行正常', color: '#20E6B2', hex: 0x20e6b2 }, warning: { label: '设备预警', color: '#FFB429', hex: 0xffb429 }, abnormal: { label: '设备异常', color: '#FF4D67', hex: 0xff4d67 }, @@ -1164,6 +1408,69 @@ export default class ThreeMap { } } + /** 在机柜设备正面创建黄色、绿色两颗状态灯。 */ + createEquipmentStatusLights(equipment, dimensions, frontDirection, frontAxis) { + const lightRadius = Math.max(2, Math.min(dimensions.height * 0.16, dimensions.width * 0.025)) + const frontOffset = frontDirection * (dimensions.depth / 2 + lightRadius * 0.35) + const lightPositions = [dimensions.width * 0.32, dimensions.width * 0.22] + const lights = lightPositions.map((surfacePosition, index) => { + const color = index === 0 ? EQUIPMENT_STATUS_LIGHT_COLORS.yellow : EQUIPMENT_STATUS_LIGHT_COLORS.green + const light = new THREE.Mesh( + new THREE.SphereGeometry(lightRadius, 12, 8), + new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 1, depthWrite: false }) + ) + light.name = equipment.name + '_status_light_' + (index + 1) + if (frontAxis === 'z') { + light.position.set(surfacePosition, dimensions.height * 0.12, frontOffset) + } else { + light.position.set(frontOffset, dimensions.height * 0.12, surfacePosition) + } + light.raycast = function () {} + light.userData.statusLightIndex = index + light.userData.statusLightFault = false + equipment.add(light) + this.equipmentStatusLights.push(light) + return light + }) + equipment.userData.statusLights = lights + return lights + } + + /** 判断设备信号是否表示故障。 */ + isEquipmentFaultSignal(signal) { + const status = String((signal && signal.status) || '').toLowerCase() + return ['abnormal', 'fault', 'failed', 'failure', 'offline', 'critical', 'error'].includes(status) + } + + /** 根据设备状态切换两颗指示灯的颜色。 */ + applyEquipmentStatusLights(equipment, signal) { + const lights = (equipment && equipment.userData && equipment.userData.statusLights) || [] + const isFault = this.isEquipmentFaultSignal(signal) + lights.forEach((light, index) => { + const color = isFault + ? EQUIPMENT_STATUS_LIGHT_COLORS.red + : index === 0 + ? EQUIPMENT_STATUS_LIGHT_COLORS.yellow + : EQUIPMENT_STATUS_LIGHT_COLORS.green + light.material.color.setHex(color) + light.userData.statusLightFault = isFault + }) + } + + /** 驱动正常状态的交替呼吸闪烁和故障状态的同步红色快闪。 */ + updateEquipmentStatusLights(timestamp) { + this.equipmentStatusLights.forEach((light) => { + if (!light || !light.material) return + const isFault = Boolean(light.userData.statusLightFault) + const phase = isFault ? 0 : Number(light.userData.statusLightIndex) * Math.PI + const speed = isFault ? 0.014 : 0.004 + const wave = (Math.sin(timestamp * speed + phase) + 1) / 2 + light.material.opacity = isFault ? (wave > 0.48 ? 1 : 0.18) : 0.55 + wave * 0.45 + const scale = isFault ? 0.92 + wave * 0.22 : 0.96 + wave * 0.08 + light.scale.setScalar(scale) + }) + } + getMetricLabel(name) { const metricName = String(name || '') const labels = { @@ -1288,15 +1595,37 @@ export default class ThreeMap { card.setAttribute('role', 'button') card.setAttribute('tabindex', '0') card.setAttribute('title', '点击查看设备实时指标和告警详情') - const selectEquipment = (event) => { + const handleCalloutAction = (event) => { event.stopPropagation() + const signal = this.deviceSignals.get(assetId) || equipment.userData.signal || null + const observabilityState = this.deviceObservability.get(assetId) + const observability = observabilityState && observabilityState.data + const alertGroups = observability && Array.isArray(observability.alerts) ? observability.alerts : signal && signal.alerts + const alerts = this.flattenDeviceAlerts(alertGroups) + const signalStatus = String((signal && signal.status) || '').toLowerCase() + const hasAlert = + alerts.length > 0 || + Number(signal && signal.active_alert_count) > 0 || + signalStatus === 'warning' || + signalStatus === 'abnormal' || + signalStatus === 'fault' || + signalStatus === 'offline' + if (hasAlert && typeof this.props.onAlertCalloutClick === 'function') { + this.props.onAlertCalloutClick({ + assetId: assetId, + device: equipment.userData.sceneDevice || null, + signal: signal, + alert: alerts[0] || null, + }) + return + } this.selectDeviceEquipment(equipment) } - card.addEventListener('click', selectEquipment) + card.addEventListener('click', handleCalloutAction) card.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() - selectEquipment(event) + handleCalloutAction(event) } }) const header = document.createElement('div') @@ -1347,10 +1676,23 @@ export default class ThreeMap { const observabilityState = this.deviceObservability.get(callout.assetId) const statusInfo = this.getDeviceStatusInfo(signal) const alertCount = Number(signal.active_alert_count) || 0 + const signalStatus = String(signal.status || '').toLowerCase() + const observability = observabilityState && observabilityState.data + const alertGroups = observability && Array.isArray(observability.alerts) ? observability.alerts : signal.alerts + const alerts = this.flattenDeviceAlerts(alertGroups) + const hasAlert = + alerts.length > 0 || + alertCount > 0 || + signalStatus === 'warning' || + signalStatus === 'abnormal' || + signalStatus === 'fault' || + signalStatus === 'offline' callout.title.textContent = device.asset_name || device.asset_code || '设备 ' + callout.assetId callout.status.textContent = alertCount > 0 ? statusInfo.label + ' · ' + alertCount : statusInfo.label callout.status.style.color = statusInfo.color + callout.card.setAttribute('title', hasAlert ? '点击进入告警受理处理页面' : '点击查看设备实时指标和告警详情') callout.element.setAttribute('data-status', statusInfo.status) + callout.element.setAttribute('data-has-alert', hasAlert ? 'true' : 'false') callout.element.style.setProperty('--device-status-color', statusInfo.color) while (callout.body.firstChild) { @@ -1445,6 +1787,7 @@ export default class ThreeMap { const statusInfo = this.getDeviceStatusInfo(signal) equipment.userData.signal = signal equipment.userData.signalStatus = statusInfo.status + this.applyEquipmentStatusLights(equipment, signal) const materials = Array.isArray(equipment.material) ? equipment.material : [equipment.material] materials.forEach((material, index) => { if (material && material.color) { @@ -1500,6 +1843,9 @@ export default class ThreeMap { if (!equipment) { return } + if (this.viewMode === 'aisle' || this.viewMode === 'cabinet') { + this.focusDeviceEquipment(equipment) + } const device = equipment.userData.sceneDevice if (device && typeof this.props.onDeviceSelected === 'function') { this.props.onDeviceSelected(device, equipment.userData.signal || null) @@ -1515,6 +1861,9 @@ export default class ThreeMap { getEquipmentAppearance(device, index) { const categoryCode = String(device.category_code || '').toLowerCase() + if (this.isCameraSceneDevice(device)) { + return { textureIndex: 0, color: 0x6ee7ff, type: 'camera' } + } if (categoryCode.indexOf('switch') >= 0 || categoryCode.indexOf('network') >= 0) { return { textureIndex: 1, color: 0x78d9ff, type: 'switch' } } @@ -1571,8 +1920,8 @@ export default class ThreeMap { const rackBottom = panel.box.min.y + panelSize.y * 0.09 const serverDepth = Math.max(modelSize.x * 0.004, panelSize.x * 2) const serverWidth = panelSize.z * 0.78 - const exteriorOffset = serverDepth * 0.62 - const centerX = panelCenter.x - panel.interiorDirection * exteriorOffset + const interiorInset = serverDepth * 0.56 + const centerX = panelCenter.x + panel.interiorDirection * interiorInset rack.devices.forEach((device, deviceIndex) => { const startUnit = Math.max(1, Number(device.unit_start) || 1) @@ -1622,7 +1971,22 @@ export default class ThreeMap { server.userData.startUnit = startUnit server.userData.occupiedUnits = occupiedUnits server.userData.sceneDevice = device + server.userData.cabinetRestPosition = server.position.clone() + server.userData.cabinetExtendedPosition = server.position + .clone() + .add(new THREE.Vector3(-panel.interiorDirection * serverDepth * RACK_EQUIPMENT_PULL_OUT_RATIO, 0, 0)) + server.userData.cabinetEquipmentExtended = false + server.userData.cabinetEquipmentTween = null server.userData.baseMaterialColors = materials.map((material) => (material && material.color ? material.color.getHex() : 0xffffff)) + this.createEquipmentStatusLights( + server, + { + depth: serverDepth, + height: equipmentHeight, + width: serverWidth, + }, + -panel.interiorDirection + ) object.add(server) equipment.push(server) this.objects.push(server) @@ -1683,7 +2047,24 @@ export default class ThreeMap { mesh.userData.equipmentType = appearance.type mesh.userData.sceneDevice = device mesh.userData.placementType = 'room' + mesh.userData.draggable = this.isCameraSceneDevice(device) + mesh.userData.roomPlacement = { + scaleX: scaleX, + scaleY: scaleY, + scaleZ: scaleZ, + modelBox: modelBox.clone(), + } mesh.userData.baseMaterialColors = [material.color.getHex()] + this.createEquipmentStatusLights( + mesh, + { + depth: depth, + height: height, + width: width, + }, + 1, + 'z' + ) object.add(mesh) equipment.push(mesh) this.objects.push(mesh) @@ -1692,12 +2073,186 @@ export default class ThreeMap { return equipment } + /** 判断独立设备是否为摄像头。 */ + isCameraSceneDevice(device) { + const categoryCode = String((device && device.category_code) || '').toLowerCase() + const searchableText = [categoryCode, device && device.category_name, device && device.asset_name, device && device.asset_code] + .filter(Boolean) + .join(' ') + .toLowerCase() + return categoryCode === 'ipc' || ['camera', 'cctv', '摄像', '监控'].some((keyword) => searchableText.includes(keyword)) + } + + /** 将鼠标位置投射到指定世界平面。 */ + getPointerPlaneIntersection(event, plane) { + if (!this.camera || !this.renderer || !this.renderer.domElement) return null + const rect = this.renderer.domElement.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) return null + this.mouseClick.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 + this.mouseClick.y = -((event.clientY - rect.top) / rect.height) * 2 + 1 + this.raycaster.setFromCamera(this.mouseClick, this.camera) + return this.raycaster.ray.intersectPlane(plane, new THREE.Vector3()) + } + + /** 摄像头按下后进入待判定状态,由按住时长与位移共同区分点击和拖动。 */ + beginRoomDeviceDrag(event, equipment) { + const isImportedCamera = Boolean(equipment && equipment.userData && equipment.userData.importedCamera) + const isPlacedCamera = Boolean(equipment && equipment.userData && equipment.userData.placementType === 'room') + if ( + !equipment || + !equipment.userData || + (!isPlacedCamera && !isImportedCamera) || + !equipment.userData.draggable || + !this.importedModel + ) { + return false + } + const worldPosition = equipment.getWorldPosition(new THREE.Vector3()) + const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -worldPosition.y) + const worldPoint = this.getPointerPlaneIntersection(event, plane) + if (!worldPoint) return false + const localPoint = this.importedModel.worldToLocal(worldPoint.clone()) + this.roomDeviceDrag = { + equipment: equipment, + plane: plane, + startClientX: event.clientX, + startClientY: event.clientY, + lastClientX: event.clientX, + lastClientY: event.clientY, + pressedAt: performance.now(), + startPosition: equipment.position.clone(), + offset: equipment.position.clone().sub(localPoint), + maxDistance: 0, + activated: false, + moved: false, + } + if (this.controls) this.controls.enabled = false + if (this.renderer) this.renderer.domElement.style.cursor = 'grab' + event.preventDefault() + event.stopPropagation() + return true + } + + /** 长按达到阈值且移动足够距离后拖动摄像头,并限制在模型边界内。 */ + updateRoomDeviceDrag(event) { + const drag = this.roomDeviceDrag + if (!drag || !this.importedModel) return false + const distance = Math.hypot(event.clientX - drag.startClientX, event.clientY - drag.startClientY) + drag.lastClientX = event.clientX + drag.lastClientY = event.clientY + drag.maxDistance = Math.max(drag.maxDistance, distance) + if (!drag.activated) { + const pressDuration = performance.now() - drag.pressedAt + if (pressDuration < ROOM_DEVICE_LONG_PRESS_DURATION || drag.maxDistance < ROOM_DEVICE_DRAG_MOVE_THRESHOLD) { + return true + } + drag.activated = true + if (this.renderer) this.renderer.domElement.style.cursor = 'grabbing' + } + drag.moved = true + const worldPoint = this.getPointerPlaneIntersection(event, drag.plane) + if (!worldPoint) return true + const localPoint = this.importedModel.worldToLocal(worldPoint.clone()).add(drag.offset) + const placement = drag.equipment.userData.roomPlacement + const modelBox = placement && placement.modelBox + const geometryBox = this.getGeometryBox(drag.equipment) + if (modelBox && geometryBox) { + const geometrySize = geometryBox.getSize(new THREE.Vector3()) + localPoint.x = THREE.MathUtils.clamp(localPoint.x, modelBox.min.x + geometrySize.x / 2, modelBox.max.x - geometrySize.x / 2) + localPoint.z = THREE.MathUtils.clamp(localPoint.z, modelBox.min.z + geometrySize.z / 2, modelBox.max.z - geometrySize.z / 2) + } + drag.equipment.position.x = localPoint.x + drag.equipment.position.z = localPoint.z + event.preventDefault() + event.stopPropagation() + return true + } + + /** 完成设备交互:短按且未移动为点击,长按并移动为拖拽。 */ + finishRoomDeviceDrag(event) { + const drag = this.roomDeviceDrag + if (!drag) return + if (event && !drag.activated) { + this.updateRoomDeviceDrag(event) + } + this.roomDeviceDrag = null + if (this.controls) this.controls.enabled = true + if (this.renderer) this.renderer.domElement.style.cursor = 'grab' + const pressDuration = performance.now() - drag.pressedAt + const isClick = pressDuration < ROOM_DEVICE_LONG_PRESS_DURATION && drag.maxDistance < ROOM_DEVICE_DRAG_MOVE_THRESHOLD + if (isClick) { + if (drag.equipment.userData.importedCamera) { + if (typeof this.props.onImportedCameraSelected === 'function') { + this.props.onImportedCameraSelected({ + index: drag.equipment.userData.importedCameraIndex, + name: drag.equipment.name, + }) + } + } else { + this.selectDeviceEquipment(drag.equipment) + } + return + } + if (!drag.activated || !drag.moved) return + + const equipment = drag.equipment + if (equipment.userData.importedCamera) { + if (typeof this.props.onImportedCameraMoved === 'function') { + this.props.onImportedCameraMoved({ + index: equipment.userData.importedCameraIndex, + name: equipment.name, + position: { + x: equipment.position.x, + y: equipment.position.y, + z: equipment.position.z, + }, + }) + } + return + } + const device = equipment.userData.sceneDevice + const placement = equipment.userData.roomPlacement + const modelBox = placement && placement.modelBox + if (!device || !placement || !modelBox) { + equipment.position.copy(drag.startPosition) + return + } + const transform = Object.assign({}, device.transform || {}, { + position_x: (equipment.position.x - modelBox.min.x) / placement.scaleX, + position_y: Number((device.transform && device.transform.position_y) || 0), + position_z: (equipment.position.z - modelBox.min.z) / placement.scaleZ, + }) + if (typeof this.props.onRoomDeviceMoved !== 'function') { + equipment.position.copy(drag.startPosition) + return + } + Promise.resolve(this.props.onRoomDeviceMoved(device, transform)) + .then((saved) => { + if (saved === false) { + equipment.position.copy(drag.startPosition) + return + } + device.transform = transform + }) + .catch(() => { + equipment.position.copy(drag.startPosition) + }) + } + clearServerEquipment() { + if (this.viewOcclusionTarget && this.viewOcclusionTarget.object && this.serverEquipment.includes(this.viewOcclusionTarget.object)) { + this.clearViewOcclusionTarget() + } this.clearDeviceCallouts() const geometries = new Set() const materials = new Set() const textures = new Set() this.serverEquipment.forEach((equipment) => { + const equipmentTween = equipment.userData && equipment.userData.cabinetEquipmentTween + if (equipmentTween) { + equipmentTween.stop() + equipment.userData.cabinetEquipmentTween = null + } if (equipment.parent) { equipment.parent.remove(equipment) } @@ -1733,6 +2288,7 @@ export default class ThreeMap { }) textures.forEach((texture) => texture.dispose()) this.serverEquipment = [] + this.equipmentStatusLights = [] } setRoomScene(sceneData) { @@ -1765,6 +2321,7 @@ export default class ThreeMap { rackCount: sceneData && Array.isArray(sceneData.racks) ? sceneData.racks.length : 0, mappedRackCount: this.rackPanelMap.size, equipmentCount: this.serverEquipment.length, + importedCameraCount: this.importedCameras.length, } if (typeof this.props.onRoomSceneApplied === 'function') { this.props.onRoomSceneApplied(renderSummary) @@ -1806,24 +2363,66 @@ export default class ThreeMap { }) } + /** 将模型内置的 6 个摄像头网格转换为可独立拖动的对象。 */ + prepareImportedCameras(meshEntries, modelBox) { + this.importedCameras = meshEntries + .filter((entry) => IMPORTED_CAMERA_NAME_PATTERN.test(entry.mesh.name)) + .sort((a, b) => a.mesh.name.localeCompare(b.mesh.name)) + .map((entry, index) => { + const mesh = entry.mesh + const geometryCenter = entry.box.getCenter(new THREE.Vector3()) + const geometry = mesh.geometry.clone() + geometry.translate(-geometryCenter.x, -geometryCenter.y, -geometryCenter.z) + geometry.computeBoundingBox() + geometry.computeBoundingSphere() + mesh.geometry = geometry + mesh.position.add(geometryCenter) + const cameraSize = geometry.boundingBox.getSize(new THREE.Vector3()) + const hitRadius = Math.max(cameraSize.x, cameraSize.y, cameraSize.z) * 0.85 + const hitArea = new THREE.Mesh( + new THREE.SphereGeometry(hitRadius, 12, 8), + new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }) + ) + hitArea.name = 'imported_camera_hit_area_' + (index + 1) + mesh.add(hitArea) + mesh.userData.importedCamera = true + mesh.userData.importedCameraIndex = index + 1 + mesh.userData.placementType = 'imported-camera' + mesh.userData.draggable = true + mesh.userData.roomPlacement = { + modelBox: modelBox.clone(), + } + mesh.userData.originalModelPosition = mesh.position.clone() + return mesh + }) + return this.importedCameras + } + setupImportedModelInteraction(object) { this.importedModel = object const modelInfo = this.getMeshListAndBox(object) + this.viewOcclusionCandidates = modelInfo.meshes.map((entry) => entry.mesh) + this.prepareImportedCameras(modelInfo.meshes, modelInfo.box) this.exteriorDoorPanels = this.createExteriorDoorPanels(object, modelInfo.meshes) - this.cabinetDoorPanels = this.createCabinetDoorPanels(object, modelInfo.meshes, modelInfo.box) + this.aisleDoorPanels = this.createAisleDoorPanels(object, modelInfo.meshes, modelInfo.box) + this.powerCabinetDoorPanels = this.createPowerCabinetDoorPanels(object, modelInfo.meshes, modelInfo.box) + this.cabinetDoorPanels = this.createCabinetDoorPanels(object, modelInfo.meshes, modelInfo.box, this.powerCabinetDoorPanels) this.assignCabinetInteriorDirections(this.cabinetDoorPanels, modelInfo.box) this.assignCabinetShells(this.cabinetDoorPanels, modelInfo.meshes, modelInfo.box) this.serverEquipment = [] const doorMeshes = new Set() - this.exteriorDoorPanels.concat(this.cabinetDoorPanels).forEach((panel) => { - panel.meshes.forEach((mesh) => doorMeshes.add(mesh)) + this.exteriorDoorPanels.concat(this.aisleDoorPanels, this.powerCabinetDoorPanels, this.cabinetDoorPanels).forEach((panel) => { + panel.meshes.concat(panel.contentMeshes || []).forEach((mesh) => doorMeshes.add(mesh)) }) this.outerWallMeshes = this.prepareOuterWallMeshes(modelInfo.meshes, modelInfo.box, doorMeshes) this.setWallsTransparent(this.wallTransparent) object.userData.interactionSummary = { outerWalls: this.outerWallMeshes.length, exteriorDoors: this.exteriorDoorPanels.length, + aisleDoors: this.aisleDoorPanels.length, + powerCabinetDoors: this.powerCabinetDoorPanels.length, cabinetDoors: this.cabinetDoorPanels.length, + importedCameras: this.importedCameras.length, servers: this.serverEquipment.length, mappedRacks: 0, } @@ -1835,6 +2434,7 @@ export default class ThreeMap { setWallsTransparent(transparent, opacity) { const shouldBeTransparent = transparent !== false const wallOpacity = opacity == null ? 0.14 : opacity + this.restoreViewOccluders() this.outerWallMeshes.forEach((mesh) => { const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] const originalState = mesh.userData.wallMaterialState || [] @@ -1847,9 +2447,181 @@ export default class ThreeMap { }) }) this.wallTransparent = shouldBeTransparent + this._lastViewOcclusionUpdate = 0 return this.wallTransparent } + /** 为可能被动态淡化的网格克隆材质,避免共享材质导致其他物体一起透明。 */ + prepareViewOccluderMaterials(mesh) { + if (!mesh || !mesh.material || mesh.userData.viewOcclusionMaterialsPrepared) return + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + const clonedMaterials = materials.map((material) => material.clone()) + mesh.material = Array.isArray(mesh.material) ? clonedMaterials : clonedMaterials[0] + mesh.userData.viewOcclusionMaterialsPrepared = true + } + + /** 恢复一个动态遮挡网格进入透明状态前的材质。 */ + restoreViewOccluder(mesh) { + const states = this.viewOccluderStates.get(mesh) + if (!states || !mesh || !mesh.material) return + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + materials.forEach((material, index) => { + const state = states[index] + if (!state) return + material.transparent = state.transparent + material.opacity = state.opacity + material.depthWrite = state.depthWrite + material.needsUpdate = true + }) + this.viewOccluderStates.delete(mesh) + } + + /** 恢复当前视线中已经淡化的全部遮挡物。 */ + restoreViewOccluders() { + Array.from(this.viewOccluderStates.keys()).forEach((mesh) => this.restoreViewOccluder(mesh)) + } + + /** 淡化一个位于相机和查看目标之间的网格。 */ + fadeViewOccluder(mesh) { + if (!mesh || !mesh.material || this.viewOccluderStates.has(mesh)) return + this.prepareViewOccluderMaterials(mesh) + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + const states = materials.map((material) => ({ + opacity: material.opacity, + transparent: material.transparent, + depthWrite: material.depthWrite, + })) + this.viewOccluderStates.set(mesh, states) + materials.forEach((material, index) => { + material.transparent = true + material.opacity = Math.min(states[index].opacity, VIEW_OCCLUDER_OPACITY) + material.depthWrite = false + material.needsUpdate = true + }) + } + + /** 设置近景视角需要持续保持可见的目标。 */ + setViewOcclusionTarget(options) { + const targetOptions = options || {} + this.restoreViewOccluders() + this.viewOcclusionTarget = { + object: targetOptions.object || null, + point: targetOptions.point ? targetOptions.point.clone() : null, + radius: Math.max(0, Number(targetOptions.radius) || 0), + excludedMeshes: new Set(targetOptions.excludedMeshes || []), + } + this._lastViewOcclusionUpdate = 0 + } + + /** 退出近景查看并恢复被淡化的网格。 */ + clearViewOcclusionTarget() { + this.restoreViewOccluders() + this.viewOcclusionTarget = null + this._lastViewOcclusionUpdate = 0 + } + + /** 判断网格及其父级当前是否可见。 */ + isViewOcclusionMeshVisible(mesh) { + let current = mesh + while (current) { + if (current.visible === false) return false + if (current === this.importedModel) break + current = current.parent + } + return true + } + + /** 判断射线命中的网格是否属于目标自身或显式排除对象。 */ + isExcludedViewOcclusionMesh(mesh, target) { + let current = mesh + while (current) { + if (current === target.object || target.excludedMeshes.has(current)) return true + if (current === this.importedModel) break + current = current.parent + } + return false + } + + /** 获取目标中心及覆盖目标主体的射线采样半径。 */ + getViewOcclusionTargetInfo() { + const target = this.viewOcclusionTarget + if (!target) return null + if (target.object) { + if (!target.object.parent || !this.isViewOcclusionMeshVisible(target.object)) return null + target.object.updateMatrixWorld(true) + const box = new THREE.Box3().setFromObject(target.object) + if (box.isEmpty()) return null + const size = box.getSize(new THREE.Vector3()) + return { + center: box.getCenter(new THREE.Vector3()), + radius: target.radius || Math.max(size.x, size.y, size.z) * 0.28, + } + } + return target.point ? { center: target.point.clone(), radius: target.radius } : null + } + + /** + * 定时从相机向查看目标发射多条射线,只淡化真正位于两者之间的模型网格。 + * 多点采样可避免仅目标中心露出、主体边缘仍被墙体遮挡。 + */ + updateViewOcclusion(timestamp, forceUpdate) { + const supportsOcclusion = this.viewMode === 'cabinet' || this.viewMode === 'power-cabinet' + if (!supportsOcclusion || !this.viewOcclusionTarget || !this.camera || !this.importedModel) { + if (this.viewOccluderStates.size > 0) this.restoreViewOccluders() + return + } + if (!forceUpdate && timestamp - this._lastViewOcclusionUpdate < VIEW_OCCLUSION_UPDATE_INTERVAL) return + this._lastViewOcclusionUpdate = timestamp + + const targetInfo = this.getViewOcclusionTargetInfo() + if (!targetInfo) { + this.clearViewOcclusionTarget() + return + } + this.importedModel.updateMatrixWorld(true) + this.camera.updateMatrixWorld(true) + + const cameraPosition = this.camera.getWorldPosition(new THREE.Vector3()) + const targetDistance = cameraPosition.distanceTo(targetInfo.center) + if (targetDistance <= 0.001) { + this.restoreViewOccluders() + return + } + const sampleRadius = Math.min(targetInfo.radius, targetDistance * 0.12) + const cameraRight = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 0).normalize().multiplyScalar(sampleRadius) + const cameraUp = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 1).normalize().multiplyScalar(sampleRadius) + const samplePoints = [targetInfo.center] + if (sampleRadius > 0.001) { + samplePoints.push( + targetInfo.center.clone().add(cameraRight), + targetInfo.center.clone().sub(cameraRight), + targetInfo.center.clone().add(cameraUp), + targetInfo.center.clone().sub(cameraUp) + ) + } + + const target = this.viewOcclusionTarget + const candidates = this.viewOcclusionCandidates.filter( + (mesh) => + mesh && mesh.isMesh && mesh.material && this.isViewOcclusionMeshVisible(mesh) && !this.isExcludedViewOcclusionMesh(mesh, target) + ) + const occludingMeshes = new Set() + samplePoints.forEach((samplePoint) => { + const direction = samplePoint.clone().sub(cameraPosition) + const distance = direction.length() + if (distance <= 0.001) return + this.viewOcclusionRaycaster.set(cameraPosition, direction.normalize()) + this.viewOcclusionRaycaster.near = 0 + this.viewOcclusionRaycaster.far = Math.max(0, distance - Math.max(0.5, sampleRadius * 0.08)) + this.viewOcclusionRaycaster.intersectObjects(candidates, false).forEach((intersection) => occludingMeshes.add(intersection.object)) + }) + + Array.from(this.viewOccluderStates.keys()).forEach((mesh) => { + if (!occludingMeshes.has(mesh)) this.restoreViewOccluder(mesh) + }) + occludingMeshes.forEach((mesh) => this.fadeViewOccluder(mesh)) + } + setServerEquipmentVisible(visible) { this.serverEquipmentVisible = visible !== false this.serverEquipment.forEach((server) => { @@ -1869,6 +2641,69 @@ export default class ThreeMap { }) } + /** 将柜内设备平滑切换到归位或抽出位置。 */ + setRackEquipmentExtended(equipment, extended, animate) { + if (!equipment || !equipment.userData || !equipment.userData.cabinetRestPosition) return false + const panel = this.cabinetDoorPanels.find((item) => item.name === equipment.userData.cabinetDoor) + const shouldExtend = extended === true && Boolean(panel && panel.isOpen) + const targetPosition = shouldExtend ? equipment.userData.cabinetExtendedPosition : equipment.userData.cabinetRestPosition + if (!targetPosition) return false + + const activeTween = equipment.userData.cabinetEquipmentTween + if (activeTween) activeTween.stop() + equipment.userData.cabinetEquipmentTween = null + equipment.userData.cabinetEquipmentExtended = shouldExtend + + if (animate === false) { + equipment.position.copy(targetPosition) + return shouldExtend + } + + const state = { + x: equipment.position.x, + y: equipment.position.y, + z: equipment.position.z, + } + const tween = new TWEEN.Tween(state) + .to( + { + x: targetPosition.x, + y: targetPosition.y, + z: targetPosition.z, + }, + RACK_EQUIPMENT_SLIDE_DURATION + ) + .easing(TWEEN.Easing.Quadratic.InOut) + .onUpdate(() => { + equipment.position.set(state.x, state.y, state.z) + }) + .onComplete(() => { + equipment.position.copy(targetPosition) + if (equipment.userData.cabinetEquipmentTween === tween) { + equipment.userData.cabinetEquipmentTween = null + } + }) + .start() + equipment.userData.cabinetEquipmentTween = tween + return shouldExtend + } + + /** 双击柜内设备时在抽出与归位状态之间切换。 */ + toggleRackEquipmentExtension(equipment) { + if (!equipment || !equipment.userData || !equipment.userData.cabinetDoor) return false + return this.setRackEquipmentExtended(equipment, !equipment.userData.cabinetEquipmentExtended, true) + } + + /** 关闭机柜门前立即将该机柜内的全部设备归位。 */ + retractCabinetEquipment(panel) { + if (!panel) return + this.serverEquipment.forEach((equipment) => { + if (equipment.userData.cabinetDoor === panel.name) { + this.setRackEquipmentExtended(equipment, false, false) + } + }) + } + /** 将模型局部坐标转换为场景世界坐标。 */ getImportedModelWorldPoint(point) { this.importedModel.updateMatrixWorld(true) @@ -1889,35 +2724,130 @@ export default class ThreeMap { this.setActiveCabinetPanel(null) } - /** 将相机移动到门板正前方,清楚展示机柜内部。 */ + /** 获取指定机柜门相邻过道的中心 X 坐标。 */ + getCabinetAisleCenterX(panel, panelCenter) { + const rowPair = this.cabinetRowPairs[panel.cabinetRowIndex] + if (!rowPair || !this.importedModelBox) { + return panelCenter.x - panel.interiorDirection * panel.box.getSize(new THREE.Vector3()).y * 0.24 + } + let oppositeBoundaryX + if (panel.cabinetSide === 'left') { + const previousRow = this.cabinetRowPairs[panel.cabinetRowIndex - 1] + oppositeBoundaryX = previousRow ? previousRow.rightLevel.centerX : this.importedModelBox.min.x + } else { + const nextRow = this.cabinetRowPairs[panel.cabinetRowIndex + 1] + oppositeBoundaryX = nextRow ? nextRow.leftLevel.centerX : this.importedModelBox.max.x + } + return (panelCenter.x + oppositeBoundaryX) / 2 + } + + /** 将相机放在门板外侧过道中,并正对柜内设备面。 */ focusCabinetPanel(panel) { - if (!panel || !this.importedModel || !this.camera || !this.camera.isPerspectiveCamera || !this.controls) { + if (!panel || !this.importedModel || !this.importedModelBox || !this.camera || !this.camera.isPerspectiveCamera || !this.controls) { return false } this.importedModel.updateMatrixWorld(true) const panelSize = panel.box.getSize(new THREE.Vector3()) const panelCenter = panel.box.getCenter(new THREE.Vector3()) const worldScale = this.importedModel.getWorldScale(new THREE.Vector3()) - const rotationMatrix = new THREE.Matrix4().extractRotation(this.importedModel.matrixWorld) - const exteriorDirection = new THREE.Vector3(-panel.interiorDirection, 0, 0).applyMatrix4(rotationMatrix).normalize() - const interiorDirection = exteriorDirection.clone().multiplyScalar(-1) - const worldCenter = this.getImportedModelWorldPoint(panelCenter) + const aisleCenterX = this.getCabinetAisleCenterX(panel, panelCenter) + const localPosition = panelCenter.clone() + localPosition.x = aisleCenterX + localPosition.y += panelSize.y * 0.02 + const position = this.getImportedModelWorldPoint(localPosition) + const target = this.getImportedModelWorldPoint(panelCenter) const worldHeight = panelSize.y * Math.abs(worldScale.y) - const position = worldCenter.clone().add(exteriorDirection.multiplyScalar(worldHeight * 1.52)) - position.y += worldHeight * 0.06 - const target = worldCenter.clone().add(interiorDirection.multiplyScalar(worldHeight * 0.035)) - this.camera.fov = 46 + this.camera.fov = 72 this.camera.near = Math.max(0.1, worldHeight / 1000) this.camera.far = Math.max(this.camera.far, worldHeight * 30) this.camera.updateProjectionMatrix() this.controls.minDistance = Math.max(1, worldHeight * 0.08) this.controls.maxDistance = worldHeight * 5 - this.moveCameraTo(position, target, 720) + this.setViewOcclusionTarget({ point: target, radius: worldHeight * 0.22 }) this.viewMode = 'cabinet' + this.moveCameraTo(position, target, 720) return true } + /** 在过道安全位置正对所选设备,避免镜头进入柜体或被门板遮挡。 */ + focusDeviceEquipment(equipment) { + if (!equipment || !this.importedModel || !this.camera || !this.camera.isPerspectiveCamera || !this.controls) return false + const panel = this.cabinetDoorPanels.find((item) => item.name === equipment.userData.cabinetDoor) + if (!panel || !panel.isOpen) return false + + this.importedModel.updateMatrixWorld(true) + equipment.updateMatrixWorld(true) + const panelCenter = panel.box.getCenter(new THREE.Vector3()) + const localPosition = equipment.position.clone() + localPosition.x = this.getCabinetAisleCenterX(panel, panelCenter) + const position = this.getImportedModelWorldPoint(localPosition) + const equipmentBox = new THREE.Box3().setFromObject(equipment) + const target = equipmentBox.getCenter(new THREE.Vector3()) + const equipmentSize = equipmentBox.getSize(new THREE.Vector3()) + const initialDistance = Math.max(1, position.distanceTo(target)) + const equipmentClearance = Math.max(2, equipmentSize.x * 0.72, equipmentSize.z * 0.08) + + this.camera.fov = 56 + this.camera.near = Math.max(0.02, Math.min(equipmentSize.x, equipmentSize.y, equipmentSize.z) / 120) + this.camera.updateProjectionMatrix() + this.controls.minDistance = Math.min(initialDistance * 0.82, equipmentClearance) + this.controls.maxDistance = Math.max(initialDistance * 3, equipmentClearance * 4) + this.setViewOcclusionTarget({ object: equipment, radius: Math.max(equipmentSize.x, equipmentSize.y) * 0.32 }) + this.viewMode = 'cabinet' + this.moveCameraTo(position, target, 560) + this.setActiveCabinetPanel(panel) + return true + } + + /** 将镜头移动到配电柜正面,透过透明门查看原始电气面板贴图。 */ + focusPowerCabinetPanel(panel) { + if (!panel || !this.importedModel || !this.importedModelBox || !this.camera || !this.camera.isPerspectiveCamera || !this.controls) { + return false + } + this.importedModel.updateMatrixWorld(true) + const panelSize = panel.box.getSize(new THREE.Vector3()) + const panelCenter = panel.box.getCenter(new THREE.Vector3()) + const modelCenter = this.importedModelBox.getCenter(new THREE.Vector3()) + const worldScale = this.importedModel.getWorldScale(new THREE.Vector3()) + const rotationMatrix = new THREE.Matrix4().extractRotation(this.importedModel.matrixWorld) + const isXPlane = panelSize.x <= panelSize.z + const localViewingDirection = isXPlane + ? new THREE.Vector3(modelCenter.x >= panelCenter.x ? -1 : 1, 0, 0) + : new THREE.Vector3(0, 0, modelCenter.z >= panelCenter.z ? -1 : 1) + const viewingDirection = localViewingDirection.applyMatrix4(rotationMatrix).normalize() + const worldCenter = this.getImportedModelWorldPoint(panelCenter) + const worldHeight = panelSize.y * Math.abs(worldScale.y) + const position = worldCenter.clone().add(viewingDirection.multiplyScalar(worldHeight * 1.32)) + + this.camera.fov = 48 + this.camera.near = Math.max(0.1, worldHeight / 1000) + this.camera.far = Math.max(this.camera.far, worldHeight * 30) + this.camera.updateProjectionMatrix() + this.controls.minDistance = Math.max(1, worldHeight * 0.08) + this.controls.maxDistance = worldHeight * 4 + this.setViewOcclusionTarget({ + point: worldCenter, + radius: worldHeight * 0.22, + excludedMeshes: panel.meshes.concat(panel.contentMeshes || []), + }) + this.viewMode = 'power-cabinet' + this.moveCameraTo(position, worldCenter, 720) + this.setActiveCabinetPanel(null) + return true + } + + /** 将各类门的开关状态同步给页面按钮。 */ + notifyDoorStates() { + if (typeof this.props.onDoorStateChanged !== 'function') return + this.props.onDoorStateChanged({ + exteriorOpen: this.exteriorDoorPanels.some((panel) => panel.isOpen), + aisleOpen: this.aisleDoorPanels.some((panel) => panel.isOpen), + powerCabinetOpen: this.powerCabinetDoorPanels.some((panel) => panel.isOpen), + cabinetOpen: this.cabinetDoorPanels.some((panel) => panel.isOpen), + }) + } + setImportedDoorState(panel, open) { if (!panel) { return false @@ -1929,11 +2859,19 @@ export default class ThreeMap { if (panel.tween) { panel.tween.stop() } + if (this.viewOccluderStates.size > 0) { + this.restoreViewOccluders() + this._lastViewOcclusionUpdate = 0 + } const wasOpen = panel.isOpen + if (panel.type === 'cabinet' && !shouldOpen) { + this.retractCabinetEquipment(panel) + } panel.isOpen = shouldOpen if (panel.type === 'exterior') { this.setExteriorDoorAppearance(panel, shouldOpen) } else if (panel.type === 'cabinet' && wasOpen !== shouldOpen) { + this.setCabinetDoorInspectionAppearance(panel, shouldOpen) this.setCabinetShellOpen(panel, shouldOpen) this.updateCabinetEquipmentVisibility(panel) } @@ -1944,6 +2882,7 @@ export default class ThreeMap { panel.tween = null }) .start() + this.notifyDoorStates() return shouldOpen } @@ -1955,7 +2894,12 @@ export default class ThreeMap { this.focusCabinetPanel(panel) } else if (this.activeRackId && panel.rackId !== undefined && String(panel.rackId) === this.activeRackId) { this.setActiveCabinetPanel(null) + if (this.viewMode === 'cabinet') this.setOverviewView() } + } else if (panel && panel.type === 'power' && shouldOpen) { + this.focusPowerCabinetPanel(panel) + } else if (panel && panel.type === 'power' && this.viewMode === 'power-cabinet') { + this.setOverviewView() } return shouldOpen } @@ -1964,11 +2908,29 @@ export default class ThreeMap { if (this.exteriorDoorPanels.length === 0) { return false } - const shouldOpen = typeof forceOpen === 'boolean' ? forceOpen : !this.exteriorDoorPanels.every((panel) => panel.isOpen) + const shouldOpen = typeof forceOpen === 'boolean' ? forceOpen : !this.exteriorDoorPanels.some((panel) => panel.isOpen) this.exteriorDoorPanels.forEach((panel) => this.setImportedDoorState(panel, shouldOpen)) return shouldOpen } + /** 切换封闭过道两端的双开门。 */ + toggleAisleDoors(forceOpen) { + if (this.aisleDoorPanels.length === 0) return false + const shouldOpen = typeof forceOpen === 'boolean' ? forceOpen : !this.aisleDoorPanels.some((panel) => panel.isOpen) + this.aisleDoorPanels.forEach((panel) => this.setImportedDoorState(panel, shouldOpen)) + return shouldOpen + } + + /** 切换配电柜透明门;打开时聚焦第一块原始电气贴图。 */ + togglePowerCabinetDoors(forceOpen) { + if (this.powerCabinetDoorPanels.length === 0) return false + const shouldOpen = typeof forceOpen === 'boolean' ? forceOpen : !this.powerCabinetDoorPanels.some((panel) => panel.isOpen) + this.powerCabinetDoorPanels.forEach((panel) => this.setImportedDoorState(panel, shouldOpen)) + if (shouldOpen) this.focusPowerCabinetPanel(this.powerCabinetDoorPanels[0]) + else if (this.viewMode === 'power-cabinet') this.setOverviewView() + return shouldOpen + } + toggleCabinetDoors(forceOpen) { if (this.cabinetDoorPanels.length === 0) { return false @@ -1992,6 +2954,7 @@ export default class ThreeMap { } else { this.cabinetDoorPanels.forEach((panel) => this.setImportedDoorState(panel, false)) this.setActiveCabinetPanel(null) + if (this.viewMode === 'cabinet') this.setOverviewView() } return shouldOpen } @@ -2042,6 +3005,7 @@ export default class ThreeMap { } fitCameraToObject(object, options) { + this.clearViewOcclusionTarget() var box = new THREE.Box3().setFromObject(object) var size = new THREE.Vector3() var center = new THREE.Vector3() @@ -2158,6 +3122,7 @@ export default class ThreeMap { this.camera.updateProjectionMatrix() this.controls.minDistance = this.overviewView.minDistance this.controls.maxDistance = this.overviewView.maxDistance + this.clearViewOcclusionTarget() this.moveCameraTo(this.overviewView.position, this.overviewView.target, 760) this.setActiveCabinetPanel(null) this.viewMode = 'overview' @@ -2224,8 +3189,9 @@ export default class ThreeMap { this.camera.near = Math.max(0.1, worldSize.y / 2000) this.camera.far = Math.max(worldSize.x, worldSize.y, worldSize.z) * 12 this.camera.updateProjectionMatrix() - this.controls.minDistance = Math.max(1, worldSize.y * 0.01) + this.controls.minDistance = Math.max(4, worldSize.y * 0.055) this.controls.maxDistance = Math.max(worldSize.x, worldSize.z) * 2 + this.clearViewOcclusionTarget() this.moveCameraTo(position, target, 820) this.setActiveCabinetPanel(null) this.viewMode = 'aisle' @@ -3015,7 +3981,7 @@ export default class ThreeMap { let current = object while (current && current !== this.scene) { const userData = current.userData || {} - if (userData.sceneDevice || userData.rackData || userData.importedDoorPanel) { + if (userData.sceneDevice || userData.rackData || userData.importedDoorPanel || userData.importedCamera) { return current } current = current.parent @@ -3052,6 +4018,8 @@ export default class ThreeMap { const _this = this if (selected.userData && selected.userData.importedDoorPanel) { this.toggleImportedDoor(selected.userData.importedDoorPanel) + } else if (selected.userData && selected.userData.sceneDevice && selected.userData.cabinetDoor) { + this.toggleRackEquipmentExtension(selected) } else if (this.eventList != null && this.eventList.dbclick != null && this.eventList.dbclick.length > 0) { this.eventList.dbclick.forEach(function (_obj) { if ('string' == typeof _obj.obj_name) { @@ -3070,6 +4038,9 @@ export default class ThreeMap { //鼠标按下事件 onDocumentMouseDown(event) { const selected = this.getIntersectedObject(event) + if (this.beginRoomDeviceDrag(event, selected)) { + return + } this.dbclick++ event.preventDefault() if (this._dbClickResetTimer) { @@ -3109,6 +4080,12 @@ export default class ThreeMap { } }, 240) } + + /** 鼠标释放时完成摄像头位置保存或触发轻点预览。 */ + onDocumentMouseUp(event) { + this.finishRoomDeviceDrag(event) + } + openCloseDoor(obj, x, y, z, info) { console.log(obj) var doorstate = 'close' @@ -3193,13 +4170,22 @@ export default class ThreeMap { } //鼠标移动 onDocumentMouseMove(event) { + if (this.updateRoomDeviceDrag(event)) { + return + } let _this = this var currentElement = null const selected = this.getIntersectedObject(event) this.hoveredEquipment = selected && selected.userData && selected.userData.sceneDevice ? selected : null if (selected) { - if (selected.userData && (selected.userData.rackData || selected.userData.importedDoorPanel || selected.userData.sceneDevice)) { - this.renderer.domElement.style.cursor = 'pointer' + if ( + selected.userData && + (selected.userData.rackData || + selected.userData.importedDoorPanel || + selected.userData.sceneDevice || + selected.userData.importedCamera) + ) { + this.renderer.domElement.style.cursor = selected.userData.draggable ? 'grab' : 'pointer' } else { this.renderer.domElement.style.cursor = 'default' }