EG/ui/widgets.py
2025-08-21 11:23:47 +08:00

586 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
自定义Qt部件模块
包含所有自定义的Qt界面组件
- NewProjectDialog: 新建项目对话框
- CustomPanda3DWidget: 自定义Panda3D显示部件
- CustomFileView: 自定义文件浏览器
- CustomTreeWidget: 自定义场景树部件
"""
import os
import re
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QGroupBox, QHBoxLayout,
QLineEdit, QPushButton, QLabel, QDialogButtonBox,
QTreeView, QTreeWidget, QTreeWidgetItem, QWidget,
QFileDialog, QMessageBox, QAbstractItemView)
from PyQt5.QtCore import Qt, QUrl
from PyQt5.QtGui import QDrag, QPainter, QPixmap, QPen, QBrush
from PyQt5.sip import wrapinstance
from QPanda3D.QPanda3DWidget import QPanda3DWidget
class NewProjectDialog(QDialog):
"""新建项目对话框"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("新建项目")
self.setMinimumWidth(500)
# 创建布局
layout = QVBoxLayout(self)
# 创建路径选择部分
pathGroup = QGroupBox("项目路径")
pathLayout = QHBoxLayout()
self.pathEdit = QLineEdit()
self.pathEdit.setReadOnly(True)
browseButton = QPushButton("浏览...")
browseButton.clicked.connect(self.browsePath)
pathLayout.addWidget(self.pathEdit)
pathLayout.addWidget(browseButton)
pathGroup.setLayout(pathLayout)
layout.addWidget(pathGroup)
# 创建项目名称部分
nameGroup = QGroupBox("项目名称")
nameLayout = QVBoxLayout()
self.nameEdit = QLineEdit()
self.nameEdit.setText("新项目")
self.nameEdit.selectAll()
nameLayout.addWidget(self.nameEdit)
# 添加提示标签
self.tipLabel = QLabel("项目名称只能包含字母、数字、下划线、中划线和中文")
self.tipLabel.setStyleSheet("color: gray;")
nameLayout.addWidget(self.tipLabel)
nameGroup.setLayout(nameLayout)
layout.addWidget(nameGroup)
# 添加按钮
buttonBox = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel
)
buttonBox.accepted.connect(self.validate)
buttonBox.rejected.connect(self.reject)
layout.addWidget(buttonBox)
# 存储结果
self.projectPath = ""
self.projectName = ""
def browsePath(self):
"""浏览选择项目路径"""
path = QFileDialog.getExistingDirectory(self, "选择项目路径")
if path:
self.pathEdit.setText(path)
def validate(self):
"""验证输入并关闭对话框"""
# 获取并验证路径
self.projectPath = self.pathEdit.text()
if not self.projectPath:
QMessageBox.warning(self, "错误", "请选择项目路径!")
return
# 获取并验证项目名称
self.projectName = self.nameEdit.text().strip()
if not self.projectName:
QMessageBox.warning(self, "错误", "请输入项目名称!")
return
# 验证项目名称格式
if not re.match(r'^[a-zA-Z0-9_\-\u4e00-\u9fa5]+$', self.projectName):
QMessageBox.warning(self, "错误",
"项目名称只能包含字母、数字、下划线、中划线和中文!")
return
# 检查项目是否已存在
full_path = os.path.join(self.projectPath, self.projectName)
if os.path.exists(full_path):
QMessageBox.warning(self, "错误", "项目已存在!")
return
self.accept()
class CustomPanda3DWidget(QPanda3DWidget):
"""自定义Panda3D显示部件"""
def __init__(self, world, parent=None):
if parent is None:
parent = wrapinstance(0, QWidget)
super().__init__(world, parent)
self.world = world
self.setFocusPolicy(Qt.StrongFocus) # 允许接收键盘焦点
self.setAcceptDrops(True) # 允许接收拖放
self.setMouseTracking(True) # 启用鼠标追踪
# 让world引用这个widget以便获取准确的尺寸
if hasattr(world, 'setQtWidget'):
world.setQtWidget(self)
def getActualSize(self):
"""获取Qt部件的实际渲染尺寸"""
return (self.width(), self.height())
def dragEnterEvent(self, event):
"""处理拖拽进入事件"""
# 检查是否是文件拖拽
if event.mimeData().hasUrls():
# 检查是否包含支持的模型文件
for url in event.mimeData().urls():
filepath = url.toLocalFile()
if filepath.lower().endswith(('.egg', '.bam', '.obj', '.fbx', '.gltf', '.glb')):
event.acceptProposedAction()
return
event.ignore()
def dragMoveEvent(self, event):
"""处理拖拽移动事件"""
if event.mimeData().hasUrls():
event.acceptProposedAction()
else:
event.ignore()
def dropEvent(self, event):
"""处理拖放事件"""
if event.mimeData().hasUrls():
for url in event.mimeData().urls():
filepath = url.toLocalFile()
if filepath.lower().endswith(('.egg', '.bam', '.obj', '.fbx', '.gltf', '.glb')):
self.world.importModel(filepath)
#self.world.addAnimationPanel(None,filepath)
event.acceptProposedAction()
else:
event.ignore()
def wheelEvent(self, event):
"""处理滚轮事件"""
if event.angleDelta().y() > 0:
# 滚轮向前滚动
self.world.wheelForward()
else:
# 滚轮向后滚动
self.world.wheelBackward()
event.accept()
def mousePressEvent(self, event):
"""处理 Qt 鼠标按下事件"""
if event.button() == Qt.LeftButton:
self.world.mousePressEventLeft({
'x': event.x(),
'y': event.y()
})
elif event.button() == Qt.RightButton:
self.world.mousePressEventRight({
'x': event.x(),
'y': event.y()
})
elif event.button() == Qt.MiddleButton: # 添加滑轮按下事件处理
self.world.mousePressEventMiddle({
'x': event.x(),
'y': event.y()
})
event.accept()
def mouseReleaseEvent(self, event):
"""处理 Qt 鼠标释放事件"""
if event.button() == Qt.LeftButton:
self.world.mouseReleaseEventLeft({
'x': event.x(),
'y': event.y()
})
elif event.button() == Qt.RightButton:
self.world.mouseReleaseEventRight({
'x': event.x(),
'y': event.y()
})
elif event.button() == Qt.MiddleButton: # 添加滑轮释放事件处理
self.world.mouseReleaseEventMiddle({
'x': event.x(),
'y': event.y()
})
event.accept()
def mouseMoveEvent(self, event):
"""处理 Qt 鼠标移动事件"""
self.world.mouseMoveEvent({
'x': event.x(),
'y': event.y()
})
event.accept()
def cleanup(self):
"""清理Panda3D资源"""
try:
print("🧹 清理CustomPanda3DWidget资源...")
# 清理world资源
if hasattr(self, 'world') and self.world:
# 如果world有cleanup方法调用它
if hasattr(self.world, 'cleanup'):
self.world.cleanup()
# 清理world引用
self.world = None
# 调用父类的清理方法(如果存在)
if hasattr(super(), 'cleanup'):
super().cleanup()
print("✅ CustomPanda3DWidget资源清理完成")
except Exception as e:
print(f"⚠️ 清理CustomPanda3DWidget资源时出错: {e}")
class CustomFileView(QTreeView):
"""自定义文件浏览器"""
def __init__(self, world, parent=None):
if parent is None:
parent = wrapinstance(0, QWidget)
super().__init__(parent)
self.world = world
self.setDragEnabled(True) # 启用拖拽
self.setSelectionMode(QTreeView.ExtendedSelection) # 允许多选
self.setDragDropMode(QTreeView.DragOnly) # 只允许拖出,不允许拖入
def startDrag(self, supportedActions):
"""开始拖拽操作"""
# 获取选中的文件
indexes = self.selectedIndexes()
if not indexes:
return
# 只处理文件名列
indexes = [idx for idx in indexes if idx.column() == 0]
# 创建 MIME 数据
mimeData = self.model().mimeData(indexes)
# 检查是否包含支持的模型文件
urls = []
for index in indexes:
filepath = self.model().filePath(index)
if filepath.lower().endswith(('.egg', '.bam', '.obj', '.fbx', '.gltf', '.glb')):
urls.append(QUrl.fromLocalFile(filepath))
if not urls:
return
# 设置 URL 列表
mimeData.setUrls(urls)
# 创建拖拽对象
drag = QDrag(self)
drag.setMimeData(mimeData)
# 设置拖拽图标(可选)
pixmap = QPixmap(32, 32)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
painter.drawText(pixmap.rect(), Qt.AlignCenter, str(len(urls)))
painter.end()
drag.setPixmap(pixmap)
# 执行拖拽
drag.exec_(supportedActions)
def mouseDoubleClickEvent(self, event):
"""处理双击事件"""
index = self.indexAt(event.pos())
if index.isValid():
model = self.model()
filepath = model.filePath(index)
# 检查是否是模型文件
if filepath.lower().endswith(('.egg', '.bam', '.obj', '.fbx', '.gltf', '.glb')):
self.world.importModel(filepath)
else:
print("不支持的文件类型")
super().mouseDoubleClickEvent(event)
class CustomTreeWidget(QTreeWidget):
"""自定义场景树部件"""
def __init__(self, world, parent=None):
if parent is None:
parent = wrapinstance(0, QWidget)
super().__init__(parent)
self.world = world
self.setupUI() # 初始化界面
self.setupDragDrop() # 设置拖拽功能
def setupUI(self):
"""初始化UI设置"""
self.setHeaderHidden(True)
# 启用多选和拖拽
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setDropIndicatorShown(True) # 启用拖放指示线
def setupDragDrop(self):
"""设置拖拽功能"""
# 使用自定义拖拽模式
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) # 或者使用 DragDrop
self.setDefaultDropAction(Qt.DropAction.MoveAction)
self.setDragEnabled(True)
self.setAcceptDrops(True)
def dropEvent(self, event):
dragged_item = self.currentItem()
target_item = self.itemAt(event.pos())
if not dragged_item or not target_item:
event.ignore()
return
if not self.isValidParentChild(dragged_item, target_item):
event.ignore()
return
dragged_node = dragged_item.data(0, Qt.UserRole)
target_node = target_item.data(0, Qt.UserRole)
if not dragged_node or not target_node:
event.ignore()
return
# # 检查是否是有效的父子关系
# if self.isValidParentChild(dragged_item, target_item):
# # 保存当前的世界坐标
# world_pos = dragged_node.getPos(self.world.render)
#
# # 更新场景图中的父子关系
# dragged_node.wrtReparentTo(target_node)
#
# # 接受拖放事件,更新树形控件
# super().dropEvent(event)
#
# #self.world.property_panel.updateNodeVisibilityAfterDrag(dragged_item)
# # 更新属性面板
# self.world.updatePropertyPanel(dragged_item)
# self.world.property_panel._syncEffectiveVisibility(dragged_node)
print(f"dragged_node: {dragged_node}, target_node: {target_node}")
# 记录拖拽前的父节点
old_parent_item = dragged_item.parent()
old_parent_node = old_parent_item.data(0, Qt.UserRole) if old_parent_item else None
# 执行Qt默认拖拽
super().dropEvent(event)
# 检查拖拽后的父节点
new_parent_item = dragged_item.parent()
new_parent_node = new_parent_item.data(0, Qt.UserRole) if new_parent_item else None
# 同步Panda3D场景图的父子关系
try:
# 检查是否是跨层级拖拽(父节点发生变化)
if old_parent_node != new_parent_node:
print(f"跨层级拖拽:从 {old_parent_node} 移动到 {new_parent_node}")
# 保存世界坐标位置
world_pos = dragged_node.getPos(self.world.render)
world_hpr = dragged_node.getHpr(self.world.render)
world_scale = dragged_node.getScale(self.world.render)
# 重新父化到新的父节点
if new_parent_node:
dragged_node.reparentTo(new_parent_node)
else:
# 如果新父节点为None重新父化到render
dragged_node.reparentTo(self.world.render)
# 恢复世界坐标位置
dragged_node.setPos(self.world.render, world_pos)
dragged_node.setHpr(self.world.render, world_hpr)
dragged_node.setScale(self.world.render, world_scale)
print(f"✅ Panda3D父子关系已更新")
else:
print(f"同层级移动父节点未变化跳过Panda3D重新父化")
except Exception as e:
print(f"⚠️ 同步Panda3D场景图失败: {e}")
# 不影响Qt树的更新继续执行
# 事后验证:确保节点仍在"场景"根节点下
self._ensureUnderSceneRoot(dragged_item)
self.world.property_panel._syncEffectiveVisibility(dragged_node)
event.accept()
# try:
# world_pos = dragged_node.getPos(self.world.render)
#
# parent_of_dragged = dragged_node.getParent()
# target_node.wrtReparentTo(parent_of_dragged)
#
# # 拖动节点到目标节点下
# dragged_node.wrtReparentTo(target_node)
# dragged_node.setPos(self.world.render, world_pos)
#
# # 更新 Qt 树控件
# super().dropEvent(event)
#
# # 更新属性面板
# self.world.updatePropertyPanel(dragged_item)
#
# event.accept()
#
# except Exception as e:
# print(f"重设父节点失败: {e}")
# event.ignore()
def _ensureUnderSceneRoot(self, item):
"""确保节点在场景根节点下,如果不是则自动修正"""
if not item:
return
# 检查是否成为了顶级节点
if not item.parent():
# 如果节点名称不是"场景",说明意外成为了顶级节点
if item.text(0) != "场景":
print(f"⚠️ 检测到节点 {item.text(0)} 意外成为顶级节点,正在修正...")
# 找到场景根节点
scene_root = None
for i in range(self.topLevelItemCount()):
top_item = self.topLevelItem(i)
if top_item.text(0) == "场景":
scene_root = top_item
break
if scene_root:
# 将节点移回场景根节点下
self.takeTopLevelItem(self.indexOfTopLevelItem(item))
scene_root.addChild(item)
print(f"✅ 已将节点 {item.text(0)} 移回场景根节点下")
def isValidParentChild(self, dragged_item, target_item):
"""检查是否是有效的父子关系(防止循环)"""
# 1. 禁止拖放到自身
if dragged_item == target_item:
return False
# 2. 禁止拖到根节点之外(根节点本身除外)
target_root = self._getRootNode(target_item)
if target_root != "场景":
print(f"❌ 目标节点 {target_item.text(0)} 不在场景下")
return False
# 3. 禁止拖拽"场景"根节点
dragged_root = self._getRootNode(dragged_item)
if dragged_item.text(0) == "场景" or dragged_root != "场景":
print(f"❌ 禁止拖拽场景根节点或根节点外的节点")
return False
# 4. Qt 树循环检查
current = target_item
while current:
if current == dragged_item:
print(f"❌ Qt 树检测:{target_item.text(0)}{dragged_item.text(0)} 的后代")
return False
current = current.parent()
return True
def _getRootNode(self, item):
"""获取树中节点的根节点文本"""
current = item
while current.parent():
current = current.parent()
return current.text(0)
def dragEnterEvent(self, event):
"""处理拖入事件"""
if event.source() == self:
event.accept()
else:
event.ignore()
def dragMoveEvent(self, event):
"""处理拖动事件"""
if event.source() != self:
event.ignore()
return
# 获取当前拖拽的项目和目标位置
target_item = self.itemAt(event.pos())
selected_items = self.selectedItems()
# 检查是否拖拽到多选区域内的项目
if target_item and target_item in selected_items:
event.ignore()
return
# 检查其他禁止条件
if target_item and selected_items:
for dragged_item in selected_items:
if not self.isValidParentChild(dragged_item, target_item):
event.ignore()
return
super().dragMoveEvent(event)
event.accept()
def keyPressEvent(self, event):
"""处理键盘按键事件"""
if event.key() == Qt.Key_Delete:
# currentItem = self.currentItem()
# if currentItem and currentItem.parent():
# # 检查是否是模型节点或其子节点
# if self.world.interface_manager.isModelOrChild(currentItem):
# nodePath = currentItem.data(0, Qt.UserRole)
# if nodePath:
# print("正在删除节点...")
# self.world.interface_manager.deleteNode(nodePath, currentItem)
# print("删除完成")
selected_items = self.selectedItems()
if selected_items:
# 执行删除操作
self.delete_items(selected_items)
else:
# 没有选中任何项目,执行默认操作
super().keyPressEvent(event)
else:
super().keyPressEvent(event)
def delete_items(self, selected_items):
"""删除选中的项目"""
if not selected_items:
return
# 准备确认对话框的内容
item_count = len(selected_items)
if item_count == 1:
item_names = f'"{selected_items[0].text(0)}"'
title = "确认删除"
message = f"确定要删除节点 {item_names} 吗?"
else:
item_names = "".join([f'"{item.text(0)}"' for item in selected_items[:3]])
if item_count > 3:
item_names += f"{item_count} 个节点"
title = "确认批量删除"
message = f"确定要删除以下 {item_count} 个节点吗?\n\n{item_names}"
# 创建确认对话框
reply = QMessageBox.question(
self,
title,
message,
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No # 默认选择"取消",防止误删
)
# 只有用户确认后才执行删除
if reply == QMessageBox.Yes:
pass
print(f"✅ 已删除 {item_count} 个节点")