From 4f680a3da4c7fd243d7ec3113a81195654443d51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=A8=AA?= <2938139566@qq.com> Date: Thu, 28 Aug 2025 15:46:38 +0800 Subject: [PATCH] =?UTF-8?q?1.=E4=BF=AE=E6=94=B9=E6=95=B4=E4=BD=93=E5=B8=83?= =?UTF-8?q?=E5=B1=80=EF=BC=88=E6=9C=89BUG=E6=9C=AA=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E5=AE=8C=E6=88=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - .../rpcore/pynative/internal_light_manager.py | 50 +- Resources/icons/test_metallic_stripes.png | Bin 0 -> 1308 bytes .../icons/test_roughness_checkerboard.png | Bin 0 -> 1364 bytes Resources/icons/test_roughness_circle.png | Bin 0 -> 19408 bytes Resources/icons/test_roughness_gradient.png | Bin 0 -> 1304 bytes core/collision_manager.py | 945 ++++++++ gui/gui_manager.py | 727 ++++-- icons/move_tool.png | Bin 0 -> 3548 bytes icons/rotate_tool.png | Bin 0 -> 5993 bytes icons/scale_tool.png | Bin 0 -> 2407 bytes icons/select_tool.png | Bin 0 -> 5466 bytes main.py | 250 ++- scene/scene_manager.py | 901 ++++++-- ui/interface_manager.py | 9 +- ui/main_window.py | 603 +++-- ui/property_panel.py | 1 + ui/widgets.py | 1994 ++++++++++++++++- 18 files changed, 4737 insertions(+), 744 deletions(-) create mode 100644 Resources/icons/test_metallic_stripes.png create mode 100644 Resources/icons/test_roughness_checkerboard.png create mode 100644 Resources/icons/test_roughness_circle.png create mode 100644 Resources/icons/test_roughness_gradient.png create mode 100644 core/collision_manager.py create mode 100644 icons/move_tool.png create mode 100644 icons/rotate_tool.png create mode 100644 icons/scale_tool.png create mode 100644 icons/select_tool.png diff --git a/.gitignore b/.gitignore index 74ae8b6c..aa6cef21 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ __pycache__/ *.pyc .idea/ -*.pyc diff --git a/RenderPipelineFile/rpcore/pynative/internal_light_manager.py b/RenderPipelineFile/rpcore/pynative/internal_light_manager.py index 96b82c7a..1d8c480d 100644 --- a/RenderPipelineFile/rpcore/pynative/internal_light_manager.py +++ b/RenderPipelineFile/rpcore/pynative/internal_light_manager.py @@ -121,25 +121,43 @@ class InternalLightManager(object): print("ERROR: Could not detach light, light was not attached!") return - self._lights.free_slot(light.get_slot()) + # 保存必要信息,避免过早清理 + light_slot = light.get_slot() + has_shadows = light.get_casts_shadows() + + # 处理shadow sources(如果有的话) + if has_shadows: + num_sources = light.get_num_shadow_sources() + if num_sources > 0: + # 关键修复:先保存第一个source的slot,再清理 + first_source = light.get_shadow_source(0) + first_source_slot = first_source.get_slot() # 保存slot值 + + # 先发送GPU移除命令(在清理之前) + cmd_remove = GPUCommand(GPUCommand.CMD_remove_sources) + cmd_remove.push_int(first_source_slot) # 使用保存的slot值 + cmd_remove.push_int(num_sources) + self._cmd_list.add_command(cmd_remove) + + # 然后清理所有shadow sources + for i in range(num_sources): + source = light.get_shadow_source(i) + if source.has_slot(): + self._shadow_sources.free_slot(source.get_slot()) + if source.has_region(): + self._shadow_manager.get_atlas().free_region(source.get_region()) + source.clear_region() + + # 清理light的shadow sources + light.clear_shadow_sources() + + # 发送GPU移除light命令 self.gpu_remove_light(light) + + # 最后释放light slot和清理light信息 + self._lights.free_slot(light_slot) light.remove_slot() - if light.get_casts_shadows(): - - for i in range(light.get_num_shadow_sources()): - source = light.get_shadow_source(i) - if source.has_slot(): - self._shadow_sources.free_slot(source.get_slot()) - if source.has_region(): - self._shadow_manager.get_atlas().free_region(source.get_region()) - source.clear_region() - - self.gpu_remove_consecutive_sources( - light.get_shadow_source(0), light.get_num_shadow_sources()) - - light.clear_shadow_sources() - def gpu_remove_consecutive_sources(self, first_source, num_sources): cmd_remove = GPUCommand(GPUCommand.CMD_remove_sources) cmd_remove.push_int(first_source.get_slot()) diff --git a/Resources/icons/test_metallic_stripes.png b/Resources/icons/test_metallic_stripes.png new file mode 100644 index 0000000000000000000000000000000000000000..956c8661f3d20aa537e1744bd66b374588bb187d GIT binary patch literal 1308 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&w3=!$sk4H3kNj$DS^ZAr*7pUfL*lz<`I@ z@bvHZb9xq7RW5kC@wa7n`px72FaO{D=Fc34M=S#B4Hk??I2HUD3Yj{D9Y%#lLt->d fjAn&FUnuAYUgrK^?WMK~RP1@W`njxgN@xNAmdRkl literal 0 HcmV?d00001 diff --git a/Resources/icons/test_roughness_checkerboard.png b/Resources/icons/test_roughness_checkerboard.png new file mode 100644 index 0000000000000000000000000000000000000000..3ee2bd65e7ccda1e9fb3c3f4e3e4020febf2ee37 GIT binary patch literal 1364 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&w3=!$sk4H3kM&X-^l&kcv5P?{4gMHRND8 zc>L@A&FeXszHBw#+1f0s`B``G)8B^wOa9&eWNYuoP{`CF>@bJn5sQF&g9YOePKALM z+Hv#V{P|C-Kj%NcJ@_04458ff=j-49V1D-g#-McG*>C^;=-i)OZ+)L_kVPywb>&sp z{i{AMZ%}`Ha5`_>_vh(9@BO*`(`-;>3Rup({rU5K>wgVDtp}&`Ht)5!KmGmL{d3zH h2Ty?l3p=Kc-|Vd?l)rZR<*|W^OHWrnmvv4FO#rtdjrafn literal 0 HcmV?d00001 diff --git a/Resources/icons/test_roughness_circle.png b/Resources/icons/test_roughness_circle.png new file mode 100644 index 0000000000000000000000000000000000000000..5ab3d4d699503e94f433c5c8fe1b13b5aa170a87 GIT binary patch literal 19408 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

WavE?*4tH2w{K0;j`Z5%%(K11=>=P9mB2 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 xlh!MsD^`}djAn&F cUMSpQ-!zHkpQ7K@8K5H1)78&qol`;+0Db^kO#lD@ literal 0 HcmV?d00001 diff --git a/core/collision_manager.py b/core/collision_manager.py new file mode 100644 index 00000000..041571bf --- /dev/null +++ b/core/collision_manager.py @@ -0,0 +1,945 @@ +import time +from datetime import datetime +from panda3d.core import ( + CollisionTraverser, CollisionHandlerQueue, CollisionNode, + CollisionRay, CollisionSphere, BitMask32, Point3, Vec3 +) +from direct.task.TaskManagerGlobal import taskMgr + +class CollisionManager: + """统一的碰撞检测管理器""" + + def __init__(self, world): + self.world = world + self.traverser = CollisionTraverser("main_traverser") + self.queue = CollisionHandlerQueue() + + # 碰撞掩码定义 + self.MASKS = { + # === 基础碰撞掩码定义 === + # 每个掩码使用不同的位(bit)来标识,可以进行位运算组合 + + 'TERRAIN': BitMask32.bit(0), # 地形/地面 - 通常用于地面碰撞检测 + 'UI_ELEMENT': BitMask32.bit(1), # UI元素 - 界面组件的碰撞检测 + 'CAMERA': BitMask32.bit(2), # 摄像机 - 相机的碰撞检测 + 'MODEL_COLLISION': BitMask32.bit(3), # 模型碰撞 - 通用模型间碰撞检测 + } + + # 碰撞体形状类型 + self.COLLISION_SHAPES = { + 'SPHERE': 'sphere', + 'BOX': 'box', + 'CAPSULE': 'capsule', + 'PLANE': 'plane', + 'POLYGON': 'polygon', + 'MESH': 'mesh' + } + + # 掩码组合定义 + self.MASK_COMBINATIONS = { + } + + # 碰撞分组管理 + self.collision_groups = {} + self.group_enabled = {} + + # 碰撞回调系统 + self.collision_callbacks = {} + self.collision_filters = {} + + # 空间分割系统(八叉树) + self.spatial_partitioning_enabled = False + self.octree = None + self.octree_max_depth = 6 + self.octree_max_objects = 10 + + # 连续碰撞检测 + self.ccd_enabled = False + self.ccd_threshold = 10.0 # 速度阈值 + + # 性能监控 + self.performance_monitor = CollisionPerformanceMonitor() + + # 模型间碰撞检测配置 + self.model_collision_enabled = False + self.collision_detection_frequency = 0.1 + self.collision_distance_threshold = 0 + self.last_collision_check = 0 + self.collision_history = [] + self.max_collision_history = 100 + + # 碰撞状态跟踪 - 避免重复打印 + self.active_collisions = set() # 当前活跃的碰撞对 + + # 模型间碰撞检测任务 + self.collision_task = None + + print("✅ 碰撞检测管理器初始化完成") + + def enableModelCollisionDetection(self, enable=True, frequency=0.1, threshold=0.5): + """启用/禁用模型间碰撞检测 + + Args: + enable: 是否启用碰撞检测 + frequency: 检测频率(秒) + threshold: 碰撞距离阈值 + """ + self.model_collision_enabled = enable + self.collision_detection_frequency = frequency + self.collision_distance_threshold = threshold + + if enable: + print(f"✅ 启用模型间碰撞检测 - 频率: {frequency}s, 阈值: {threshold}") + self._startCollisionDetectionTask() + else: + print("❌ 禁用模型间碰撞检测") + self._stopCollisionDetectionTask() + + def _startCollisionDetectionTask(self): + """启动碰撞检测任务""" + if self.collision_task: + taskMgr.remove(self.collision_task) + + def collisionDetectionTask(task): + try: + self.detectModelCollisions() + return task.again + except Exception as e: + print(f"❌ 碰撞检测任务异常: {str(e)}") + return task.again + + self.collision_task = taskMgr.doMethodLater( + self.collision_detection_frequency, + collisionDetectionTask, + "modelCollisionDetection" + ) + + def _stopCollisionDetectionTask(self): + """停止碰撞检测任务""" + if self.collision_task: + taskMgr.remove(self.collision_task) + self.collision_task = None + + def detectModelCollisions(self, specific_models=None, log_results=True): + """检测模型间碰撞""" + if not self.model_collision_enabled and specific_models is None: + return [] + + start_time = time.perf_counter() + current_time = datetime.now() + + models_to_check = specific_models if specific_models else self.world.models + if len(models_to_check) < 2: + return [] + + collision_results = [] + checked_pairs = set() + current_collision_pairs = set() + + # 遍历所有模型对 + for i, model_a in enumerate(models_to_check): + for j, model_b in enumerate(models_to_check[i+1:], i+1): + pair_key = tuple(sorted([id(model_a), id(model_b)])) + if pair_key in checked_pairs: + continue + checked_pairs.add(pair_key) + + collision_info = self._checkModelPairCollision(model_a, model_b, current_time) + if collision_info: + collision_results.append(collision_info) + current_collision_pairs.add(pair_key) + + # 只有新碰撞才打印日志 + if log_results and pair_key not in self.active_collisions: + self._logCollisionInfo(collision_info) + print(f"🆕 新碰撞检测到!") + + # 检测碰撞结束的情况 + ended_collisions = self.active_collisions - current_collision_pairs + for pair_key in ended_collisions: + print(f"✅ 碰撞结束: 模型对 {pair_key}") + + # 更新活跃碰撞状态 + self.active_collisions = current_collision_pairs + + # 记录性能和历史 + detection_time = time.perf_counter() - start_time + self.performance_monitor.recordModelCollisionDetection( + detection_time, len(models_to_check), len(collision_results)) + + if collision_results: + self.collision_history.extend(collision_results) + if len(self.collision_history) > self.max_collision_history: + self.collision_history = self.collision_history[-self.max_collision_history:] + + return collision_results + + def _checkModelPairCollision(self, model_a, model_b, timestamp): + """使用Panda3D内置碰撞系统检查两个模型是否碰撞""" + try: + # 检查模型是否有碰撞节点 + collision_node_a = self._findCollisionNode(model_a) + collision_node_b = self._findCollisionNode(model_b) + + if not collision_node_a or not collision_node_b: + return None + + # 使用Panda3D的碰撞遍历器 + temp_traverser = CollisionTraverser() + temp_queue = CollisionHandlerQueue() + + # 设置碰撞检测 + temp_traverser.addCollider(collision_node_a, temp_queue) + + # 执行碰撞检测 + temp_traverser.traverse(model_b) + + if temp_queue.getNumEntries() > 0: + # 有碰撞,获取碰撞信息 + entry = temp_queue.getEntry(0) + + center_a = model_a.getPos(self.world.render) + center_b = model_b.getPos(self.world.render) + distance = (center_b - center_a).length() + + return { + 'timestamp': timestamp, + 'model_a': { + 'name': model_a.getName(), + 'center': center_a, + 'radius': 0, # 不再需要手动计算半径 + 'node': model_a + }, + 'model_b': { + 'name': model_b.getName(), + 'center': center_b, + 'radius': 0, + 'node': model_b + }, + 'collision_point': entry.getSurfacePoint(self.world.render), + 'distance': distance, + 'penetration_depth': 0, # Panda3D会自动处理 + 'collision_normal': entry.getSurfaceNormal(self.world.render) + } + + return None + + except Exception as e: + print(f"❌ 检测模型对碰撞失败 ({model_a.getName()} vs {model_b.getName()}): {str(e)}") + return None + + def _findCollisionNode(self, model): + """查找模型的碰撞节点""" + for child in model.getChildren(): + if isinstance(child.node(), CollisionNode): + return child + return None + + def _logCollisionInfo(self, collision_info): + """输出碰撞信息日志""" + timestamp = collision_info['timestamp'].strftime('%H:%M:%S.%f')[:-3] + model_a = collision_info['model_a'] + model_b = collision_info['model_b'] + + print(f"🔥 [{timestamp}] 检测到碰撞:") + print(f" 模型A: {model_a['name']} (中心: {model_a['center']}, 半径: {model_a['radius']:.2f})") + print(f" 模型B: {model_b['name']} (中心: {model_b['center']}, 半径: {model_b['radius']:.2f})") + print(f" 碰撞点: {collision_info['collision_point']}") + print(f" 中心距离: {collision_info['distance']:.3f}") + print(f" 穿透深度: {collision_info['penetration_depth']:.3f}") + print(f" 碰撞法向: {collision_info['collision_normal']}") + + def getCollisionHistory(self, limit=None): + """获取碰撞历史记录 + + Args: + limit: 返回记录数量限制 + + Returns: + list: 碰撞历史记录 + """ + if limit: + return self.collision_history[-limit:] + return self.collision_history.copy() + + def clearCollisionHistory(self): + """清除碰撞历史记录""" + self.collision_history.clear() + print("✅ 碰撞历史记录已清除") + + def getCollisionStatistics(self): + """获取碰撞统计信息""" + if not self.collision_history: + return {"total_collisions": 0, "unique_pairs": 0, "most_frequent_pair": None} + + # 统计碰撞对 + collision_pairs = {} + for collision in self.collision_history: + pair_key = tuple(sorted([ + collision['model_a']['name'], + collision['model_b']['name'] + ])) + collision_pairs[pair_key] = collision_pairs.get(pair_key, 0) + 1 + + most_frequent_pair = max(collision_pairs.items(), key=lambda x: x[1]) + + return { + "total_collisions": len(self.collision_history), + "unique_pairs": len(collision_pairs), + "most_frequent_pair": { + "models": most_frequent_pair[0], + "count": most_frequent_pair[1] + }, + "collision_pairs": collision_pairs + } + + def createCollisionShape(self, model, shape_type='auto', **kwargs): + """为模型创建指定类型的碰撞体 + + Args: + model: 模型节点 + shape_type: 碰撞体类型 ('auto', 'sphere', 'box', 'capsule', 'plane', 'polygon') + **kwargs: 形状特定参数 + + Returns: + CollisionSolid: 创建的碰撞体 + """ + from panda3d.core import ( + CollisionSphere, CollisionBox, CollisionCapsule, + CollisionPlane, CollisionPolygon, Plane, Vec3 + ) + + bounds = model.getBounds() + if bounds.isEmpty(): + # 默认小球体 + return CollisionSphere(Point3(0, 0, 0), 1.0) + + center = bounds.getCenter() + radius = bounds.getRadius() + + # 自动选择最适合的形状 + if shape_type == 'auto': + shape_type = self._determineOptimalShape(model, bounds) + + if shape_type == 'sphere': + return CollisionSphere(center, kwargs.get('radius', radius)) + + elif shape_type == 'box': + # 创建包围盒 + min_point = bounds.getMin() + max_point = bounds.getMax() + return CollisionBox(min_point, max_point) + + elif shape_type == 'capsule': + # 创建胶囊体(适合角色) + height = kwargs.get('height', (bounds.getMax().z - bounds.getMin().z)) + radius = kwargs.get('radius', min(bounds.getRadius() * 0.5, height * 0.3)) + point_a = Point3(center.x, center.y, bounds.getMin().z + radius) + point_b = Point3(center.x, center.y, bounds.getMax().z - radius) + return CollisionCapsule(point_a, point_b, radius) + + elif shape_type == 'plane': + # 创建平面(适合地面、墙面) + normal = kwargs.get('normal', Vec3(0, 0, 1)) + point = kwargs.get('point', center) + plane = Plane(normal, point) + return CollisionPlane(plane) + + elif shape_type == 'polygon': + # === 创建多边形碰撞体 === + # CollisionPolygon特点: + # - 单个平面多边形,所有顶点必须共面 + # - 适用于:地板、墙面、简单平台等平面区域 + # - 性能较好,但只能表示平面形状 + # + # Mesh碰撞体特点: + # - 由多个三角形组成的复杂3D形状 + # - 适用于:复杂地形、建筑物、不规则物体 + # - 性能较差,但可以表示任意复杂形状 + + vertices = kwargs.get('vertices', []) + if len(vertices) >= 3: + # 将顶点转换为Point3对象 + # 要求:所有顶点必须在同一平面上 + collision_poly = CollisionPolygon(*[Point3(*v) for v in vertices]) + return collision_poly + else: + print("⚠️ 多边形至少需要3个顶点,回退到球体") + return CollisionSphere(center, radius) + + def _determineOptimalShape(self, model, bounds): + """根据模型特征自动确定最适合的碰撞体形状""" + # 获取模型尺寸比例 + size = bounds.getMax() - bounds.getMin() + max_dim = max(size.x, size.y, size.z) + min_dim = min(size.x, size.y, size.z) + + # 根据模型名称和比例判断 + model_name = model.getName().lower() + + # 角色类模型使用胶囊体 + if any(keyword in model_name for keyword in ['character', 'human', 'player', 'npc']): + return 'capsule' + + # 建筑、箱子类使用包围盒 + if any(keyword in model_name for keyword in ['building', 'box', 'cube', 'wall', 'door']): + return 'box' + + # 地面、平台使用平面 + if any(keyword in model_name for keyword in ['ground', 'floor', 'platform', 'terrain']): + return 'plane' + + # 根据尺寸比例判断 + aspect_ratio = max_dim / min_dim if min_dim > 0 else 1 + + if aspect_ratio > 3: # 细长物体用胶囊体 + return 'capsule' + elif aspect_ratio < 1.5: # 接近球形用球体 + return 'sphere' + else: # 其他用包围盒 + return 'box' + + def createMouseRay(self, screen_x, screen_y, mask_types=['SELECTABLE']): + """创建鼠标射线""" + # 组合掩码 + combined_mask = BitMask32.allOff() + for mask_type in mask_types: + if mask_type in self.MASKS: + combined_mask |= self.MASKS[mask_type] + + # 坐标转换 + near_point, far_point = self.world.event_handler.robustCoordinateConversion( + screen_x, screen_y) + + if not near_point: + return None + + # 创建射线节点 + ray_node = CollisionNode('mouse_ray') + ray_node.setFromCollideMask(combined_mask) + ray_node.setIntoCollideMask(BitMask32.allOff()) + + # 创建射线 + direction = far_point - near_point + direction.normalize() + ray = CollisionRay(near_point, direction) + ray_node.addSolid(ray) + + return ray_node, near_point, far_point + + def performRaycast(self, ray_node, scene_root=None): + """执行射线检测""" + if scene_root is None: + scene_root = self.world.render + + # 创建临时节点路径 + ray_np = self.world.cam.attachNewNode(ray_node) + + try: + # 清除之前的结果 + self.queue.clearEntries() + + # 添加碰撞器 + self.traverser.addCollider(ray_np, self.queue) + + # 执行遍历 + start_time = time.perf_counter() + self.traverser.traverse(scene_root) + detection_time = time.perf_counter() - start_time + + # 记录性能 + self.performance_monitor.recordDetection( + detection_time, self.queue.getNumEntries()) + + # 处理结果 + results = [] + if self.queue.getNumEntries() > 0: + self.queue.sortEntries() + + for i in range(self.queue.getNumEntries()): + entry = self.queue.getEntry(i) + results.append({ + 'hit_pos': entry.getSurfacePoint(scene_root), + 'hit_normal': entry.getSurfaceNormal(scene_root), + 'hit_node': entry.getIntoNodePath(), + 'distance': entry.getT() + }) + + return results + + finally: + # 清理资源 + self.traverser.removeCollider(ray_np) + ray_np.removeNode() + + def getCollisionMask(self, mask_type): + """获取碰撞掩码""" + return self.MASKS.get(mask_type, BitMask32.allOff()) + + def createCollisionGroup(self, group_name, mask_types, enabled=True): + """创建碰撞分组 + + Args: + group_name: 分组名称 + mask_types: 掩码类型列表 + enabled: 是否启用 + """ + combined_mask = BitMask32.allOff() + for mask_type in mask_types: + if mask_type in self.MASKS: + combined_mask |= self.MASKS[mask_type] + + self.collision_groups[group_name] = { + 'mask': combined_mask, + 'types': mask_types, + 'objects': [] + } + self.group_enabled[group_name] = enabled + + print(f"✅ 创建碰撞分组: {group_name}, 掩码: {mask_types}") + + def addToCollisionGroup(self, group_name, model, collision_node): + """将模型添加到碰撞分组""" + if group_name in self.collision_groups: + self.collision_groups[group_name]['objects'].append({ + 'model': model, + 'collision_node': collision_node + }) + + def enableCollisionGroup(self, group_name, enabled=True): + """启用/禁用碰撞分组""" + if group_name in self.group_enabled: + self.group_enabled[group_name] = enabled + + # 更新分组中所有对象的碰撞状态 + if group_name in self.collision_groups: + for obj in self.collision_groups[group_name]['objects']: + collision_node = obj['collision_node'] + if enabled: + collision_node.show() + else: + collision_node.hide() + + print(f"{'✅ 启用' if enabled else '❌ 禁用'}碰撞分组: {group_name}") + + def registerCollisionCallback(self, mask_a, mask_b, callback_func, filter_func=None): + """注册碰撞回调函数 + + Args: + mask_a: 第一个掩码类型 + mask_b: 第二个掩码类型 + callback_func: 碰撞回调函数 callback(collision_info) + filter_func: 过滤函数 filter(model_a, model_b) -> bool + """ + key = tuple(sorted([mask_a, mask_b])) + if key not in self.collision_callbacks: + self.collision_callbacks[key] = [] + + self.collision_callbacks[key].append(callback_func) + + if filter_func: + if key not in self.collision_filters: + self.collision_filters[key] = [] + self.collision_filters[key].append(filter_func) + + print(f"✅ 注册碰撞回调: {mask_a} <-> {mask_b}") + + def _executeCollisionCallbacks(self, collision_info): + """执行碰撞回调""" + model_a = collision_info['model_a']['node'] + model_b = collision_info['model_b']['node'] + + # 获取模型的掩码类型 + mask_a = self._getModelMaskType(model_a) + mask_b = self._getModelMaskType(model_b) + + if mask_a and mask_b: + key = tuple(sorted([mask_a, mask_b])) + + # 检查过滤条件 + if key in self.collision_filters: + for filter_func in self.collision_filters[key]: + if not filter_func(model_a, model_b): + return + + # 执行回调 + if key in self.collision_callbacks: + for callback_func in self.collision_callbacks[key]: + try: + callback_func(collision_info) + except Exception as e: + print(f"❌ 碰撞回调执行失败: {e}") + + def _getModelMaskType(self, model): + """获取模型的掩码类型""" + # 从模型标签或属性中获取掩码类型 + if model.hasTag('collision_mask_type'): + return model.getTag('collision_mask_type') + return None + + def enableSpatialPartitioning(self, enabled=True, max_depth=6, max_objects=10): + """启用空间分割优化 + + Args: + enabled: 是否启用 + max_depth: 八叉树最大深度 + max_objects: 每个节点最大对象数 + """ + self.spatial_partitioning_enabled = enabled + self.octree_max_depth = max_depth + self.octree_max_objects = max_objects + + if enabled: + self._buildOctree() + print(f"✅ 启用空间分割优化 - 深度: {max_depth}, 对象数: {max_objects}") + else: + self.octree = None + print("❌ 禁用空间分割优化") + + def _buildOctree(self): + """构建八叉树""" + if not hasattr(self.world, 'models') or not self.world.models: + return + + # 计算场景边界 + min_bound = Point3(float('inf'), float('inf'), float('inf')) + max_bound = Point3(float('-inf'), float('-inf'), float('-inf')) + + for model in self.world.models: + bounds = model.getBounds() + if not bounds.isEmpty(): + model_min = bounds.getMin() + model_max = bounds.getMax() + + min_bound.x = min(min_bound.x, model_min.x) + min_bound.y = min(min_bound.y, model_min.y) + min_bound.z = min(min_bound.z, model_min.z) + + max_bound.x = max(max_bound.x, model_max.x) + max_bound.y = max(max_bound.y, model_max.y) + max_bound.z = max(max_bound.z, model_max.z) + + # 创建八叉树根节点 + self.octree = OctreeNode(min_bound, max_bound, 0, self.octree_max_depth, self.octree_max_objects) + + # 将模型插入八叉树 + for model in self.world.models: + self.octree.insert(model) + + def detectModelCollisionsOptimized(self, specific_models=None, log_results=True): + """使用空间分割优化的碰撞检测""" + if not self.spatial_partitioning_enabled or not self.octree: + return self.detectModelCollisions(specific_models, log_results) + + start_time = time.perf_counter() + current_time = datetime.now() + + models_to_check = specific_models if specific_models else self.world.models + if len(models_to_check) < 2: + return [] + + collision_results = [] + current_collision_pairs = set() + + # 使用八叉树进行优化检测 + for model in models_to_check: + # 获取可能碰撞的模型列表 + potential_collisions = self.octree.query(model) + + for other_model in potential_collisions: + if model == other_model: + continue + + pair_key = tuple(sorted([id(model), id(other_model)])) + if pair_key in current_collision_pairs: + continue + + collision_info = self._checkModelPairCollision(model, other_model, current_time) + if collision_info: + collision_results.append(collision_info) + current_collision_pairs.add(pair_key) + + # 执行回调 + self._executeCollisionCallbacks(collision_info) + + # 只有新碰撞才打印日志 + if log_results and pair_key not in self.active_collisions: + self._logCollisionInfo(collision_info) + print(f"🆕 新碰撞检测到!") + + # 更新活跃碰撞状态 + ended_collisions = self.active_collisions - current_collision_pairs + for pair_key in ended_collisions: + print(f"✅ 碰撞结束: 模型对 {pair_key}") + + self.active_collisions = current_collision_pairs + + # 记录性能 + detection_time = time.perf_counter() - start_time + self.performance_monitor.recordModelCollisionDetection( + detection_time, len(models_to_check), len(collision_results)) + + return collision_results + + def cleanup(self): + """清理碰撞管理器资源""" + self._stopCollisionDetectionTask() + self.collision_history.clear() + self.active_collisions.clear() + print("✅ 碰撞管理器已清理") + + def setupAdvancedCollision(self, model, shape_type='auto', mask_type='SELECTABLE', + group_name=None, **shape_kwargs): + """高级碰撞设置方法 + + Args: + model: 模型节点 + shape_type: 碰撞体形状类型 + mask_type: 掩码类型 + group_name: 碰撞分组名称 + **shape_kwargs: 形状特定参数 + + Returns: + collision_node_path: 碰撞节点路径 + """ + # 创建碰撞节点 + cNode = CollisionNode(f'collision_{model.getName()}') + + # 设置掩码 + if mask_type in self.MASKS: + cNode.setIntoCollideMask(self.MASKS[mask_type]) + # 设置模型标签用于回调识别 + model.setTag('collision_mask_type', mask_type) + + # 创建碰撞体 + collision_solid = self.createCollisionShape(model, shape_type, **shape_kwargs) + cNode.addSolid(collision_solid) + + # 附加到模型 + cNodePath = model.attachNewNode(cNode) + + # 添加到分组 + if group_name: + if group_name not in self.collision_groups: + self.createCollisionGroup(group_name, [mask_type]) + self.addToCollisionGroup(group_name, model, cNodePath) + + # 根据调试设置决定是否显示 + if hasattr(self.world, 'debug_collision') and self.world.debug_collision: + cNodePath.show() + else: + cNodePath.hide() + + print(f"✅ 为 {model.getName()} 设置高级碰撞 - 形状: {shape_type}, 掩码: {mask_type}") + return cNodePath + + def example_usage(self): + """碰撞系统使用示例""" + print("\n=== 碰撞系统使用示例 ===") + + # 1. 创建碰撞分组 + self.createCollisionGroup('characters', ['CHARACTER', 'DYNAMIC_OBJECT']) + self.createCollisionGroup('environment', ['STATIC_GEOMETRY', 'TERRAIN']) + self.createCollisionGroup('pickups', ['PICKUP_ITEM', 'INTERACTIVE']) + + # 2. 注册碰撞回调 + def character_pickup_callback(collision_info): + print(f"角色拾取物品: {collision_info['model_a']['name']} -> {collision_info['model_b']['name']}") + + def character_environment_callback(collision_info): + print(f"角色碰撞环境: {collision_info['model_a']['name']} 碰到 {collision_info['model_b']['name']}") + + self.registerCollisionCallback('CHARACTER', 'PICKUP_ITEM', character_pickup_callback) + self.registerCollisionCallback('CHARACTER', 'STATIC_GEOMETRY', character_environment_callback) + + # 3. 启用空间分割优化 + self.enableSpatialPartitioning(enabled=True, max_depth=6, max_objects=8) + + # 4. 为模型设置不同类型的碰撞体 + # model1 = ... # 假设已有模型 + # self.setupAdvancedCollision(model1, 'capsule', 'CHARACTER', 'characters') + # self.setupAdvancedCollision(model2, 'box', 'PICKUP_ITEM', 'pickups') + # self.setupAdvancedCollision(model3, 'plane', 'TERRAIN', 'environment') + + print("✅ 碰撞系统示例配置完成") + +class CollisionPerformanceMonitor: + """碰撞检测性能监控器""" + + def __init__(self): + self.detection_times = [] + self.collision_counts = [] + self.model_collision_times = [] # 新增:模型间碰撞检测时间 + self.model_collision_data = [] # 新增:模型间碰撞数据 + self.max_records = 100 + + def recordDetection(self, detection_time, collision_count): + """记录射线检测性能""" + self.detection_times.append(detection_time) + self.collision_counts.append(collision_count) + + # 限制记录数量 + if len(self.detection_times) > self.max_records: + self.detection_times.pop(0) + self.collision_counts.pop(0) + + def recordModelCollisionDetection(self, detection_time, model_count, collision_count): + """记录模型间碰撞检测性能""" + self.model_collision_times.append(detection_time) + self.model_collision_data.append({ + 'model_count': model_count, + 'collision_count': collision_count, + 'timestamp': time.time() + }) + + # 限制记录数量 + if len(self.model_collision_times) > self.max_records: + self.model_collision_times.pop(0) + self.model_collision_data.pop(0) + + def getAverageTime(self): + """获取平均射线检测时间""" + if not self.detection_times: + return 0 + return sum(self.detection_times) / len(self.detection_times) + + def getAverageModelCollisionTime(self): + """获取平均模型间碰撞检测时间""" + if not self.model_collision_times: + return 0 + return sum(self.model_collision_times) / len(self.model_collision_times) + + def getPerformanceReport(self): + """获取完整性能报告""" + report = [] + + # 射线检测性能 + if self.detection_times: + avg_time = self.getAverageTime() + max_time = max(self.detection_times) + avg_collisions = sum(self.collision_counts) / len(self.collision_counts) + + report.append("=== 射线检测性能 ===") + report.append(f"平均检测时间: {avg_time*1000:.2f}ms") + report.append(f"最大检测时间: {max_time*1000:.2f}ms") + report.append(f"平均碰撞数量: {avg_collisions:.1f}") + report.append(f"检测次数: {len(self.detection_times)}") + + # 模型间碰撞检测性能 + if self.model_collision_times: + avg_model_time = self.getAverageModelCollisionTime() + max_model_time = max(self.model_collision_times) + avg_model_count = sum(d['model_count'] for d in self.model_collision_data) / len(self.model_collision_data) + total_collisions = sum(d['collision_count'] for d in self.model_collision_data) + + report.append("\n=== 模型间碰撞检测性能 ===") + report.append(f"平均检测时间: {avg_model_time*1000:.2f}ms") + report.append(f"最大检测时间: {max_model_time*1000:.2f}ms") + report.append(f"平均模型数量: {avg_model_count:.1f}") + report.append(f"总碰撞次数: {total_collisions}") + report.append(f"检测次数: {len(self.model_collision_times)}") + + return "\n".join(report) if report else "没有性能数据" + +class OctreeNode: + """八叉树节点""" + + def __init__(self, min_bound, max_bound, depth, max_depth, max_objects): + self.min_bound = min_bound + self.max_bound = max_bound + self.depth = depth + self.max_depth = max_depth + self.max_objects = max_objects + + self.objects = [] + self.children = [] + self.is_leaf = True + + def insert(self, model): + """插入模型到八叉树""" + if not self._contains(model): + return False + + if self.is_leaf: + self.objects.append(model) + + # 如果超过最大对象数且未达到最大深度,则分割 + if len(self.objects) > self.max_objects and self.depth < self.max_depth: + self._subdivide() + + # 重新分配对象到子节点 + remaining_objects = [] + for obj in self.objects: + inserted = False + for child in self.children: + if child.insert(obj): + inserted = True + break + if not inserted: + remaining_objects.append(obj) + + self.objects = remaining_objects + else: + # 尝试插入到子节点 + inserted = False + for child in self.children: + if child.insert(model): + inserted = True + break + + if not inserted: + self.objects.append(model) + + return True + + def query(self, model): + """查询可能与指定模型碰撞的模型列表""" + if not self._contains(model): + return [] + + result = list(self.objects) + + if not self.is_leaf: + for child in self.children: + result.extend(child.query(model)) + + return result + + def _contains(self, model): + """检查模型是否在此节点范围内""" + bounds = model.getBounds() + if bounds.isEmpty(): + return False + + model_min = bounds.getMin() + model_max = bounds.getMax() + + return (model_max.x >= self.min_bound.x and model_min.x <= self.max_bound.x and + model_max.y >= self.min_bound.y and model_min.y <= self.max_bound.y and + model_max.z >= self.min_bound.z and model_min.z <= self.max_bound.z) + + def _subdivide(self): + """分割节点为8个子节点""" + center = Point3( + (self.min_bound.x + self.max_bound.x) * 0.5, + (self.min_bound.y + self.max_bound.y) * 0.5, + (self.min_bound.z + self.max_bound.z) * 0.5 + ) + + # 创建8个子节点 + subdivisions = [ + (self.min_bound, center), + (Point3(center.x, self.min_bound.y, self.min_bound.z), Point3(self.max_bound.x, center.y, center.z)), + (Point3(self.min_bound.x, center.y, self.min_bound.z), Point3(center.x, self.max_bound.y, center.z)), + (Point3(center.x, center.y, self.min_bound.z), Point3(self.max_bound.x, self.max_bound.y, center.z)), + (Point3(self.min_bound.x, self.min_bound.y, center.z), Point3(center.x, center.y, self.max_bound.z)), + (Point3(center.x, self.min_bound.y, center.z), Point3(self.max_bound.x, center.y, self.max_bound.z)), + (Point3(self.min_bound.x, center.y, center.z), Point3(center.x, self.max_bound.y, self.max_bound.z)), + (center, self.max_bound) + ] + + for min_b, max_b in subdivisions: + child = OctreeNode(min_b, max_b, self.depth + 1, self.max_depth, self.max_objects) + self.children.append(child) + + self.is_leaf = False diff --git a/gui/gui_manager.py b/gui/gui_manager.py index 550f0bb3..2029888e 100644 --- a/gui/gui_manager.py +++ b/gui/gui_manager.py @@ -41,180 +41,568 @@ class GUIManager: self.currentGUITool = None print("✓ GUI管理系统初始化完成") - + # ==================== GUI元素创建方法 ==================== - + def createGUIButton(self, pos=(0, 0, 0), text="按钮", size=0.1): - """创建2D GUI按钮""" - from direct.gui.DirectGui import DirectButton - - # 将3D坐标转换为2D屏幕坐标 - gui_pos = (pos[0] * 0.1, 0, pos[2] * 0.1) - - button = DirectButton( - text=text, - pos=gui_pos, - scale=size, - command=self.onGUIButtonClick, - extraArgs=[f"button_{len(self.gui_elements)}"], - frameColor=(0.2, 0.6, 0.8, 1), - text_font=self.world.getChineseFont() if self.world.getChineseFont() else None, - rolloverSound=None, - clickSound=None - ) - - # 为GUI元素添加标识 - button.setTag("gui_type", "button") - button.setTag("gui_id", f"button_{len(self.gui_elements)}") - button.setTag("gui_text", text) - button.setTag("is_gui_element", "1") - - self.gui_elements.append(button) - # 安全地调用updateSceneTree - if hasattr(self.world, 'updateSceneTree'): - self.world.updateSceneTree() - - print(f"✓ 创建GUI按钮: {text} (逻辑位置: {pos}, 屏幕位置: {gui_pos})") - return button - + """创建2D GUI按钮 - 支持多选创建和GUI父子关系,优化版本""" + try: + from direct.gui.DirectGui import DirectButton + from PyQt5.QtCore import Qt + + print(f"🔘 开始创建GUI按钮,位置: {pos}, 文本: {text}, 尺寸: {size}") + + # 获取树形控件 + tree_widget = self._get_tree_widget() + if not tree_widget: + print("❌ 无法访问树形控件") + return None + + # 使用CustomTreeWidget的方法获取目标父节点列表 + target_parents = tree_widget.get_target_parents_for_gui_creation() + if not target_parents: + print("❌ 没有找到有效的父节点") + return None + + created_buttons = [] + + # 为每个有效的父节点创建GUI按钮 + for parent_item, parent_node in target_parents: + try: + # 生成唯一名称 + button_name = f"GUIButton_{len(self.gui_elements)}" + + # 使用CustomTreeWidget的方法判断父节点类型并设置相应的挂载方式 + if tree_widget.is_gui_element(parent_node): + # 父节点是GUI元素 - 作为子GUI挂载 + gui_pos = tree_widget.calculate_relative_gui_position(pos, parent_node) + parent_gui_node = parent_node # 直接挂载到GUI元素 + print(f"📎 挂载到GUI父节点: {parent_node.getName()}") + else: + # 父节点是普通3D节点 - 使用屏幕坐标 + gui_pos = (pos[0] * 0.1, 0, pos[2] * 0.1) + parent_gui_node = None # 使用默认的aspect2d + print(f"📎 挂载到3D父节点: {parent_item.text(0)}") + + button = DirectButton( + text=text, + pos=gui_pos, + scale=size, + command=self.onGUIButtonClick, + extraArgs=[f"button_{len(self.gui_elements)}"], + frameColor=(0.2, 0.6, 0.8, 1), + text_font=self.world.getChineseFont() if self.world.getChineseFont() else None, + rolloverSound=None, + clickSound=None, + parent=parent_gui_node # 设置GUI父节点 + ) + + # 设置节点标签 + button.setTag("gui_type", "button") + button.setTag("gui_id", f"button_{len(self.gui_elements)}") + button.setTag("gui_text", text) + button.setTag("is_gui_element", "1") + button.setTag("is_scene_element", "1") + button.setTag("created_by_user", "1") + button.setTag("gui_parent_type", "gui" if parent_gui_node else "3d") + button.setName(button_name) + + # 如果有GUI父节点,建立引用关系 + if parent_gui_node: + parent_id = parent_gui_node.getTag("gui_id") if hasattr(parent_gui_node, 'getTag') else "" + button.setTag("gui_parent_id", parent_id) + + # 添加到GUI元素列表 + self.gui_elements.append(button) + + print(f"✅ 为 {parent_item.text(0)} 创建GUI按钮成功: {button_name}") + + # 使用CustomTreeWidget的方法在Qt树形控件中添加对应节点 + qt_item = tree_widget.add_node_to_tree_widget(button, parent_item, "GUI_BUTTON") + if qt_item: + created_buttons.append((button, qt_item)) + else: + created_buttons.append((button, None)) + print("⚠️ Qt树节点添加失败,但GUI对象已创建") + + except Exception as e: + print(f"❌ 为 {parent_item.text(0)} 创建GUI按钮失败: {str(e)}") + continue + + # 处理创建结果 + if not created_buttons: + print("❌ 没有成功创建任何GUI按钮") + return None + + # 选中最后创建的按钮并更新场景树 + if created_buttons: + last_button, last_qt_item = created_buttons[-1] + if last_qt_item: + tree_widget.setCurrentItem(last_qt_item) + tree_widget.update_selection_and_properties(last_button, last_qt_item) + + print(f"🎉 总共创建了 {len(created_buttons)} 个GUI按钮") + + # 返回值处理 + if len(created_buttons) == 1: + return created_buttons[0][0] + else: + return [button for button, _ in created_buttons] + + except Exception as e: + print(f"❌ 创建GUI按钮过程失败: {str(e)}") + import traceback + traceback.print_exc() + return None + def createGUILabel(self, pos=(0, 0, 0), text="标签", size=0.08): - """创建2D GUI标签""" - from direct.gui.DirectGui import DirectLabel - - # 将3D坐标转换为2D屏幕坐标 - gui_pos = (pos[0] * 0.1, 0, pos[2] * 0.1) - - label = DirectLabel( - text=text, - pos=gui_pos, - scale=size, - frameColor=(0, 0, 0, 0), # 透明背景 - text_fg=(1, 1, 1, 1), - text_font=self.world.getChineseFont() if self.world.getChineseFont() else None - ) - - # 为GUI元素添加标识 - label.setTag("gui_type", "label") - label.setTag("gui_id", f"label_{len(self.gui_elements)}") - label.setTag("gui_text", text) - label.setTag("is_gui_element", "1") - - self.gui_elements.append(label) - # 安全地调用updateSceneTree - if hasattr(self.world, 'updateSceneTree'): - self.world.updateSceneTree() - - print(f"✓ 创建GUI标签: {text} (逻辑位置: {pos}, 屏幕位置: {gui_pos})") - return label - + """创建2D GUI标签 - 支持多选创建和GUI父子关系,优化版本""" + try: + from direct.gui.DirectGui import DirectLabel + from PyQt5.QtCore import Qt + + print(f"🏷️ 开始创建GUI标签,位置: {pos}, 文本: {text}, 尺寸: {size}") + + # 获取树形控件 + tree_widget = self._get_tree_widget() + if not tree_widget: + print("❌ 无法访问树形控件") + return None + + # 使用CustomTreeWidget的方法获取目标父节点列表 + target_parents = tree_widget.get_target_parents_for_gui_creation() + if not target_parents: + print("❌ 没有找到有效的父节点") + return None + + created_labels = [] + + # 为每个有效的父节点创建GUI标签 + for parent_item, parent_node in target_parents: + try: + # 生成唯一名称 + label_name = f"GUILabel_{len(self.gui_elements)}" + + # 使用CustomTreeWidget的方法判断父节点类型并设置相应的挂载方式 + if tree_widget.is_gui_element(parent_node): + # 父节点是GUI元素 - 作为子GUI挂载 + gui_pos = tree_widget.calculate_relative_gui_position(pos, parent_node) + parent_gui_node = parent_node + print(f"📎 挂载到GUI父节点: {parent_node.getName()}") + else: + # 父节点是普通3D节点 - 使用屏幕坐标 + gui_pos = (pos[0] * 0.1, 0, pos[2] * 0.1) + parent_gui_node = None + print(f"📎 挂载到3D父节点: {parent_item.text(0)}") + + label = DirectLabel( + text=text, + pos=gui_pos, + scale=size, + frameColor=(0, 0, 0, 0), # 透明背景 + text_fg=(1, 1, 1, 1), + text_font=self.world.getChineseFont() if self.world.getChineseFont() else None, + parent=parent_gui_node # 设置GUI父节点 + ) + + # 设置节点标签 + label.setTag("gui_type", "label") + label.setTag("gui_id", f"label_{len(self.gui_elements)}") + label.setTag("gui_text", text) + label.setTag("is_gui_element", "1") + label.setTag("is_scene_element", "1") + label.setTag("created_by_user", "1") + label.setTag("gui_parent_type", "gui" if parent_gui_node else "3d") + label.setName(label_name) + + # 如果有GUI父节点,建立引用关系 + if parent_gui_node: + parent_id = parent_gui_node.getTag("gui_id") if hasattr(parent_gui_node, 'getTag') else "" + label.setTag("gui_parent_id", parent_id) + + # 添加到GUI元素列表 + self.gui_elements.append(label) + + print(f"✅ 为 {parent_item.text(0)} 创建GUI标签成功: {label_name}") + + # 使用CustomTreeWidget的方法在Qt树形控件中添加对应节点 + qt_item = tree_widget.add_node_to_tree_widget(label, parent_item, "GUI_LABEL") + if qt_item: + created_labels.append((label, qt_item)) + else: + created_labels.append((label, None)) + print("⚠️ Qt树节点添加失败,但GUI对象已创建") + + except Exception as e: + print(f"❌ 为 {parent_item.text(0)} 创建GUI标签失败: {str(e)}") + continue + + # 处理创建结果 + if not created_labels: + print("❌ 没有成功创建任何GUI标签") + return None + + # 选中最后创建的标签并更新场景树 + if created_labels: + last_label, last_qt_item = created_labels[-1] + if last_qt_item: + tree_widget.setCurrentItem(last_qt_item) + tree_widget.update_selection_and_properties(last_label, last_qt_item) + + print(f"🎉 总共创建了 {len(created_labels)} 个GUI标签") + + # 返回值处理 + if len(created_labels) == 1: + return created_labels[0][0] + else: + return [label for label, _ in created_labels] + + except Exception as e: + print(f"❌ 创建GUI标签过程失败: {str(e)}") + import traceback + traceback.print_exc() + return None + def createGUIEntry(self, pos=(0, 0, 0), placeholder="输入文本...", size=0.08): - """创建2D GUI文本输入框""" - from direct.gui.DirectGui import DirectEntry - - # 将3D坐标转换为2D屏幕坐标 - gui_pos = (pos[0] * 0.1, 0, pos[2] * 0.1) - - entry = DirectEntry( - text="", - pos=gui_pos, - scale=size, - command=self.onGUIEntrySubmit, - extraArgs=[f"entry_{len(self.gui_elements)}"], - initialText=placeholder, - numLines=1, - width=12, - focus=0 - ) - - # 为GUI元素添加标识 - entry.setTag("gui_type", "entry") - entry.setTag("gui_id", f"entry_{len(self.gui_elements)}") - entry.setTag("gui_placeholder", placeholder) - entry.setTag("is_gui_element", "1") - - self.gui_elements.append(entry) - # 安全地调用updateSceneTree - if hasattr(self.world, 'updateSceneTree'): - self.world.updateSceneTree() - - print(f"✓ 创建GUI输入框: {placeholder} (逻辑位置: {pos}, 屏幕位置: {gui_pos})") - return entry - + """创建2D GUI文本输入框 - 支持多选创建和GUI父子关系,优化版本""" + try: + from direct.gui.DirectGui import DirectEntry + from PyQt5.QtCore import Qt + + print(f"📝 开始创建GUI输入框,位置: {pos}, 占位符: {placeholder}, 尺寸: {size}") + + # 获取树形控件 + tree_widget = self._get_tree_widget() + if not tree_widget: + print("❌ 无法访问树形控件") + return None + + # 使用CustomTreeWidget的方法获取目标父节点列表 + target_parents = tree_widget.get_target_parents_for_gui_creation() + if not target_parents: + print("❌ 没有找到有效的父节点") + return None + + created_entries = [] + + # 为每个有效的父节点创建GUI输入框 + for parent_item, parent_node in target_parents: + try: + # 生成唯一名称 + entry_name = f"GUIEntry_{len(self.gui_elements)}" + + # 使用CustomTreeWidget的方法判断父节点类型并设置相应的挂载方式 + if tree_widget.is_gui_element(parent_node): + # 父节点是GUI元素 - 作为子GUI挂载 + gui_pos = tree_widget.calculate_relative_gui_position(pos, parent_node) + parent_gui_node = parent_node + print(f"📎 挂载到GUI父节点: {parent_node.getName()}") + else: + # 父节点是普通3D节点 - 使用屏幕坐标 + gui_pos = (pos[0] * 0.1, 0, pos[2] * 0.1) + parent_gui_node = None + print(f"📎 挂载到3D父节点: {parent_item.text(0)}") + + entry = DirectEntry( + text="", + pos=gui_pos, + scale=size, + command=self.onGUIEntrySubmit, + extraArgs=[f"entry_{len(self.gui_elements)}"], + initialText=placeholder, + numLines=1, + width=12, + focus=0, + parent=parent_gui_node # 设置GUI父节点 + ) + + # 设置节点标签 + entry.setTag("gui_type", "entry") + entry.setTag("gui_id", f"entry_{len(self.gui_elements)}") + entry.setTag("gui_placeholder", placeholder) + entry.setTag("is_gui_element", "1") + entry.setTag("is_scene_element", "1") + entry.setTag("created_by_user", "1") + entry.setTag("gui_parent_type", "gui" if parent_gui_node else "3d") + entry.setName(entry_name) + + # 如果有GUI父节点,建立引用关系 + if parent_gui_node: + parent_id = parent_gui_node.getTag("gui_id") if hasattr(parent_gui_node, 'getTag') else "" + entry.setTag("gui_parent_id", parent_id) + + # 添加到GUI元素列表 + self.gui_elements.append(entry) + + print(f"✅ 为 {parent_item.text(0)} 创建GUI输入框成功: {entry_name}") + + # 使用CustomTreeWidget的方法在Qt树形控件中添加对应节点 + qt_item = tree_widget.add_node_to_tree_widget(entry, parent_item, "GUI_ENTRY") + if qt_item: + created_entries.append((entry, qt_item)) + else: + created_entries.append((entry, None)) + print("⚠️ Qt树节点添加失败,但GUI对象已创建") + + except Exception as e: + print(f"❌ 为 {parent_item.text(0)} 创建GUI输入框失败: {str(e)}") + continue + + # 处理创建结果 + if not created_entries: + print("❌ 没有成功创建任何GUI输入框") + return None + + # 选中最后创建的输入框并更新场景树 + if created_entries: + last_entry, last_qt_item = created_entries[-1] + if last_qt_item: + tree_widget.setCurrentItem(last_qt_item) + tree_widget.update_selection_and_properties(last_entry, last_qt_item) + + print(f"🎉 总共创建了 {len(created_entries)} 个GUI输入框") + + # 返回值处理 + if len(created_entries) == 1: + return created_entries[0][0] + else: + return [entry for entry, _ in created_entries] + + except Exception as e: + print(f"❌ 创建GUI输入框过程失败: {str(e)}") + import traceback + traceback.print_exc() + return None + def createGUI3DText(self, pos=(0, 0, 0), text="3D文本", size=0.5): - """创建3D空间文本""" - from panda3d.core import TextNode - - textNode = TextNode(f'3d-text-{len(self.gui_elements)}') - textNode.setText(text) - textNode.setAlign(TextNode.ACenter) - if self.world.getChineseFont(): - textNode.setFont(self.world.getChineseFont()) - - textNodePath = self.world.render.attachNewNode(textNode) - textNodePath.setPos(*pos) - textNodePath.setScale(size) - textNodePath.setColor(1, 1, 0, 1) - textNodePath.setBillboardAxis() # 让文本总是面向相机 - - # 为GUI元素添加标识 - textNodePath.setTag("gui_type", "3d_text") - textNodePath.setTag("gui_id", f"3d_text_{len(self.gui_elements)}") - textNodePath.setTag("gui_text", text) - textNodePath.setTag("is_gui_element", "1") - - self.gui_elements.append(textNodePath) - # 安全地调用updateSceneTree - if hasattr(self.world, 'updateSceneTree'): - self.world.updateSceneTree() - - print(f"✓ 创建3D文本: {text} (世界位置: {pos})") - return textNodePath + """创建3D空间文本 - 支持多选创建,优化版本""" + try: + from panda3d.core import TextNode + from PyQt5.QtCore import Qt + print(f"📄 开始创建3D文本,位置: {pos}, 文本: {text}, 尺寸: {size}") + + # 获取树形控件 + tree_widget = self._get_tree_widget() + if not tree_widget: + print("❌ 无法访问树形控件") + return None + + # 获取目标父节点列表 + target_parents = tree_widget.get_target_parents_for_creation() + if not target_parents: + print("❌ 没有找到有效的父节点") + return None + + created_texts = [] + + # 为每个有效的父节点创建3D文本 + for parent_item, parent_node in target_parents: + try: + # 生成唯一名称 + text_name = f"GUI3DText_{len(self.gui_elements)}" + + textNode = TextNode(f'3d-text-{len(self.gui_elements)}') + textNode.setText(text) + textNode.setAlign(TextNode.ACenter) + if self.world.getChineseFont(): + textNode.setFont(self.world.getChineseFont()) + + # 挂载到选中的父节点 + textNodePath = parent_node.attachNewNode(textNode) + textNodePath.setPos(*pos) + textNodePath.setScale(size) + textNodePath.setColor(1, 1, 0, 1) + textNodePath.setBillboardAxis() # 让文本总是面向相机 + textNodePath.setName(text_name) + + # 设置节点标签 + textNodePath.setTag("gui_type", "3d_text") + textNodePath.setTag("gui_id", f"3d_text_{len(self.gui_elements)}") + textNodePath.setTag("gui_text", text) + textNodePath.setTag("is_gui_element", "1") + textNodePath.setTag("is_scene_element", "1") + textNodePath.setTag("created_by_user", "1") + + # 添加到GUI元素列表 + self.gui_elements.append(textNodePath) + + print(f"✅ 为 {parent_item.text(0)} 创建3D文本成功: {text_name}") + + # 在Qt树形控件中添加对应节点 + qt_item = tree_widget.add_node_to_tree_widget(textNodePath, parent_item, "GUI_3DTEXT") + if qt_item: + created_texts.append((textNodePath, qt_item)) + else: + created_texts.append((textNodePath, None)) + print("⚠️ Qt树节点添加失败,但GUI对象已创建") + + except Exception as e: + print(f"❌ 为 {parent_item.text(0)} 创建3D文本失败: {str(e)}") + continue + + # 处理创建结果 + if not created_texts: + print("❌ 没有成功创建任何3D文本") + return None + + # 选中最后创建的文本并更新场景树 + if created_texts: + last_text, last_qt_item = created_texts[-1] + if last_qt_item: + tree_widget.setCurrentItem(last_qt_item) + tree_widget.update_selection_and_properties(last_text, last_qt_item) + + print(f"🎉 总共创建了 {len(created_texts)} 个3D文本") + + # 返回值处理 + if len(created_texts) == 1: + return created_texts[0][0] + else: + return [text_np for text_np, _ in created_texts] + + except Exception as e: + print(f"❌ 创建3D文本过程失败: {str(e)}") + import traceback + traceback.print_exc() + return None - def createGUIVirtualScreen(self, pos=(0, 0, 0), size=(2, 1), text="虚拟屏幕"): - """创建3D虚拟屏幕""" - from panda3d.core import CardMaker, TransparencyAttrib, TextNode - - # 创建虚拟屏幕 - cm = CardMaker(f"virtual-screen-{len(self.gui_elements)}") - cm.setFrame(-size[0]/2, size[0]/2, -size[1]/2, size[1]/2) - virtualScreen = self.world.render.attachNewNode(cm.generate()) - virtualScreen.setPos(*pos) - virtualScreen.setColor(0.2, 0.2, 0.2, 0.8) - virtualScreen.setTransparency(TransparencyAttrib.MAlpha) - - # 在虚拟屏幕上添加文本 - screenText = TextNode(f'screen-text-{len(self.gui_elements)}') - screenText.setText(text) - screenText.setAlign(TextNode.ACenter) - if self.world.getChineseFont(): - screenText.setFont(self.world.getChineseFont()) - screenTextNP = virtualScreen.attachNewNode(screenText) - screenTextNP.setPos(0, 0.01, 0) - screenTextNP.setScale(0.3) - screenTextNP.setColor(0, 1, 0, 1) - - # 为GUI元素添加标识 - virtualScreen.setTag("gui_type", "virtual_screen") - virtualScreen.setTag("gui_id", f"virtual_screen_{len(self.gui_elements)}") - virtualScreen.setTag("gui_text", text) - virtualScreen.setTag("is_gui_element", "1") - - self.gui_elements.append(virtualScreen) - # 安全地调用updateSceneTree - if hasattr(self.world, 'updateSceneTree'): - self.world.updateSceneTree() - - print(f"✓ 创建虚拟屏幕: {text} (世界位置: {pos})") - return virtualScreen - + """创建3D虚拟屏幕 - 支持多选创建,优化版本""" + try: + from panda3d.core import CardMaker, TransparencyAttrib, TextNode + from PyQt5.QtCore import Qt + + print(f"🖥️ 开始创建虚拟屏幕,位置: {pos}, 尺寸: {size}, 文本: {text}") + + # 获取树形控件 + tree_widget = self._get_tree_widget() + if not tree_widget: + print("❌ 无法访问树形控件") + return None + + # 获取目标父节点列表 + target_parents = tree_widget.get_target_parents_for_creation() + if not target_parents: + print("❌ 没有找到有效的父节点") + return None + + created_screens = [] + + # 为每个有效的父节点创建虚拟屏幕 + for parent_item, parent_node in target_parents: + try: + # 生成唯一名称 + screen_name = f"VirtualScreen_{len(self.gui_elements)}" + + # 创建虚拟屏幕几何体 + cm = CardMaker(f"virtual-screen-{len(self.gui_elements)}") + cm.setFrame(-size[0] / 2, size[0] / 2, -size[1] / 2, size[1] / 2) + + # 创建挂载节点 - 挂载到选中的父节点 + virtual_screen = parent_node.attachNewNode(cm.generate()) + virtual_screen.setPos(*pos) + virtual_screen.setName(screen_name) + virtual_screen.setColor(0.2, 0.2, 0.2, 0.8) + virtual_screen.setTransparency(TransparencyAttrib.MAlpha) + + # 在虚拟屏幕上添加文本 + screen_text_node = self._create_screen_text(virtual_screen, text, len(self.gui_elements)) + + # 设置节点标签 + virtual_screen.setTag("gui_type", "virtual_screen") + virtual_screen.setTag("gui_id", f"virtual_screen_{len(self.gui_elements)}") + virtual_screen.setTag("gui_text", text) + virtual_screen.setTag("is_gui_element", "1") + virtual_screen.setTag("is_scene_element", "1") + virtual_screen.setTag("created_by_user", "1") + + # 添加到GUI元素列表 + self.gui_elements.append(virtual_screen) + + print(f"✅ 为 {parent_item.text(0)} 创建虚拟屏幕成功: {screen_name}") + + # 在Qt树形控件中添加对应节点 + qt_item = tree_widget.add_node_to_tree_widget(virtual_screen, parent_item, "GUI_VirtualScreen") + if qt_item: + created_screens.append((virtual_screen, qt_item)) + else: + created_screens.append((virtual_screen, None)) + print("⚠️ Qt树节点添加失败,但Panda3D对象已创建") + + except Exception as e: + print(f"❌ 为 {parent_item.text(0)} 创建虚拟屏幕失败: {str(e)}") + continue + + # 处理创建结果 + if not created_screens: + print("❌ 没有成功创建任何虚拟屏幕") + return None + + # 选中最后创建的虚拟屏幕 + if created_screens: + last_screen_np, last_qt_item = created_screens[-1] + if last_qt_item: + tree_widget.setCurrentItem(last_qt_item) + # 更新选择和属性面板 + tree_widget.update_selection_and_properties(last_screen_np, last_qt_item) + + print(f"🎉 总共创建了 {len(created_screens)} 个虚拟屏幕") + + # 返回值处理 + if len(created_screens) == 1: + return created_screens[0][0] # 单个屏幕返回NodePath + else: + return [screen_np for screen_np, _ in created_screens] # 多个屏幕返回列表 + + except Exception as e: + print(f"❌ 创建虚拟屏幕过程失败: {str(e)}") + import traceback + traceback.print_exc() + return None + + def _create_screen_text(self, virtual_screen, text, screen_index): + """为虚拟屏幕创建文本节点""" + try: + from panda3d.core import TextNode + + screen_text = TextNode(f'screen-text-{screen_index}') + screen_text.setText(text) + screen_text.setAlign(TextNode.ACenter) + + # 设置中文字体 + if hasattr(self.world, 'getChineseFont') and self.world.getChineseFont(): + screen_text.setFont(self.world.getChineseFont()) + + # 创建文本节点路径并设置属性 + screen_text_np = virtual_screen.attachNewNode(screen_text) + screen_text_np.setPos(0, 0.01, 0) # 稍微向前偏移避免Z-fighting + screen_text_np.setScale(0.3) + screen_text_np.setColor(0, 1, 0, 1) # 绿色文本 + + print(f"✅ 虚拟屏幕文本创建成功: {text}") + return screen_text_np + + except Exception as e: + print(f"❌ 创建虚拟屏幕文本失败: {str(e)}") + return None + + def _get_tree_widget(self): + """安全获取树形控件""" + try: + if (hasattr(self.world, 'interface_manager') and + hasattr(self.world.interface_manager, 'treeWidget')): + return self.world.interface_manager.treeWidget + except AttributeError: + pass + return None + def createGUISlider(self, pos=(0, 0, 0), text="滑块", scale=0.3): """创建2D GUI滑块""" from direct.gui.DirectGui import DirectSlider - + gui_pos = (pos[0] * 0.1, 0, pos[2] * 0.1) - + slider = DirectSlider( pos=gui_pos, scale=scale, @@ -223,20 +611,21 @@ class GUIManager: frameColor=(0.6, 0.6, 0.6, 1), thumbColor=(0.2, 0.8, 0.2, 1) ) - + slider.setTag("gui_type", "slider") slider.setTag("gui_id", f"slider_{len(self.gui_elements)}") slider.setTag("gui_text", text) slider.setTag("is_gui_element", "1") - + self.gui_elements.append(slider) # 安全地调用updateSceneTree if hasattr(self.world, 'updateSceneTree'): + pass # CH self.world.updateSceneTree() - + print(f"✓ 创建GUI滑块: {text} (逻辑位置: {pos}, 屏幕位置: {gui_pos})") return slider - + # ==================== GUI元素管理方法 ==================== def deleteGUIElement(self, gui_element): @@ -937,7 +1326,7 @@ class GUIManager: """编辑2D GUI元素位置""" try: current_pos = gui_element.getPos() - + if axis == "x": # 将逻辑坐标转换为屏幕坐标 new_screen_x = value * 0.1 @@ -946,8 +1335,8 @@ class GUIManager: # 将逻辑坐标转换为屏幕坐标 new_screen_z = value * 0.1 gui_element.setPos(current_pos.getX(), current_pos.getY(), new_screen_z) - + print(f"更新2D GUI位置: {axis}轴 = {value} (屏幕坐标: {gui_element.getPos()})") - + except Exception as e: print(f"编辑2D GUI位置失败: {str(e)}") \ No newline at end of file diff --git a/icons/move_tool.png b/icons/move_tool.png new file mode 100644 index 0000000000000000000000000000000000000000..37aa5d0e53494cff58b9c133144b1e11083c1013 GIT binary patch literal 3548 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 X9<`b$>y@Cw00000NkvXXu0mjftrXPk literal 0 HcmV?d00001 diff --git a/icons/scale_tool.png b/icons/scale_tool.png new file mode 100644 index 0000000000000000000000000000000000000000..9d95774eca0c9aa637116a2f24a554c52b924949 GIT binary patch literal 2407 zcmZuzdpuO#8s0N)+QXFXl3Npd7`ISFWn40w88eehNSM-H(I}VkDY|^du3U;5mpX}< z`sm^^)lfuAO=Y6!>X%ABOs7a8ic~43pqOvBDc=p)wNUhbpa=cVzA`$qGjA@B<43=@3!S+b#=0u*`g^bq$pS+bB-0 zJ%x_gWa`!z43t8|WQSz-BH%PTUhvYB1do=|=S4Eam{G61HkFKg=BDpO(h|u++{DBr zc4uR^z4@&DWAUOU)BLW9;U3R-hNq7|Gq+fJ1q%xcD=n9D?^({7ffr8H-?%Z&-5DM- z>_GL*pnA(nQ&(zzi}W}^_o&dif(>efL*#S{mf0I`?a#&bemJ`(ElN-L&AzbX+HA_m zoI4w%3$B}xV8PKVg{M>*ALlyVHCzbe*)!QGTPPS$Fg+Ao3=xJ}(d$x*j~#{xm04TE zDwHLezo{%M0!A4Ig3f9JB0{vy{Z(dS?3%Ju9^1bUGc1CK5FKlWJl*s7 z5l9UutN8?zuPC*p7*E>F^O0II$$BtF&ybH8=Jj7I{qP~?i<6bM%E%(F7OuK~`r|ui zQ#^oCpJN$VNh2WytdSi}UflNI<(X!IjWm+RZ{uA!RHC@k6We4E={VoA)(5Mjb+?%Y znek!_LTCMy(Ycf6%WbPBH8xpLW+qkHv^Y?I30MTC9~>X3I4KIhorWEM;;`DcQgpy2 zbPi*?a)cw@!t@IM9k~ysb^%vRU}@z3c_dXbPGAdZ$$NYt=DLlT(pK}0E?G%O+mEb< znK4Le2m1$|?Pn_)KW+K(CBUnnktU-8J zG^D1gqW65?H_vF$bX=RQGx8@@F><1E=C${Semv0rvwdJ{I`)%f>}C%2i{=J%p3FAp zZjx4p!k{bQXIF4%a^haBr6^JO3&Q8~k{i31l^=(h>lRSql-|FmcP!^i`oD-+k}&;G zJ-&2`=z0?sRoQDzm~La>M&YKU zeYi``8F!JG-&}pU&OY|#B%?3575k0*4<9+>FLQRo$GP+((Z?d+*Pz>oDJdb z<(%qDaB80C)||E?BYigZ!nLQGX?WFbCjEcIO|~%=jy@2ct>(qN3S3sZpTB2Ff*tjdw=c{#a3mv} zTKC|887=ad1`WjD8uL{vQDH2na_!+Na0sg43Ho z>bjP?aor4U!lw z!M;RUh%v#aynYvG01teyV|jBiCTQG}fY<@C&`e9%y#_|hs;>|1qyySUvY);Jyu0JA zf1M_!mZ9`=E}otCP#v$=z#MRvy)w40@tC7^%SU!jtci~L(M2J4ubp4B z@VDAm9-bE&?sW`dL~PRc?2L?zB6#s@zu~-JkM;INGs{jaEL(Qp`pwi$RuR-TUts1q$j zP-6^=>0jWSw;za>wDsh3U>2U)UY?sY#g-(c)D$@ZceFdHRf`P>5fB^>Jm-#MF)9!?OoCNsqW)KSU9p^p(#!^!(?9(wK&x zKMs_0u6<59CdO>6bWlV_% aC(VGa2VS5Y-mEzZED)a)z?OM!ApQZjxbp1) literal 0 HcmV?d00001 diff --git a/icons/select_tool.png b/icons/select_tool.png new file mode 100644 index 0000000000000000000000000000000000000000..cfbaa7d4d5044f3e6ea8d4ef389378b8003b943b GIT binary patch literal 5466 zcmZ`-c{Egy`yOL3W68|emo&!M$-Y<2Fc?cC`;sNuimcgHjKMHsEF&a@if9m(kPu~1 zsjPjHeT$SqL!|F?et-Y|xc8p(JolXEp7*}@dGGsV+MTuF;}+)z004YAOEY`cb>QCt z=4728xzfr304M-whIWi&+wi<%zupG7m5M#V@67O#edXC_J9daXELCy^4O`7V0tK9~ zD~Xa^gTw#3Y>@9il@YlxIc~Aun&W;VhfzU2A|^vu%}hXRM00*(_hnf^tuQ!63S>r+ z3g58=lMoNtF_l~?*`PJLQ!dd&mW5D7tu7a#O+bpN9g&YfY_J-*DekQ%i|`M3p-~Zb z@@OSCm&@b1$zc1Jd`^UH79-YUTe9PQ<4^Sd-d^0FpFhK`=T-{Wlpn>gbYjkD%|sL1 z*u}gM9b_{3*>orq*k7)2n0*j-w%nxH=*vW&C>so=QMn>#;amCfr9LJk@2gJDdRrg+ zpencp+o=cRAfZl~QDoGuJ*D96hC6w{T%%lTydY)isaBy>F${_xwp-#MIsYw{VmIKG z<*d4HwCXha{WmAQi3jE<8cO%glMLE5cxqX69}iv&&FgwCCq-ckg!uu2>Z&RkUe)kG z>5K{38W|;({eQ4xRW|xq;{(h2Jd!aPu~p1>b4yi9N%cYS%ju!`l*3TmU|IaI#ub=f zrIW!rkvrC+C;Ni{ab4(?o>1QVj(l_1cj^&{X`#ivBcsqx+Qho*B4@=7Ou2L;biGH6 zX50>R@aB#-QVDA3k<*D>5OoRKkS_rR@ItNa-B0^@81^K)L9p;4e?xu zFQ;gMo2Nr`T`0G`UtXU!_1fAOV+cg1uAq1!eu{LRod@MFU(J`2^TNuo)w687On={a zg`F4_w}@Q)@@deWztRNJ5F6jh$9hyLY6bNTv>wvye82=seY%j=CqQP#{XIPF1WIqh zy0*KwU5GO0R1(frq?e7d`(q+vvI9S47^+Ls(fL>ND&_)Yke1zr4VE)x+Hg5Lil3bkZ zU^TDKYa3>V(MFEbC1(P7l9GNbevZCn)@eB^R4}k)?tLd7`K^HHU>1S2<|^tvBuCy}i&bhtSLq&l?Rns=crnj9Z9Z{(-616x-`J3WNWi1p|$r)PFJLfz|cNUdfUER zQtum3Bws_Jg;dGNEW637d6y&gCDg zp~Xs^`G?nZPaOE_s)ci;?(A0fzKZm89W(b=yoN}(M4*YE( z)@I*#u|XD`k>8u;zy(}JU2EjJVCWzM^9#FMDSb{_MRobilV5-ThQ0LM*mM&BqfTq{ z--}plZv?xqu)6d6vJz2a=aPCh7DHXrw*P9R2$oR!lzYOMa1;O4xBON+`0*PxKeJ!Y znbKTGu^=Aj+7!Blh4n1VWmUI)-Klz4%BXi9VxK7Dv%wuzd?KLk$e^=)VWnTdFUQ=e z#F>tOqVv&p#AQ-4Skt2Zp$#4S{{r2L=w(+cDh|xcGEOmYS=F5kz(hqGoxShJ*{Kc7 z%-|t%^@_g~%mb7OutsOd%z}G7C1d&d`L72K26)J-=rgcqH&+LOL+3tk%<6MICLvy3 zp%rt2?DqE;s$)eCW?WG)-e%{d-3(*p3CcaxIWr3NNK?k-y^pN@o*au4pv^rY@8Y8L&(EyxJ>t^^RMHQD;P@Or1JA&@Xy=SxA;Nn6PtqDQ7^or z?koS2$eLn)g`<6>k5?l{LL8q>g_KqlzA7=&(>=I3*D}*f_0v#Hi!N&LG6QLppi}G- zCTeK-&b|I0n^93`h)bK)9qns9``J8sQ=A(}uym21MY2%2&`c?;_c71wO&4|UPLjWO zi;v+IHZzK@<5e{sV5)AN46c&oZy^>B1vstRJzYWm+~2>%0A1Nf(EKfiLZ(&XDZ2Hm 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 o2LSl{H^Q(Xij|OZ{ 10] - + if not large_scales: print("没有发现大缩放值,无需标准化") return - + print(f"发现 {len(large_scales)} 个节点有大缩放值") - + # 计算标准化因子(基于最常见的大缩放值) common_large_scale = self._findCommonLargeScale(large_scales) if common_large_scale: normalize_factor = 1.0 / common_large_scale print(f"检测到常见大缩放值: {common_large_scale}, 标准化因子: {normalize_factor}") - + # 应用标准化 self._applyScaleNormalization(model, normalize_factor) print("✓ 缩放标准化完成") else: print("无法确定合适的标准化因子,跳过标准化") - + except Exception as e: print(f"缩放标准化失败: {str(e)}") - + def _collectScaleInfo(self, node, scale_info, depth=0): """递归收集节点缩放信息""" try: @@ -421,15 +449,15 @@ class SceneManager: 'scale': scale, 'depth': depth }) - + # 递归处理子节点 for i in range(node.getNumChildren()): child = node.getChild(i) self._collectScaleInfo(child, scale_info, depth + 1) - + except Exception as e: print(f"收集缩放信息失败 ({node.getName()}): {str(e)}") - + def _findCommonLargeScale(self, large_scales): """找到最常见的大缩放值""" try: @@ -439,66 +467,66 @@ class SceneManager: scale = info['scale'] max_scale = max(abs(scale.x), abs(scale.y), abs(scale.z)) scale_values.append(round(max_scale)) # 四舍五入到整数 - + if not scale_values: return None - + # 找到最常见的值 from collections import Counter counter = Counter(scale_values) most_common = counter.most_common(1)[0] - + print(f"缩放值统计: {dict(counter)}") print(f"最常见的大缩放值: {most_common[0]} (出现{most_common[1]}次)") - + # 只有当最常见的值确实很大时才返回 if most_common[0] >= 10: return float(most_common[0]) - + return None - + except Exception as e: print(f"分析常见缩放值失败: {str(e)}") return None - + def _applyScaleNormalization(self, node, normalize_factor, depth=0): """递归应用缩放标准化,同时调整位置以保持视觉一致性""" try: indent = " " * depth current_scale = node.getScale() current_pos = node.getPos() - + # 检查是否需要标准化(只处理明显的大缩放) max_scale_component = max(abs(current_scale.x), abs(current_scale.y), abs(current_scale.z)) - + if max_scale_component > 10: # 只标准化明显的大缩放 # 应用新的缩放 new_scale = current_scale * normalize_factor node.setScale(new_scale) - + # 同时调整位置:当缩放变小时,位置也应该相应变小以保持视觉相对位置 # 这确保了子节点之间的相对距离在视觉上保持一致 new_pos = current_pos * normalize_factor node.setPos(new_pos) - + print(f"{indent}标准化 {node.getName()}:") print(f"{indent} 缩放: {current_scale} -> {new_scale}") print(f"{indent} 位置: {current_pos} -> {new_pos}") - + # 递归处理子节点 for i in range(node.getNumChildren()): child = node.getChild(i) self._applyScaleNormalization(child, normalize_factor, depth + 1) - + except Exception as e: print(f"应用缩放标准化失败 ({node.getName()}): {str(e)}") - + def importModelAsync(self, filepath): """异步导入模型""" try: # 创建异步加载请求 request = self.world.loader.makeAsyncRequest(filepath) - + # 添加完成回调 def modelLoaded(task): if task.isReady(): @@ -507,15 +535,15 @@ class SceneManager: # 处理加载完成的模型 self.processLoadedModel(model) return task.done() - + request.done_event = modelLoaded - + # 开始异步加载 self.world.loader.loadAsync(request) - + except Exception as e: print(f"异步加载模型失败: {str(e)}") - + def loadAnimatedModel(self, model_path, anims=None, auto_play=True): """加载带动画的模型 @@ -570,7 +598,7 @@ class SceneManager: return None # ==================== 材质和几何体处理 ==================== - + def processMaterials(self, model): """处理模型材质""" if isinstance(model.node(), GeomNode): @@ -580,7 +608,7 @@ class SceneManager: material.setDiffuse((0.8, 0.8, 0.8, 1.0)) material.setSpecular((0.5, 0.5, 0.5, 1.0)) material.setShininess(32.0) - + # 检查FBX材质 state = model.node().getGeomState(0) if state.hasAttrib(MaterialAttrib.getClassType()): @@ -591,65 +619,106 @@ class SceneManager: material.setDiffuse(fbx_material.getDiffuse()) material.setSpecular(fbx_material.getSpecular()) material.setShininess(fbx_material.getShininess()) - + # 应用材质 model.setMaterial(material) - + def processModelGeometry(self, model): """处理模型几何体""" # 创建EggData对象 egg_data = EggData() - + # 处理顶点数据 vertex_pool = EggVertexPool("vpool") egg_data.addChild(vertex_pool) - + # 处理几何体 if isinstance(model.node(), GeomNode): for i in range(model.node().getNumGeoms()): geom = model.node().getGeom(i) # 处理几何体数据 # ... - + # ==================== 碰撞系统 ==================== - + def setupCollision(self, model): - """为模型设置碰撞检测""" - # 创建碰撞节点 - cNode = CollisionNode(f'modelCollision_{model.getName()}') - # 设置碰撞掩码 - cNode.setIntoCollideMask(BitMask32.bit(2)) # 使用第2位作为模型的碰撞掩码 - - # 获取模型的边界 - bounds = model.getBounds() - center = bounds.getCenter() - radius = bounds.getRadius() - - # 添加碰撞球体 - cSphere = CollisionSphere(center, radius) - cNode.addSolid(cSphere) - - # 将碰撞节点附加到模型上 - cNodePath = model.attachNewNode(cNode) - #cNodePath.hide() - # cNodePath.show() # 取消注释可以显示碰撞体,用于调试 - + """为模型设置碰撞检测(增强版本)""" + try: + # 创建碰撞节点 + cNode = CollisionNode(f'modelCollision_{model.getName()}') + + # 设置碰撞掩码 + cNode.setIntoCollideMask(BitMask32.bit(2)) # 用于鼠标选择 + + # 如果启用了模型间碰撞检测,添加额外的掩码 + if (hasattr(self.world, 'collision_manager') and + self.world.collision_manager.model_collision_enabled): + # 同时设置模型间碰撞掩码 + current_mask = cNode.getIntoCollideMask() + model_collision_mask = BitMask32.bit(6) # MODEL_COLLISION + cNode.setIntoCollideMask(current_mask | model_collision_mask) + print(f"为 {model.getName()} 启用模型间碰撞检测") + + # 获取模型的边界 + bounds = model.getBounds() + if bounds.isEmpty(): + print(f"⚠️ 模型 {model.getName()} 边界为空,使用默认碰撞体") + # 使用默认的小球体 + cSphere = CollisionSphere(Point3(0, 0, 0), 1.0) + else: + center = bounds.getCenter() + radius = bounds.getRadius() + + # 确保半径不为零 + if radius <= 0: + radius = 1.0 + print(f"⚠️ 模型 {model.getName()} 半径为零,使用默认半径 1.0") + # + # # 添加碰撞球体 + # cSphere = CollisionSphere(center, radius) + cSphere = self.world.collision_manager.createCollisionShape(model, 'polygon') + + cNode.addSolid(cSphere) + + # 将碰撞节点附加到模型上 + cNodePath = model.attachNewNode(cNode) + + # 根据调试设置决定是否显示碰撞体 + if hasattr(self.world, 'debug_collision') and self.world.debug_collision: + cNodePath.show() + else: + cNodePath.hide() + + # 为模型添加碰撞相关标签 + model.setTag("has_collision", "true") + model.setTag("collision_radius", str(radius if 'radius' in locals() else 1.0)) + + print(f"✅ 为模型 {model.getName()} 设置碰撞检测完成") + + return cNodePath + + except Exception as e: + print(f"❌ 为模型 {model.getName()} 设置碰撞检测失败: {str(e)}") + import traceback + traceback.print_exc() + return None + # ==================== 场景树管理 ==================== - + def updateSceneTree(self): """更新场景树显示 - 代理到interface_manager""" if hasattr(self.world, 'interface_manager'): return self.world.interface_manager.updateSceneTree() else: print("界面管理器未初始化,无法更新场景树") - + # ==================== 场景保存和加载 ==================== - + def saveScene(self, filename): """保存场景到BAM文件""" try: print(f"\n=== 开始保存场景到: {filename} ===") - + # 遍历所有模型,保存材质状态和变换信息 for model in self.models: # 保存变换信息(关键!) @@ -660,10 +729,10 @@ class SceneManager: print(f" 位置: {model.getPos()}") print(f" 旋转: {model.getHpr()}") print(f" 缩放: {model.getScale()}") - + # 获取当前状态 state = model.getState() - + # 如果有材质属性,保存为标签 if state.hasAttrib(MaterialAttrib.getClassType()): mat_attrib = state.getAttrib(MaterialAttrib.getClassType()) @@ -677,8 +746,8 @@ class SceneManager: model.setTag("material_shininess", str(material.getShininess())) if material.hasBaseColor(): model.setTag("material_basecolor", str(material.getBaseColor())) - - # 如果有颜色属性,保存为标签 + + # 如果有颜色属性,保存为标签 if state.hasAttrib(ColorAttrib.getClassType()): color_attrib = state.getAttrib(ColorAttrib.getClassType()) if not color_attrib.isOff(): @@ -691,53 +760,53 @@ class SceneManager: except Exception as e: print(f"保存场景时发生错误: {str(e)}") return False - + def loadScene(self, filename): """从BAM文件加载场景""" try: print(f"\n=== 开始加载场景: {filename} ===") - + # 清除当前场景 print("\n清除当前场景...") for model in self.models: model.removeNode() self.models.clear() - + # 加载场景 scene = self.world.loader.loadModel(filename) if not scene: return False - + # 遍历场景中的所有模型节点 def processNode(nodePath, depth=0): indent = " " * depth print(f"{indent}处理节点: {nodePath.getName()}") - + # 跳过render节点的递归 if nodePath.getName() == "render" and depth > 0: print(f"{indent}跳过重复的render节点") return - + # 跳过光源节点 if nodePath.getName() in ["alight", "dlight"]: print(f"{indent}跳过光源节点: {nodePath.getName()}") return - + # 跳过相机节点 if nodePath.getName() in ["camera", "cam"]: print(f"{indent}跳过相机节点: {nodePath.getName()}") return - + if isinstance(nodePath.node(), ModelRoot): print(f"{indent}找到模型根节点!") - + # 清除现有材质状态 nodePath.clearMaterial() nodePath.clearColor() - + # 创建新材质 material = Material() - + # 从标签恢复材质属性 def parseColor(color_str): """解析颜色字符串为Vec4""" @@ -748,7 +817,7 @@ class SceneManager: return Vec4(r, g, b, a) except: return Vec4(1, 1, 1, 1) # 默认白色 - + if nodePath.hasTag("material_ambient"): material.setAmbient(parseColor(nodePath.getTag("material_ambient"))) if nodePath.hasTag("material_diffuse"): @@ -761,14 +830,14 @@ class SceneManager: material.setShininess(float(nodePath.getTag("material_shininess"))) if nodePath.hasTag("material_basecolor"): material.setBaseColor(parseColor(nodePath.getTag("material_basecolor"))) - + # 应用材质 nodePath.setMaterial(material) - + # 恢复颜色属性 if nodePath.hasTag("color"): nodePath.setColor(parseColor(nodePath.getTag("color"))) - + # 恢复变换信息(关键!) def parseVec3(vec_str): """解析向量字符串为Vec3""" @@ -780,52 +849,52 @@ class SceneManager: except Exception as e: print(f"解析向量失败: {vec_str}, 错误: {e}") return Vec3(0, 0, 0) # 默认值 - + if nodePath.hasTag("transform_pos"): pos = parseVec3(nodePath.getTag("transform_pos")) nodePath.setPos(pos) print(f"{indent}恢复位置: {pos}") - + if nodePath.hasTag("transform_hpr"): hpr = parseVec3(nodePath.getTag("transform_hpr")) nodePath.setHpr(hpr) print(f"{indent}恢复旋转: {hpr}") - + if nodePath.hasTag("transform_scale"): scale = parseVec3(nodePath.getTag("transform_scale")) nodePath.setScale(scale) print(f"{indent}恢复缩放: {scale}") - + # 将模型重新挂载到render下 nodePath.wrtReparentTo(self.world.render) - + # 为加载的模型设置碰撞检测 self.setupCollision(nodePath) - + self.models.append(nodePath) - + # 递归处理子节点 for child in nodePath.getChildren(): processNode(child, depth + 1) - + print("\n开始处理场景节点...") processNode(scene) - + # 移除临时场景节点 scene.removeNode() - + # 更新场景树 self.updateSceneTree() - + print("=== 场景加载完成 ===\n") return True - + except Exception as e: print(f"加载场景时发生错误: {str(e)}") return False - + # ==================== 模型管理 ==================== - + def deleteModel(self, model): """删除模型""" try: @@ -890,99 +959,489 @@ class SceneManager: print(f"异步加载模型完成: {model.getName()}") def createSpotLight(self, pos=(0, 0, 0)): - from RenderPipelineFile.rpcore import SpotLight, RenderPipeline - from panda3d.core import Vec3,NodePath + """创建聚光灯 - 支持多选创建,优化版本""" + try: + from RenderPipelineFile.rpcore import SpotLight + from QPanda3D.Panda3DWorld import get_render_pipeline + from panda3d.core import Vec3, NodePath + from PyQt5.QtCore import Qt - render_pipeline = get_render_pipeline() + print(f"🔆 开始创建聚光灯,位置: {pos}") - # 创建一个挂载节点(你控制的) - light_np = NodePath("SpotlightAttachNode") - light_np.reparentTo(self.world.render) - #light_np.setPos(*pos) + # 获取树形控件 + tree_widget = self._get_tree_widget() + if not tree_widget: + print("❌ 无法访问树形控件") + return None - self.half_energy = 5000 - self.lamp_fov = 70 - self.lamp_radius = 1000 + # 获取目标父节点列表 + target_parents = tree_widget.get_target_parents_for_creation() + if not target_parents: + print("❌ 没有找到有效的父节点") + return None - light = SpotLight() - light.direction = Vec3(0, 0, -1) # 光照方向 - light.fov = self.lamp_fov # 光源角度(类似手电筒) - light.set_color_from_temperature(5 * 1000.0) # 色温(K) - light.energy = self.half_energy # 光照强度 - light.radius = self.lamp_radius # 影响范围 - light.casts_shadows = True # 是否投射阴影 - light.shadow_map_resolution = 256 # 阴影分辨率 - light.setPos(*pos) - #light_np.setPos(*pos) + created_lights = [] + render_pipeline = get_render_pipeline() - #light_np = render_pipeline.add_light(light, parent=self.world.render) - render_pipeline.add_light(light) # 添加到渲染管线 + # 为每个有效的父节点创建聚光灯 + for parent_item, parent_node in target_parents: + try: + # 生成唯一名称 + light_name = f"Spotlight_{len(self.Spotlight)}" - light_name = f"Spotlight_{len(self.Spotlight)}" + # 创建挂载节点 - 挂载到选中的父节点 + light_np = NodePath(light_name) + light_np.reparentTo(parent_node) # 挂载到父节点而不是render + light_np.setPos(*pos) - light_np.setName(light_name) # 设置唯一名称 - #light_np.reparentTo(self.world.render) # 挂载到场景根节点 + # 创建聚光灯对象 + light = SpotLight() + light.direction = Vec3(0, 0, -1) + light.fov = 70 + light.set_color_from_temperature(5 * 1000.0) + light.energy = 5000 + light.radius = 1000 + light.casts_shadows = True + light.shadow_map_resolution = 256 - light_np.setTag("light_type", "spot_light") - light_np.setTag("is_scene_element", "1") - light_np.setTag("light_energy", str(light.energy)) + # 设置光源的世界坐标位置 + world_pos = light_np.getPos(self.world.render) + light.setPos(world_pos) - light_np.setPythonTag("rp_light_object", light) + # 添加到渲染管线 + render_pipeline.add_light(light) - self.Spotlight.append(light_np) + # 设置节点属性和标签 + light_np.setTag("light_type", "spot_light") + light_np.setTag("is_scene_element", "1") + light_np.setTag("light_energy", str(light.energy)) + light_np.setTag("created_by_user", "1") - if hasattr(self.world, 'updateSceneTree'): - self.world.updateSceneTree() + # 保存光源对象引用 + light_np.setPythonTag("rp_light_object", light) - #print("nikan"+light_np.getHpr()) + # 添加到管理列表 + self.Spotlight.append(light_np) + + print(f"✅ 为 {parent_item.text(0)} 创建聚光灯成功: {light_name}") + + # 在Qt树形控件中添加对应节点 + qt_item = tree_widget.add_node_to_tree_widget(light_np, parent_item, "LIGHT_NODE") + if qt_item: + created_lights.append((light_np, qt_item)) + else: + created_lights.append((light_np, None)) + print("⚠️ Qt树节点添加失败,但Panda3D对象已创建") + + except Exception as e: + print(f"❌ 为 {parent_item.text(0)} 创建聚光灯失败: {str(e)}") + continue + + # 处理创建结果 + if not created_lights: + print("❌ 没有成功创建任何聚光灯") + return None + + # 选中最后创建的光源 + if created_lights: + last_light_np, last_qt_item = created_lights[-1] + if last_qt_item: + tree_widget.setCurrentItem(last_qt_item) + # 更新选择和属性面板 + tree_widget.update_selection_and_properties(last_light_np, last_qt_item) + + print(f"🎉 总共创建了 {len(created_lights)} 个聚光灯") + + # 返回值处理 + if len(created_lights) == 1: + return created_lights[0][0] # 单个光源返回NodePath + else: + return [light_np for light_np, _ in created_lights] # 多个光源返回列表 + + except Exception as e: + print(f"❌ 创建聚光灯过程失败: {str(e)}") + import traceback + traceback.print_exc() + return None def createPointLight(self, pos=(0, 0, 0)): - from RenderPipelineFile.rpcore import PointLight, RenderPipeline - from panda3d.core import Vec3, NodePath + """创建点光源 - 支持多选创建,优化版本""" + try: + from RenderPipelineFile.rpcore import PointLight + from QPanda3D.Panda3DWorld import get_render_pipeline + from panda3d.core import Vec3, NodePath + from PyQt5.QtCore import Qt - render_pipeline = get_render_pipeline() + print(f"💡 开始创建点光源,位置: {pos}") - # 创建一个挂载节点(你控制的) - light_np = NodePath("PointlightAttachNode") - light_np.reparentTo(self.world.render) + # 获取树形控件 + tree_widget = self._get_tree_widget() + if not tree_widget: + print("❌ 无法访问树形控件") + return None + # 获取目标父节点列表 + target_parents = tree_widget.get_target_parents_for_creation() + if not target_parents: + print("❌ 没有找到有效的父节点") + return None - light = PointLight() - light.setPos(*pos) - light_np.setPos(*pos) - light.energy = 5000 - light.radius = 1000 - light.inner_radius = 0.4 - light.set_color_from_temperature(5 * 1000.0) # 色温(K) - light.casts_shadows = True # 是否投射阴影 - light.shadow_map_resolution = 256 # 阴影分辨率 + created_lights = [] + render_pipeline = get_render_pipeline() - render_pipeline.add_light(light) # 添加到渲染管线 + # 为每个有效的父节点创建点光源 + for parent_item, parent_node in target_parents: + try: + # 生成唯一名称 + light_name = f"Pointlight_{len(self.Pointlight)}" - light_name = f"Pointlight{len(self.Pointlight)}" + # 创建挂载节点 - 挂载到选中的父节点 + light_np = NodePath(light_name) + light_np.reparentTo(parent_node) # 挂载到父节点而不是render + light_np.setPos(*pos) - light_np.setName(light_name) # 设置唯一名称 + # 创建点光源对象 + light = PointLight() - #light_np = NodePath(f"PointLight_{len(self.Pointlight)}") - #light_np.reparentTo(self.world.render) - #light_np.setPos(*pos) + # 设置光源的世界坐标位置 + world_pos = light_np.getPos(self.world.render) + light.setPos(world_pos) - light_np.setTag("light_type", "point_light") - light_np.setTag("is_scene_element", "1") - light_np.setTag("light_energy", str(light.energy)) + light.energy = 5000 + light.radius = 1000 + light.inner_radius = 0.4 + light.set_color_from_temperature(5 * 1000.0) + light.casts_shadows = True + light.shadow_map_resolution = 256 - # 保存光源对象引用(重要!用于属性面板) - light_np.setPythonTag("rp_light_object", light) + # 添加到渲染管线 + render_pipeline.add_light(light) - self.Pointlight.append(light_np) + # 设置节点属性和标签 + light_np.setTag("light_type", "point_light") + light_np.setTag("is_scene_element", "1") + light_np.setTag("light_energy", str(light.energy)) + light_np.setTag("created_by_user", "1") - if hasattr(self.world, 'updateSceneTree'): - self.world.updateSceneTree() + # 保存光源对象引用 + light_np.setPythonTag("rp_light_object", light) - return light,light_np + # 添加到管理列表 + self.Pointlight.append(light_np) + + print(f"✅ 为 {parent_item.text(0)} 创建点光源成功: {light_name}") + + # 在Qt树形控件中添加对应节点 + qt_item =tree_widget.add_node_to_tree_widget(light_np, parent_item, "LIGHT_NODE") + if qt_item: + created_lights.append((light_np, qt_item)) + else: + created_lights.append((light_np, None)) + print("⚠️ Qt树节点添加失败,但Panda3D对象已创建") + + except Exception as e: + print(f"❌ 为 {parent_item.text(0)} 创建点光源失败: {str(e)}") + continue + + # 处理创建结果 + if not created_lights: + print("❌ 没有成功创建任何点光源") + return None + + # 选中最后创建的光源 + if created_lights: + last_light_np, last_qt_item = created_lights[-1] + if last_qt_item: + tree_widget.setCurrentItem(last_qt_item) + # 更新选择和属性面板 + tree_widget.update_selection_and_properties(last_light_np, last_qt_item) + + print(f"🎉 总共创建了 {len(created_lights)} 个点光源") + + # 返回值处理 + if len(created_lights) == 1: + return created_lights[0][0] # 单个光源返回NodePath + else: + return [light_np for light_np, _ in created_lights] # 多个光源返回列表 + + except Exception as e: + print(f"❌ 创建点光源过程失败: {str(e)}") + import traceback + traceback.print_exc() + return None + + def _get_tree_widget(self): + """安全获取树形控件""" + try: + if (hasattr(self.world, 'interface_manager') and + hasattr(self.world.interface_manager, 'treeWidget')): + return self.world.interface_manager.treeWidget + except AttributeError: + pass + return None + + def _importModelSingle(self, filepath, apply_unit_conversion=False, normalize_scales=True, auto_convert_to_glb=True): + """传统单一模型导入方法(兼容性保留)""" + try: + print(f"\n=== 使用传统模式导入模型: {filepath} ===") + + filepath = util.normalize_model_path(filepath) + original_filepath = filepath + + # 检查是否需要转换为GLB + if auto_convert_to_glb and self._shouldConvertToGLB(filepath): + print(f"🔄 检测到需要转换的格式,尝试转换为GLB...") + converted_path = self._convertToGLBWithProgress(filepath) + if converted_path: + print(f"✅ 转换成功: {converted_path}") + filepath = converted_path + try: + from PyQt5.QtWidgets import QMessageBox + original_ext = os.path.splitext(original_filepath)[1].upper() + QMessageBox.information(None, "转换成功", + f"已将 {original_ext} 格式自动转换为 GLB 格式\n以获得更好的动画支持!") + except: + pass + else: + print(f"⚠️ 转换失败,使用原始文件") + + # 加载模型 + print("直接从文件加载模型...") + model = self.world.loader.loadModel(filepath) + if not model: + print("加载模型失败") + return None + + # 设置模型名称 + model_name = os.path.basename(filepath) + model.setName(model_name) + + # 将模型添加到场景 + model.reparentTo(self.world.render) + + # 设置标签和路径信息 + model.setTag("model_path", filepath) + model.setTag("original_path", original_filepath) + model.setTag("file", model_name) + model.setTag("is_model_root", "1") + model.setTag("is_scene_element", "1") + model.setTag("created_by_user", "1") + + if filepath != original_filepath: + model.setTag("converted_from", os.path.splitext(original_filepath)[1]) + model.setTag("converted_to_glb", "true") + + # 应用处理选项 + if apply_unit_conversion and filepath.lower().endswith('.fbx'): + print("应用FBX单位转换(厘米到米)...") + self._applyUnitConversion(model, 0.01) + model.setTag("unit_conversion_applied", "true") + + if normalize_scales and filepath.lower().endswith('.fbx'): + print("标准化FBX模型缩放层级...") + self._normalizeModelScales(model) + model.setTag("scale_normalization_applied", "true") + + # 调整模型位置到地面 + self._adjustModelToGround(model) + + # 创建并设置基础材质 + print("\n=== 开始设置材质 ===") + self._applyMaterialsToModel(model) + + # 设置碰撞检测(重要!用于选择功能) + print("\n=== 设置碰撞检测 ===") + self.setupCollision(model) + + # 添加到模型列表 + self.models.append(model) + + # 更新场景树 + self.updateSceneTree() + + print(f"=== 模型导入成功: {model_name} ===\n") + return model + + except Exception as e: + print(f"导入模型失败: {str(e)}") + return None + + # def createSpotLight(self, pos=(0, 0, 0)): + # """创建聚光灯 - 使用统一的create_item方法""" + # try: + # # 调用CustomTreeWidget的create_item方法创建聚光灯节点 + # if hasattr(self.world, 'interface_manager') and hasattr(self.world.interface_manager, 'treeWidget'): + # tree_widget = self.world.interface_manager.treeWidget + # if tree_widget and hasattr(tree_widget, 'create_item'): + # # 创建聚光灯节点 + # created_nodes = tree_widget.create_item("spot_light") + # + # if created_nodes: + # # 获取创建的节点 + # light_np, qt_item = created_nodes[0] + # + # # 设置位置(如果指定了非默认位置) + # if pos != (0, 0, 0): + # light_np.setPos(*pos) + # # 同时更新光源对象的位置 + # light_obj = light_np.getPythonTag("rp_light_object") + # if light_obj: + # light_obj.setPos(*pos) + # + # print(f"✅ 通过create_item创建聚光灯成功: {light_np.getName()}") + # return light_np + # else: + # print("❌ create_item创建聚光灯失败") + # return None + # else: + # print("❌ 无法访问树形控件的create_item方法") + # return None + # else: + # print("❌ 无法访问界面管理器或树形控件") + # return None + # + # except Exception as e: + # print(f"❌ 创建聚光灯时发生错误: {str(e)}") + # import traceback + # traceback.print_exc() + # return None + + # def createSpotLight(self, pos=(0, 0, 0)): + # from RenderPipelineFile.rpcore import SpotLight, RenderPipeline + # from panda3d.core import Vec3,NodePath + # + # render_pipeline = get_render_pipeline() + # + # # 创建一个挂载节点(你控制的) + # light_np = NodePath("SpotlightAttachNode") + # light_np.reparentTo(self.world.render) + # #light_np.setPos(*pos) + # + # self.half_energy = 5000 + # self.lamp_fov = 70 + # self.lamp_radius = 1000 + # + # light = SpotLight() + # light.direction = Vec3(0, 0, -1) # 光照方向 + # light.fov = self.lamp_fov # 光源角度(类似手电筒) + # light.set_color_from_temperature(5 * 1000.0) # 色温(K) + # light.energy = self.half_energy # 光照强度 + # light.radius = self.lamp_radius # 影响范围 + # light.casts_shadows = True # 是否投射阴影 + # light.shadow_map_resolution = 256 # 阴影分辨率 + # light.setPos(*pos) + # #light_np.setPos(*pos) + # + # #light_np = render_pipeline.add_light(light, parent=self.world.render) + # render_pipeline.add_light(light) # 添加到渲染管线 + # + # light_name = f"Spotlight_{len(self.Spotlight)}" + # + # light_np.setName(light_name) # 设置唯一名称 + # #light_np.reparentTo(self.world.render) # 挂载到场景根节点 + # + # light_np.setTag("light_type", "spot_light") + # light_np.setTag("is_scene_element", "1") + # light_np.setTag("light_energy", str(light.energy)) + # + # light_np.setPythonTag("rp_light_object", light) + # + # self.Spotlight.append(light_np) + # + # if hasattr(self.world, 'updateSceneTree'): + # self.world.updateSceneTree() + # + # #print("nikan"+light_np.getHpr()) + + # def createPointLight(self, pos=(0, 0, 0)): + # """创建点光源 - 使用统一的create_item方法""" + # try: + # # 调用CustomTreeWidget的create_item方法创建点光源节点 + # if hasattr(self.world, 'interface_manager') and hasattr(self.world.interface_manager, 'treeWidget'): + # tree_widget = self.world.interface_manager.treeWidget + # if tree_widget and hasattr(tree_widget, 'create_item'): + # # 创建点光源节点 + # created_nodes = tree_widget.create_item("point_light") + # + # if created_nodes: + # # 获取创建的节点 + # light_np, qt_item = created_nodes[0] + # + # # 设置位置(如果指定了非默认位置) + # if pos != (0, 0, 0): + # light_np.setPos(*pos) + # # 同时更新光源对象的位置 + # light_obj = light_np.getPythonTag("rp_light_object") + # if light_obj: + # light_obj.setPos(*pos) + # + # print(f"✅ 通过create_item创建点光源成功: {light_np.getName()}") + # return light_np + # else: + # print("❌ create_item创建点光源失败") + # return None + # else: + # print("❌ 无法访问树形控件的create_item方法") + # return None + # else: + # print("❌ 无法访问界面管理器或树形控件") + # return None + # + # except Exception as e: + # print(f"❌ 创建点光源时发生错误: {str(e)}") + # import traceback + # traceback.print_exc() + # return None + + # def createPointLight(self, pos=(0, 0, 0)): + # from RenderPipelineFile.rpcore import PointLight, RenderPipeline + # from panda3d.core import Vec3, NodePath + # + # render_pipeline = get_render_pipeline() + # + # # 创建一个挂载节点(你控制的) + # light_np = NodePath("PointlightAttachNode") + # light_np.reparentTo(self.world.render) + # + # + # light = PointLight() + # light.setPos(*pos) + # light_np.setPos(*pos) + # light.energy = 5000 + # light.radius = 1000 + # light.inner_radius = 0.4 + # light.set_color_from_temperature(5 * 1000.0) # 色温(K) + # light.casts_shadows = True # 是否投射阴影 + # light.shadow_map_resolution = 256 # 阴影分辨率 + # + # render_pipeline.add_light(light) # 添加到渲染管线 + # + # light_name = f"Pointlight{len(self.Pointlight)}" + # + # light_np.setName(light_name) # 设置唯一名称 + # + # #light_np = NodePath(f"PointLight_{len(self.Pointlight)}") + # #light_np.reparentTo(self.world.render) + # #light_np.setPos(*pos) + # + # light_np.setTag("light_type", "point_light") + # light_np.setTag("is_scene_element", "1") + # light_np.setTag("light_energy", str(light.energy)) + # + # # 保存光源对象引用(重要!用于属性面板) + # light_np.setPythonTag("rp_light_object", light) + # + # self.Pointlight.append(light_np) + # + # if hasattr(self.world, 'updateSceneTree'): + # self.world.updateSceneTree() + # + # return light,light_np # ==================== GLB 转换方法 ==================== - + + #=================================================== + def _shouldConvertToGLB(self, filepath): """判断是否应该转换为GLB格式""" ext = os.path.splitext(filepath)[1].lower() diff --git a/ui/interface_manager.py b/ui/interface_manager.py index b3fbd8f3..6a3aa771 100644 --- a/ui/interface_manager.py +++ b/ui/interface_manager.py @@ -18,8 +18,8 @@ class InterfaceManager: self.treeWidget = treeWidget # 添加右键菜单 - self.treeWidget.setContextMenuPolicy(Qt.CustomContextMenu) - self.treeWidget.customContextMenuRequested.connect(self.showTreeContextMenu) + # self.treeWidget.setContextMenuPolicy(Qt.CustomContextMenu) + # self.treeWidget.customContextMenuRequested.connect(self.showTreeContextMenu) # 更新场景树 self.world.scene_manager.updateSceneTree() @@ -155,10 +155,12 @@ class InterfaceManager: # 创建场景根节点 sceneRoot = QTreeWidgetItem(self.treeWidget, ['场景']) - + sceneRoot.setData(0, Qt.UserRole, self.world.render) + sceneRoot.setData(0, Qt.UserRole + 1, "SCENE_ROOT") # 添加相机节点 cameraItem = QTreeWidgetItem(sceneRoot, ['相机']) cameraItem.setData(0, Qt.UserRole, self.world.cam) + cameraItem.setData(0, Qt.UserRole + 1, "CAMERA_NODE") print("添加相机节点") # # 添加模型节点组 @@ -223,6 +225,7 @@ class InterfaceManager: if hasattr(self.world, 'ground') and self.world.ground: groundItem = QTreeWidgetItem(sceneRoot, ['地板']) groundItem.setData(0, Qt.UserRole, self.world.ground) + groundItem.setData(0,Qt.UserRole + 1, "SCENE_NODE") # 展开所有节点 #self.treeWidget.expandAll() diff --git a/ui/main_window.py b/ui/main_window.py index bd90fd3b..ed278dac 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -8,13 +8,15 @@ """ import sys + +from PyQt5.QtGui import QKeySequence, QIcon from PyQt5.QtWidgets import (QApplication, QMainWindow, QMenuBar, QMenu, QAction, QDockWidget, QTreeWidget, QListWidget, QWidget, QVBoxLayout, QTreeWidgetItem, QLabel, QLineEdit, QFormLayout, QDoubleSpinBox, QScrollArea, QFileSystemModel, QButtonGroup, QToolButton, QPushButton, QHBoxLayout, - QComboBox, QGroupBox, QInputDialog, QFileDialog, QMessageBox, QDesktopWidget) -from PyQt5.QtCore import Qt, QDir, QTimer -from ui.widgets import CustomPanda3DWidget, CustomFileView, CustomTreeWidget + QComboBox, QGroupBox, QInputDialog, QFileDialog, QMessageBox, QDesktopWidget, QFrame) +from PyQt5.QtCore import Qt, QDir, QTimer, QSize, QPoint +from ui.widgets import CustomPanda3DWidget, CustomFileView, CustomTreeWidget, CustomAssetsTreeWidget, CustomConsoleDockWidget class MainWindow(QMainWindow): """主窗口类""" @@ -22,15 +24,17 @@ class MainWindow(QMainWindow): def __init__(self, world): super().__init__() self.world = world + self.world.main_window = self # 关键:让world对象能访问主窗口 + self.setupCenterWidget() # 创建中间部分Panda3D self.setupMenus() # 创建菜单栏 self.setupDockWindows() - self.setupToolbar() + # self.setupToolbar() self.connectEvents() # 移动窗口到屏幕中央 self.move_center() - + # 创建定时器来更新脚本管理面板状态 self.updateTimer = QTimer() self.updateTimer.timeout.connect(self.updateScriptPanel) @@ -44,6 +48,9 @@ class MainWindow(QMainWindow): self.pandaWidget = CustomPanda3DWidget(self.world) self.setCentralWidget(self.pandaWidget) + # 创建内嵌工具栏 + self.setupEmbeddedToolbar() + def move_center(self): """设置窗口居中显示""" self.setGeometry(50, 50, 1920, 1080) @@ -54,10 +61,208 @@ class MainWindow(QMainWindow): int(screen.height() / 2 - self.height() / 2), ) + def setupEmbeddedToolbar(self): + """创建Unity风格的内嵌工具栏""" + # 创建工具栏容器 + self.embeddedToolbar = QFrame(self.pandaWidget) + self.embeddedToolbar.setObjectName("UnityToolbar") + self.embeddedToolbar.setStyleSheet(""" + QFrame#UnityToolbar { + background-color: rgba(240, 240, 240, 180); + border: 1px solid rgba(200, 200, 200, 200); + border-radius: 4px; + } + QToolButton { + background-color: rgba(250, 250, 250, 150); + border: none; + color: #333333; + padding: 5px; + border-radius: 3px; + } + QToolButton:hover { + background-color: rgba(220, 220, 220, 200); + } + QToolButton:checked { + background-color: rgba(100, 150, 220, 180); + color: white; + } + QToolButton:pressed { + background-color: rgba(80, 130, 200, 200); + } + """) + + # 水平布局 + self.toolbarLayout = QHBoxLayout(self.embeddedToolbar) + self.toolbarLayout.setContentsMargins(5, 5, 5, 5) + self.toolbarLayout.setSpacing(2) + + # 创建工具按钮组 + self.toolGroup = QButtonGroup() + + # 选择工具 + self.selectTool = QToolButton() + self.selectTool.setIcon(QIcon("icons/select_tool.png")) # 使用图标资源 + self.selectTool.setText('选择') + self.selectTool.setIconSize(QSize(16, 16)) + self.selectTool.setCheckable(True) + self.selectTool.setToolTip("选择工具 (Q)") + # self.selectTool.setShortcut(QKeySequence("Q")) + self.toolGroup.addButton(self.selectTool) + self.toolbarLayout.addWidget(self.selectTool) + + # 移动工具 + self.moveTool = QToolButton() + self.moveTool.setIcon(QIcon("icons/move_tool.png")) + self.moveTool.setText('移动') + self.moveTool.setIconSize(QSize(16, 16)) + self.moveTool.setCheckable(True) + self.moveTool.setToolTip("移动工具 (W)") + # self.moveTool.setShortcut(QKeySequence("W")) + self.toolGroup.addButton(self.moveTool) + self.toolbarLayout.addWidget(self.moveTool) + + # 旋转工具 + self.rotateTool = QToolButton() + self.rotateTool.setIcon(QIcon("icons/rotate_tool.png")) + self.rotateTool.setText('旋转') + self.rotateTool.setIconSize(QSize(16, 16)) + self.rotateTool.setCheckable(True) + self.rotateTool.setToolTip("旋转工具 (E)") + # self.rotateTool.setShortcut(QKeySequence("E")) + self.toolGroup.addButton(self.rotateTool) + self.toolbarLayout.addWidget(self.rotateTool) + + # 缩放工具 + self.scaleTool = QToolButton() + self.scaleTool.setIcon(QIcon("icons/scale_tool.png")) + self.scaleTool.setText('缩放') + self.scaleTool.setIconSize(QSize(16, 16)) + self.scaleTool.setCheckable(True) + self.scaleTool.setToolTip("缩放工具 (R)") + # self.scaleTool.setShortcut(QKeySequence("R")) + self.toolGroup.addButton(self.scaleTool) + self.toolbarLayout.addWidget(self.scaleTool) + + # 添加分隔符 + separator = QFrame() + separator.setFrameShape(QFrame.VLine) + separator.setFrameShadow(QFrame.Sunken) + separator.setStyleSheet("background-color: rgba(240, 240, 240, 255);") + separator.setFixedWidth(1) + self.toolbarLayout.addWidget(separator) + + # 设置位置到左上角 + self.embeddedToolbar.move(10, 10) + self.embeddedToolbar.adjustSize() + self.embeddedToolbar.show() + + # 连接事件 + self.toolGroup.buttonClicked.connect(self.onToolChanged) + # 设置工具栏拖拽事件 + self.embeddedToolbar.mousePressEvent = self.toolbarMousePressEvent + self.embeddedToolbar.mouseMoveEvent = self.toolbarMouseMoveEvent + self.embeddedToolbar.mouseReleaseEvent = self.toolbarMouseReleaseEvent + + # 默认选择"选择"工具 + self.selectTool.setChecked(True) + self.world.setCurrentTool("选择") + + def toolbarMousePressEvent(self, event): + """工具栏鼠标按下事件""" + if event.button() == Qt.LeftButton: + self.toolbarDragging = True + self.dragStartPos = event.globalPos() + self.toolbarStartPos = self.embeddedToolbar.pos() + event.accept() + + def toolbarMouseMoveEvent(self, event): + """工具栏鼠标移动事件""" + if self.toolbarDragging and event.buttons() == Qt.LeftButton: + # 计算新位置 + delta = event.globalPos() - self.dragStartPos + new_pos = self.toolbarStartPos + delta + + # 边界检测 + panda_rect = self.pandaWidget.geometry() + toolbar_size = self.embeddedToolbar.size() + + # 限制在Panda3D区域内 + new_pos.setX(max(0, min(new_pos.x(), panda_rect.width() - toolbar_size.width()))) + new_pos.setY(max(0, min(new_pos.y(), panda_rect.height() - toolbar_size.height()))) + + self.embeddedToolbar.move(new_pos) + event.accept() + + def toolbarMouseReleaseEvent(self, event): + """工具栏鼠标释放事件""" + if event.button() == Qt.LeftButton and self.toolbarDragging: + self.toolbarDragging = False + + # 自动吸附到最近的预设位置 + self.snapToNearestPosition() + event.accept() + + def snapToNearestPosition(self): + """自动吸附到最近的预设位置""" + current_pos = self.embeddedToolbar.pos() + panda_rect = self.pandaWidget.geometry() + toolbar_size = self.embeddedToolbar.size() + + margin = 10 + + # 定义所有预设位置 + positions = { + "top_center": QPoint( + (panda_rect.width() - toolbar_size.width()) // 2, + margin + ), + "top_left": QPoint(margin, margin), + "top_right": QPoint( + panda_rect.width() - toolbar_size.width() - margin, + margin + ), + "bottom_center": QPoint( + (panda_rect.width() - toolbar_size.width()) // 2, + panda_rect.height() - toolbar_size.height() - margin + ), + "bottom_left": QPoint( + margin, + panda_rect.height() - toolbar_size.height() - margin + ), + "bottom_right": QPoint( + panda_rect.width() - toolbar_size.width() - margin, + panda_rect.height() - toolbar_size.height() - margin + ) + } + + # 找到最近的位置 + min_distance = float('inf') + nearest_position = "top_center" + + for pos_name, pos_point in positions.items(): + distance = ((current_pos.x() - pos_point.x()) ** 2 + + (current_pos.y() - pos_point.y()) ** 2) ** 0.5 + if distance < min_distance: + min_distance = distance + nearest_position = pos_name + + # 更新位置并平滑移动 + self.toolbarPosition = nearest_position + target_pos = positions[nearest_position] + + # 简单的平滑移动动画 + from PyQt5.QtCore import QPropertyAnimation, QEasingCurve + self.toolbarAnimation = QPropertyAnimation(self.embeddedToolbar, b"pos") + self.toolbarAnimation.setDuration(200) + self.toolbarAnimation.setStartValue(current_pos) + self.toolbarAnimation.setEndValue(target_pos) + self.toolbarAnimation.setEasingCurve(QEasingCurve.OutCubic) + self.toolbarAnimation.start() + def setupMenus(self): """创建菜单栏""" menubar = self.menuBar() - + # 文件菜单 self.fileMenu = menubar.addMenu('文件') self.newAction = self.fileMenu.addAction('新建') @@ -66,7 +271,7 @@ class MainWindow(QMainWindow): self.buildAction = self.fileMenu.addAction('打包') self.fileMenu.addSeparator() self.exitAction = self.fileMenu.addAction('退出') - + # 编辑菜单 self.editMenu = menubar.addMenu('编辑') self.undoAction = self.editMenu.addAction('撤销') @@ -75,7 +280,7 @@ class MainWindow(QMainWindow): self.cutAction = self.editMenu.addAction('剪切') self.copyAction = self.editMenu.addAction('复制') self.pasteAction = self.editMenu.addAction('粘贴') - + # 视图菜单 self.viewMenu = menubar.addMenu('视图') self.viewPerspectiveAction = self.viewMenu.addAction('透视图') @@ -83,7 +288,7 @@ class MainWindow(QMainWindow): self.viewFrontAction = self.viewMenu.addAction('前视图') self.viewMenu.addSeparator() self.viewGridAction = self.viewMenu.addAction('显示网格') - + # 工具菜单 self.toolsMenu = menubar.addMenu('工具') self.selectAction = self.toolsMenu.addAction('选择工具') @@ -93,39 +298,19 @@ class MainWindow(QMainWindow): self.sunsetAction = self.toolsMenu.addAction('光照编辑') self.pluginAction = self.toolsMenu.addAction('图形编辑') - # 创建菜单 + # 统一创建菜单 - 关键修改 self.createMenu = menubar.addMenu('创建') - self.createEnptyaddAction = self.createMenu.addAction('空对象') - self.create3dObjectaddMenu = self.createMenu.addMenu('3D对象') + self.setupCreateMenuActions() # 统一创建菜单动作 - self.create3dGUIaddMenu = self.createMenu.addMenu('3D GUI') - self.create3DTextAction = self.create3dGUIaddMenu.addAction('3D文本') - - self.createGUIaddMenu = self.createMenu.addMenu('GUI') - self.createButtonAction = self.createGUIaddMenu.addAction('创建按钮') - self.createLabelAction = self.createGUIaddMenu.addAction('创建标签') - self.createEntryAction = self.createGUIaddMenu.addAction('创建输入框') - self.createGUIaddMenu.addSeparator() - self.createVirtualScreenAction = self.createGUIaddMenu.addAction('创建虚拟屏幕') - - self.createLightaddMenu = self.createMenu.addMenu('光源') - self.createSpotLightAction = self.createLightaddMenu.addAction('聚光灯') - self.createPointLightAction = self.createLightaddMenu.addAction('点光源') - - # GUI菜单 + # GUI菜单 - 复用创建菜单的动作 self.guiMenu = menubar.addMenu('GUI') self.guiEditModeAction = self.guiMenu.addAction('进入GUI编辑模式') self.guiMenu.addSeparator() - # self.createButtonAction = self.guiMenu.addAction('创建按钮') - # self.createLabelAction = self.guiMenu.addAction('创建标签') - # self.createEntryAction = self.guiMenu.addAction('创建输入框') self.guiMenu.addAction(self.createButtonAction) self.guiMenu.addAction(self.createLabelAction) self.guiMenu.addAction(self.createEntryAction) self.guiMenu.addSeparator() - # self.create3DTextAction = self.guiMenu.addAction('创建3D文本') self.guiMenu.addAction(self.create3DTextAction) - # self.createVirtualScreenAction = self.guiMenu.addAction('创建虚拟屏幕') self.guiMenu.addAction(self.createVirtualScreenAction) # 脚本菜单 @@ -139,37 +324,100 @@ class MainWindow(QMainWindow): self.toggleHotReloadAction.setChecked(True) # 默认启用 self.scriptMenu.addSeparator() self.openScriptsManagerAction = self.scriptMenu.addAction('脚本管理器') - + # 帮助菜单 self.helpMenu = menubar.addMenu('帮助') self.aboutAction = self.helpMenu.addAction('关于') - + + def setupCreateMenuActions(self): + """统一设置创建菜单的所有动作 - 避免重复代码""" + # 基础对象 + self.createEnptyaddAction = self.createMenu.addAction('空对象') + + # 3D对象子菜单 + self.create3dObjectaddMenu = self.createMenu.addMenu('3D对象') + # 可以在这里添加更多3D对象类型 + + # 3D GUI子菜单 + self.create3dGUIaddMenu = self.createMenu.addMenu('3D GUI') + self.create3DTextAction = self.create3dGUIaddMenu.addAction('3D文本') + + # GUI子菜单 + self.createGUIaddMenu = self.createMenu.addMenu('GUI') + self.createButtonAction = self.createGUIaddMenu.addAction('创建按钮') + self.createLabelAction = self.createGUIaddMenu.addAction('创建标签') + self.createEntryAction = self.createGUIaddMenu.addAction('创建输入框') + self.createGUIaddMenu.addSeparator() + self.createVideoScreen = self.createGUIaddMenu.addAction('创建视频屏幕') + self.createSphericalVideo = self.createGUIaddMenu.addAction('创建球形视频') + self.createGUIaddMenu.addSeparator() + self.createVirtualScreenAction = self.createGUIaddMenu.addAction('创建虚拟屏幕') + + # 光源子菜单 + self.createLightaddMenu = self.createMenu.addMenu('光源') + self.createSpotLightAction = self.createLightaddMenu.addAction('聚光灯') + self.createPointLightAction = self.createLightaddMenu.addAction('点光源') + + # 统一连接信号到处理方法 + self.connectCreateMenuActions() + + def connectCreateMenuActions(self): + """统一连接创建菜单的信号到处理方法""" + # 连接到world对象的创建方法 + # self.createEnptyaddAction.triggered.connect(self.world.createEmptyObject) + self.create3DTextAction.triggered.connect(lambda: self.world.createGUI3DText()) + self.createButtonAction.triggered.connect(lambda: self.world.createGUIButton()) + self.createLabelAction.triggered.connect(lambda: self.world.createGUILabel()) + self.createEntryAction.triggered.connect(lambda: self.world.createGUIEntry()) + # self.createVideoScreen.triggered.connect(self.world.createVideoScreen) + # self.createSphericalVideo.triggered.connect(self.world.createSphericalVideo) + # self.createVirtualScreenAction.triggered.connect(self.world.createVirtualScreen) + self.createSpotLightAction.triggered.connect(lambda :self.world.createSpotLight()) + self.createPointLightAction.triggered.connect(lambda :self.world.createPointLight()) + + # # self.createVideoScreen.triggered.connect(lambda: self.world.video_manager.create_video_screen( + # # pos=(0, 0, 2), + # # size=(4, 3), + # # name=f"video_screen_{len(self.world.video_manager.video_objects) if hasattr(self.world, 'video_manager') else 0}" + # # )) + # # self.createSphericalVideo.triggered.connect(lambda: self.world.createGUISphericalVideo()) + # self.createVirtualScreenAction.triggered.connect(lambda: self.world.create_spherical_video()) + + def getCreateMenuActions(self): + """获取所有创建菜单动作的字典 - 供右键菜单使用""" + return { + 'createEmpty': self.createEnptyaddAction, + 'create3DText': self.create3DTextAction, + 'createButton': self.createButtonAction, + 'createLabel': self.createLabelAction, + 'createEntry': self.createEntryAction, + 'createVideoScreen': self.createVideoScreen, + 'createSphericalVideo': self.createSphericalVideo, + 'createVirtualScreen': self.createVirtualScreenAction, + 'createSpotLight': self.createSpotLightAction, + 'createPointLight': self.createPointLightAction, + } + def setupDockWindows(self): """创建停靠窗口""" # 创建左侧停靠窗口(层级窗口) self.leftDock = QDockWidget("层级", self) - self.leftDock.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) self.treeWidget = CustomTreeWidget(self.world) self.world.setTreeWidget(self.treeWidget) # 设置树形控件引用 self.leftDock.setWidget(self.treeWidget) - # self.leftDock.setMinimumWidth(300) self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.leftDock) - # 创建右侧停靠窗口(属性窗口) self.rightDock = QDockWidget("属性", self) - self.rightDock.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) # 创建属性面板的主容器和布局 self.propertyContainer = QWidget() self.propertyContainer.setObjectName("PropertyContainer") self.propertyLayout = QVBoxLayout(self.propertyContainer) - # self.propertyLayout = QFormLayout(self.propertyContainer) # 添加初始提示信息 tipLabel = QLabel("") tipLabel.setStyleSheet("color: gray;") # 使用灰色字体 - # self.propertyLayout.addRow(tipLabel) self.propertyLayout.addWidget(tipLabel) # 创建滚动区域并设置属性 @@ -185,81 +433,109 @@ class MainWindow(QMainWindow): # 设置属性面板到世界对象 self.world.setPropertyLayout(self.propertyLayout) - # 创建脚本管理停靠窗口 self.scriptDock = QDockWidget("脚本管理", self) - self.scriptDock.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) - self.setupScriptPanel() + + # 创建脚本面板的主容器和布局(与属性面板相同结构) + self.scriptContainer = QWidget() + self.scriptContainer.setObjectName("ScriptContainer") + self.scriptLayout = QVBoxLayout(self.scriptContainer) + + # 创建滚动区域并设置属性 + self.scriptScrollArea = QScrollArea() + self.scriptScrollArea.setWidgetResizable(True) + self.scriptScrollArea.setWidget(self.scriptContainer) + + # 设置滚动区域为停靠窗口的主部件 + self.scriptDock.setWidget(self.scriptScrollArea) + self.scriptDock.setMinimumWidth(300) + + # 设置脚本面板内容 + self.setupScriptPanel(self.scriptLayout) self.addDockWidget(Qt.RightDockWidgetArea, self.scriptDock) - + # 将右侧停靠窗口设为标签形式 self.tabifyDockWidget(self.rightDock, self.scriptDock) - + + # # 创建底部停靠窗口(资源窗口) + # self.bottomDock = QDockWidget("资源", self) + # self.bottomDock.setAllowedAreas(Qt.BottomDockWidgetArea) + # + # # 创建文件系统模型 + # self.fileModel = QFileSystemModel() + # self.fileModel.setRootPath(QDir.homePath()) # 设置为用户主目录 + # + # # 创建树形视图显示文件系统 + # self.fileView = CustomFileView(self.world) + # self.fileView.setModel(self.fileModel) + # self.fileView.setRootIndex(self.fileModel.index(QDir.homePath())) # 设置为用户主目录索引 + # + # # 设置列宽 + # self.fileView.setColumnWidth(0, 250) # 名称列 + # self.fileView.setColumnWidth(1, 100) # 大小列 + # self.fileView.setColumnWidth(2, 100) # 类型列 + # self.fileView.setColumnWidth(3, 150) # 修改日期列 + # + # # 设置视图属性 + # self.fileView.setMinimumHeight(200) # 设置最小高度 + # + # self.bottomDock.setWidget(self.fileView) + # self.addDockWidget(Qt.BottomDockWidgetArea, self.bottomDock) + + # 创建底部停靠窗口(资源窗口) self.bottomDock = QDockWidget("资源", self) - self.bottomDock.setAllowedAreas(Qt.BottomDockWidgetArea) - - # 创建文件系统模型 - self.fileModel = QFileSystemModel() - self.fileModel.setRootPath(QDir.homePath()) # 设置为用户主目录 - - # 创建树形视图显示文件系统 - self.fileView = CustomFileView(self.world) - self.fileView.setModel(self.fileModel) - self.fileView.setRootIndex(self.fileModel.index(QDir.homePath())) # 设置为用户主目录索引 - - # 设置列宽 - self.fileView.setColumnWidth(0, 250) # 名称列 - self.fileView.setColumnWidth(1, 100) # 大小列 - self.fileView.setColumnWidth(2, 100) # 类型列 - self.fileView.setColumnWidth(3, 150) # 修改日期列 - - # 设置视图属性 - self.fileView.setMinimumHeight(200) # 设置最小高度 - + self.fileView = CustomAssetsTreeWidget(self.world) self.bottomDock.setWidget(self.fileView) self.addDockWidget(Qt.BottomDockWidgetArea, self.bottomDock) - + + # 创建底部停靠控制台 + self.consoleDock = QDockWidget("控制台", self) + self.consoleView = CustomConsoleDockWidget(self.world) + self.consoleDock.setWidget(self.consoleView) + self.addDockWidget(Qt.BottomDockWidgetArea, self.consoleDock) + + def setupToolbar(self): """创建工具栏""" self.toolbar = self.addToolBar('工具栏') - + # 创建工具按钮组 self.toolGroup = QButtonGroup() - + # 选择工具 self.selectTool = QToolButton() self.selectTool.setText("选择") self.selectTool.setCheckable(True) self.toolGroup.addButton(self.selectTool) self.toolbar.addWidget(self.selectTool) - + # 旋转工具 self.rotateTool = QToolButton() self.rotateTool.setText("旋转") self.rotateTool.setCheckable(True) self.toolGroup.addButton(self.rotateTool) self.toolbar.addWidget(self.rotateTool) - + # 缩放工具 self.scaleTool = QToolButton() self.scaleTool.setText("缩放") self.scaleTool.setCheckable(True) self.toolGroup.addButton(self.scaleTool) self.toolbar.addWidget(self.scaleTool) - + # 添加分隔符 self.toolbar.addSeparator() - + # GUI创建工具 self.createButtonTool = QToolButton() self.createButtonTool.setText("创建按钮") self.toolbar.addWidget(self.createButtonTool) - + self.createLabelTool = QToolButton() self.createLabelTool.setText("创建标签") self.toolbar.addWidget(self.createLabelTool) - + self.create3DTextTool = QToolButton() self.create3DTextTool.setText("3D文本") self.toolbar.addWidget(self.create3DTextTool) @@ -275,32 +551,28 @@ class MainWindow(QMainWindow): # 默认选择"选择"工具 self.selectTool.setChecked(True) self.world.setCurrentTool("选择") - - def setupScriptPanel(self): + + def setupScriptPanel(self, layout): """创建脚本管理面板""" - # 创建主容器 - scriptContainer = QWidget() - layout = QVBoxLayout(scriptContainer) - # 脚本状态组 statusGroup = QGroupBox("脚本系统状态") statusLayout = QVBoxLayout() - + self.scriptStatusLabel = QLabel("脚本系统: 已启动") self.scriptStatusLabel.setStyleSheet("color: green; font-weight: bold;") statusLayout.addWidget(self.scriptStatusLabel) - + self.hotReloadLabel = QLabel("热重载: 已启用") self.hotReloadLabel.setStyleSheet("color: blue;") statusLayout.addWidget(self.hotReloadLabel) - + statusGroup.setLayout(statusLayout) layout.addWidget(statusGroup) - + # 脚本创建组 createGroup = QGroupBox("创建脚本") createLayout = QVBoxLayout() - + # 脚本名称输入 nameLayout = QHBoxLayout() nameLayout.addWidget(QLabel("脚本名称:")) @@ -308,7 +580,7 @@ class MainWindow(QMainWindow): self.scriptNameEdit.setPlaceholderText("输入脚本名称...") nameLayout.addWidget(self.scriptNameEdit) createLayout.addLayout(nameLayout) - + # 模板选择 templateLayout = QHBoxLayout() templateLayout.addWidget(QLabel("模板:")) @@ -316,90 +588,87 @@ class MainWindow(QMainWindow): self.templateCombo.addItems(["basic", "movement", "animation"]) templateLayout.addWidget(self.templateCombo) createLayout.addLayout(templateLayout) - + # 创建按钮 self.createScriptBtn = QPushButton("创建脚本") self.createScriptBtn.clicked.connect(self.onCreateScript) createLayout.addWidget(self.createScriptBtn) - + createGroup.setLayout(createLayout) layout.addWidget(createGroup) - + # 可用脚本组 scriptsGroup = QGroupBox("可用脚本") scriptsLayout = QVBoxLayout() - + # 脚本列表 self.scriptsList = QListWidget() self.scriptsList.itemDoubleClicked.connect(self.onScriptDoubleClick) scriptsLayout.addWidget(self.scriptsList) - + # 脚本操作按钮 scriptButtonsLayout = QHBoxLayout() - + self.loadScriptBtn = QPushButton("加载脚本") self.loadScriptBtn.clicked.connect(self.onLoadScript) scriptButtonsLayout.addWidget(self.loadScriptBtn) - + self.reloadAllBtn = QPushButton("重载全部") self.reloadAllBtn.clicked.connect(self.onReloadAllScripts) scriptButtonsLayout.addWidget(self.reloadAllBtn) - + scriptsLayout.addLayout(scriptButtonsLayout) scriptsGroup.setLayout(scriptsLayout) layout.addWidget(scriptsGroup) - + # 脚本挂载组 mountGroup = QGroupBox("脚本挂载") mountLayout = QVBoxLayout() - + # 当前选中对象显示 self.selectedObjectLabel = QLabel("未选择对象") self.selectedObjectLabel.setStyleSheet("color: gray; font-style: italic;") mountLayout.addWidget(self.selectedObjectLabel) - + # 脚本选择和挂载 mountControlLayout = QHBoxLayout() - + self.mountScriptCombo = QComboBox() self.mountScriptCombo.setEnabled(False) mountControlLayout.addWidget(self.mountScriptCombo) - + self.mountBtn = QPushButton("挂载") self.mountBtn.setEnabled(False) self.mountBtn.clicked.connect(self.onMountScript) mountControlLayout.addWidget(self.mountBtn) - + mountLayout.addLayout(mountControlLayout) - + # 已挂载脚本列表 self.mountedScriptsList = QListWidget() self.mountedScriptsList.setMaximumHeight(100) mountLayout.addWidget(QLabel("已挂载脚本:")) mountLayout.addWidget(self.mountedScriptsList) - + # 卸载按钮 self.unmountBtn = QPushButton("卸载选中脚本") self.unmountBtn.clicked.connect(self.onUnmountScript) mountLayout.addWidget(self.unmountBtn) - + mountGroup.setLayout(mountLayout) layout.addWidget(mountGroup) - + # 添加拉伸以填充剩余空间 layout.addStretch() - - # 设置到停靠窗口 - self.scriptDock.setWidget(scriptContainer) - + # 初始化脚本列表 self.refreshScriptsList() - + def connectEvents(self): """连接事件信号""" # 导入项目管理功能函数 from main import createNewProject, saveProject, openProject, buildPackage - + # 连接文件菜单事件 self.newAction.triggered.connect(lambda: createNewProject(self)) self.openAction.triggered.connect(lambda: openProject(self)) @@ -414,40 +683,48 @@ class MainWindow(QMainWindow): # 连接GUI编辑模式事件 self.guiEditModeAction.triggered.connect(lambda: self.world.toggleGUIEditMode()) - # 连接创建事件 - # 连接光源创建按钮事件 - self.createSpotLightAction.triggered.connect(lambda :self.world.createSpotLight()) - self.createPointLightAction.triggered.connect(lambda :self.world.createPointLight()) - # 连接GUI创建按钮事件 - self.createButtonAction.triggered.connect(lambda: self.world.createGUIButton()) - self.createLabelAction.triggered.connect(lambda: self.world.createGUILabel()) - self.createEntryAction.triggered.connect(lambda: self.world.createGUIEntry()) - self.create3DTextAction.triggered.connect(lambda: self.world.createGUI3DText()) - #self.createSpotLightAction.triggered.connect(lambda :self.world.createSpotLight()) - self.createVirtualScreenAction.triggered.connect(lambda: self.world.createGUIVirtualScreen()) - - # 连接工具栏GUI创建按钮事件 - self.createButtonTool.clicked.connect(lambda: self.world.createGUIButton()) - self.createLabelTool.clicked.connect(lambda: self.world.createGUILabel()) - self.create3DTextTool.clicked.connect(lambda: self.world.createGUI3DText()) - self.createSpotLight.clicked.connect(lambda :self.world.createSpotLight()) - self.createPointLight.clicked.connect(lambda :self.world.createPointLight()) - + # # 连接创建事件 + # # 连接光源创建按钮事件 + # self.createSpotLightAction.triggered.connect(lambda :self.world.createSpotLight()) + # self.createPointLightAction.triggered.connect(lambda :self.world.createPointLight()) + # # 连接GUI创建按钮事件 + # self.createButtonAction.triggered.connect(lambda: self.world.createGUIButton()) + # self.createLabelAction.triggered.connect(lambda: self.world.createGUILabel()) + # self.createEntryAction.triggered.connect(lambda: self.world.createGUIEntry()) + # self.create3DTextAction.triggered.connect(lambda: self.world.createGUI3DText()) + # #self.createSpotLightAction.triggered.connect(lambda :self.world.createSpotLight()) + # + # # self.createVideoScreen.triggered.connect(lambda: self.world.video_manager.create_video_screen( + # # pos=(0, 0, 2), + # # size=(4, 3), + # # name=f"video_screen_{len(self.world.video_manager.video_objects) if hasattr(self.world, 'video_manager') else 0}" + # # )) + # # self.createSphericalVideo.triggered.connect(lambda: self.world.createGUISphericalVideo()) + # + # self.createVirtualScreenAction.triggered.connect(lambda: self.world.create_spherical_video()) + # + # # 连接工具栏GUI创建按钮事件 + # self.createButtonTool.clicked.connect(lambda: self.world.createGUIButton()) + # self.createLabelTool.clicked.connect(lambda: self.world.createGUILabel()) + # self.create3DTextTool.clicked.connect(lambda: self.world.createGUI3DText()) + # self.createSpotLight.clicked.connect(lambda :self.world.createSpotLight()) + # self.createPointLight.clicked.connect(lambda :self.world.createPointLight()) + # 连接树节点点击信号 # self.treeWidget.itemClicked.connect(self.world.onTreeItemClicked) self.treeWidget.itemSelectionChanged.connect(lambda :self.world.onTreeItemClicked(self.treeWidget.currentItem(), 0)) print("已连接点击信号") - + # 连接工具切换信号 self.toolGroup.buttonClicked.connect(self.onToolChanged) - + # 连接脚本菜单事件 self.createScriptAction.triggered.connect(self.onCreateScriptDialog) self.loadScriptAction.triggered.connect(self.onLoadScriptFile) self.loadAllScriptsAction.triggered.connect(self.onReloadAllScripts) self.toggleHotReloadAction.triggered.connect(self.onToggleHotReload) self.openScriptsManagerAction.triggered.connect(self.onOpenScriptsManager) - + def onToolChanged(self, button): """工具切换事件处理""" if button.isChecked(): @@ -457,29 +734,29 @@ class MainWindow(QMainWindow): else: self.world.setCurrentTool(None) print("工具栏: 取消选择工具") - + # ==================== 脚本管理事件处理 ==================== - + def refreshScriptsList(self): """刷新脚本列表""" self.scriptsList.clear() self.mountScriptCombo.clear() - + available_scripts = self.world.getAvailableScripts() for script_name in available_scripts: self.scriptsList.addItem(script_name) self.mountScriptCombo.addItem(script_name) - + def updateScriptPanel(self): """更新脚本面板状态""" # 更新热重载状态 hot_reload_enabled = self.world.script_manager.hot_reload_enabled self.hotReloadLabel.setText(f"热重载: {'已启用' if hot_reload_enabled else '已禁用'}") self.hotReloadLabel.setStyleSheet(f"color: {'blue' if hot_reload_enabled else 'gray'};") - + # 更新热重载菜单状态 self.toggleHotReloadAction.setChecked(hot_reload_enabled) - + # 更新选中对象信息 selected_object = getattr(self.world.selection, 'selectedObject', None) if selected_object: @@ -487,7 +764,7 @@ class MainWindow(QMainWindow): self.selectedObjectLabel.setStyleSheet("color: green; font-weight: bold;") self.mountScriptCombo.setEnabled(True) self.mountBtn.setEnabled(True) - + # 更新已挂载脚本列表 self.updateMountedScriptsList(selected_object) else: @@ -496,7 +773,7 @@ class MainWindow(QMainWindow): self.mountScriptCombo.setEnabled(False) self.mountBtn.setEnabled(False) self.mountedScriptsList.clear() - + def updateMountedScriptsList(self, game_object): """更新已挂载脚本列表""" # 保存当前选中项的脚本名(去除状态前缀) @@ -505,7 +782,7 @@ class MainWindow(QMainWindow): if current_item: # 提取脚本名(移除 "✓ " 或 "✗ " 前缀) selected_script_name = current_item.text()[2:] - + # 清空并重新填充列表 self.mountedScriptsList.clear() scripts = self.world.getScripts(game_object) @@ -514,7 +791,7 @@ class MainWindow(QMainWindow): enabled = "✓" if script_component.enabled else "✗" item_text = f"{enabled} {script_name}" self.mountedScriptsList.addItem(item_text) - + # 恢复选中状态(根据脚本名匹配) if selected_script_name: for i in range(self.mountedScriptsList.count()): @@ -524,16 +801,16 @@ class MainWindow(QMainWindow): if current_script_name == selected_script_name: self.mountedScriptsList.setCurrentItem(item) break - + def onCreateScript(self): """创建脚本按钮事件""" script_name = self.scriptNameEdit.text().strip() if not script_name: QMessageBox.warning(self, "错误", "请输入脚本名称!") return - + template = self.templateCombo.currentText() - + try: success = self.world.createScript(script_name, template) if success: @@ -544,7 +821,7 @@ class MainWindow(QMainWindow): QMessageBox.warning(self, "错误", f"脚本 '{script_name}' 创建失败!") except Exception as e: QMessageBox.critical(self, "错误", f"创建脚本时出错: {str(e)}") - + def onCreateScriptDialog(self): """菜单创建脚本事件""" script_name, ok = QInputDialog.getText(self, "创建脚本", "输入脚本名称:") @@ -558,14 +835,14 @@ class MainWindow(QMainWindow): QMessageBox.warning(self, "错误", f"脚本 '{script_name}' 创建失败!") except Exception as e: QMessageBox.critical(self, "错误", f"创建脚本时出错: {str(e)}") - + def onLoadScript(self): """加载脚本按钮事件""" current_item = self.scriptsList.currentItem() if not current_item: QMessageBox.warning(self, "错误", "请选择要加载的脚本!") return - + script_name = current_item.text() try: success = self.world.reloadScript(script_name) @@ -575,7 +852,7 @@ class MainWindow(QMainWindow): QMessageBox.warning(self, "错误", f"脚本 '{script_name}' 重载失败!") except Exception as e: QMessageBox.critical(self, "错误", f"重载脚本时出错: {str(e)}") - + def onLoadScriptFile(self): """加载脚本文件菜单事件""" file_path, _ = QFileDialog.getOpenFileName( @@ -591,7 +868,7 @@ class MainWindow(QMainWindow): QMessageBox.warning(self, "错误", "脚本文件加载失败!") except Exception as e: QMessageBox.critical(self, "错误", f"加载脚本文件时出错: {str(e)}") - + def onReloadAllScripts(self): """重载所有脚本事件""" try: @@ -600,44 +877,44 @@ class MainWindow(QMainWindow): self.refreshScriptsList() except Exception as e: QMessageBox.critical(self, "错误", f"重载脚本时出错: {str(e)}") - + def onToggleHotReload(self): """切换热重载状态""" enabled = self.toggleHotReloadAction.isChecked() self.world.enableHotReload(enabled) status = "启用" if enabled else "禁用" QMessageBox.information(self, "热重载", f"热重载已{status}") - + def onOpenScriptsManager(self): """打开脚本管理器""" # 显示脚本管理停靠窗口 self.scriptDock.show() self.scriptDock.raise_() - + def onScriptDoubleClick(self, item): """脚本列表双击事件""" # 可以在这里添加打开外部编辑器的功能 script_name = item.text() QMessageBox.information(self, "提示", f"双击了脚本: {script_name}\n\n可以使用外部编辑器编辑脚本文件。") - + def onMountScript(self): """挂载脚本事件""" selected_object = getattr(self.world.selection, 'selectedObject', None) if not selected_object: QMessageBox.warning(self, "错误", "请先选择一个对象!") return - + script_name = self.mountScriptCombo.currentText() if not script_name: QMessageBox.warning(self, "错误", "请选择要挂载的脚本!") return - + try: success = self.world.addScript(selected_object, script_name) if success: QMessageBox.information(self, "成功", f"脚本 '{script_name}' 已挂载到对象!") self.updateMountedScriptsList(selected_object) - + # 同时更新属性面板 if self.treeWidget and self.treeWidget.currentItem(): self.world.updatePropertyPanel(self.treeWidget.currentItem()) @@ -645,29 +922,29 @@ class MainWindow(QMainWindow): QMessageBox.warning(self, "错误", f"挂载脚本失败!") except Exception as e: QMessageBox.critical(self, "错误", f"挂载脚本时出错: {str(e)}") - + def onUnmountScript(self): """卸载脚本事件""" selected_object = getattr(self.world.selection, 'selectedObject', None) if not selected_object: QMessageBox.warning(self, "错误", "请先选择一个对象!") return - + current_item = self.mountedScriptsList.currentItem() if not current_item: QMessageBox.warning(self, "错误", "请选择要卸载的脚本!") return - + # 解析脚本名称(移除状态标记) item_text = current_item.text() script_name = item_text[2:] # 移除 "✓ " 或 "✗ " 前缀 - + try: success = self.world.removeScript(selected_object, script_name) if success: QMessageBox.information(self, "成功", f"脚本 '{script_name}' 已从对象卸载!") self.updateMountedScriptsList(selected_object) - + # 同时更新属性面板 if self.treeWidget and self.treeWidget.currentItem(): self.world.updatePropertyPanel(self.treeWidget.currentItem()) @@ -713,4 +990,4 @@ def setup_main_window(world): main_window = MainWindow(world) main_window.show() - return app, main_window \ No newline at end of file + return app, main_window \ No newline at end of file diff --git a/ui/property_panel.py b/ui/property_panel.py index d2c51790..137f6238 100644 --- a/ui/property_panel.py +++ b/ui/property_panel.py @@ -504,6 +504,7 @@ class PropertyPanelManager: success = self.world.gui_manager.editGUIElement(gui_element, "text", text) if success: # 更新场景树显示的名称 + pass #CH self.world.scene_manager.updateSceneTree() textEdit.textChanged.connect(updateText) diff --git a/ui/widgets.py b/ui/widgets.py index 52f97d1d..207f26d9 100644 --- a/ui/widgets.py +++ b/ui/widgets.py @@ -13,10 +13,12 @@ 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 + QFileDialog, QMessageBox, QAbstractItemView, QMenu, QDockWidget, QButtonGroup, QToolButton) +from PyQt5.QtCore import Qt, QUrl, QMimeData from PyQt5.QtGui import QDrag, QPainter, QPixmap, QPen, QBrush from PyQt5.sip import wrapinstance +from panda3d.core import ModelRoot + from QPanda3D.QPanda3DWidget import QPanda3DWidget @@ -103,10 +105,9 @@ class NewProjectDialog(QDialog): self.accept() - class CustomPanda3DWidget(QPanda3DWidget): """自定义Panda3D显示部件""" - + def __init__(self, world, parent=None): if parent is None: parent = wrapinstance(0, QWidget) @@ -119,7 +120,7 @@ class CustomPanda3DWidget(QPanda3DWidget): # 让world引用这个widget,以便获取准确的尺寸 if hasattr(world, 'setQtWidget'): world.setQtWidget(self) - + def getActualSize(self): """获取Qt部件的实际渲染尺寸""" return (self.width(), self.height()) @@ -233,7 +234,6 @@ class CustomPanda3DWidget(QPanda3DWidget): except Exception as e: print(f"⚠️ 清理CustomPanda3DWidget资源时出错: {e}") - class CustomFileView(QTreeView): """自定义文件浏览器""" @@ -242,40 +242,54 @@ class CustomFileView(QTreeView): parent = wrapinstance(0, QWidget) super().__init__(parent) self.world = world - self.setDragEnabled(True) # 启用拖拽 - self.setSelectionMode(QTreeView.ExtendedSelection) # 允许多选 + self.setupUI() + self.setupDragDrop() + + def setupUI(self): + """初始化UI设置""" + self.setHeaderHidden(True) + # 启用多选和拖拽 + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.setDropIndicatorShown(True) # 启用拖放指示线 + + def setupDragDrop(self): + """设置拖拽功能""" + # 使用自定义拖拽模式 self.setDragDropMode(QTreeView.DragOnly) # 只允许拖出,不允许拖入 - + self.setDefaultDropAction(Qt.DropAction.MoveAction) + self.setDragEnabled(True) + self.setAcceptDrops(True) + 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) @@ -283,7 +297,7 @@ class CustomFileView(QTreeView): painter.drawText(pixmap.rect(), Qt.AlignCenter, str(len(urls))) painter.end() drag.setPixmap(pixmap) - + # 执行拖拽 drag.exec_(supportedActions) @@ -293,7 +307,7 @@ class CustomFileView(QTreeView): if index.isValid(): model = self.model() filepath = model.filePath(index) - + # 检查是否是模型文件 if filepath.lower().endswith(('.egg', '.bam', '.obj', '.fbx', '.gltf', '.glb')): self.world.importModel(filepath) @@ -301,16 +315,995 @@ class CustomFileView(QTreeView): print("不支持的文件类型") super().mouseDoubleClickEvent(event) +class CustomAssetsTreeWidget(QTreeWidget): + def __init__(self, world, parent=None): + if parent is None: + parent = wrapinstance(0, QWidget) + super().__init__(parent) + self.world = world + self.root_path = None + self.setupUI() + self.setupDragDrop() + + # 默认加载项目根路径 + self.load_file_tree() + # 设置右键菜单 + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self.showContextMenu) + + def showContextMenu(self, position): + """显示右键菜单""" + item = self.itemAt(position) + if not item: + return + + filepath = item.data(0, Qt.UserRole) + is_folder = item.data(0, Qt.UserRole + 1) + + menu = QMenu(self) + + if is_folder: + # 文件夹右键菜单 + menu.addAction("📁 新建文件夹", lambda: self.createNewFolder(item)) + menu.addAction("📄 新建文件", lambda: self.createNewFile(item)) + menu.addSeparator() + menu.addAction("✏️ 重命名", lambda: self.renameItem(item)) + menu.addAction("🗑️ 删除", lambda: self.deleteItem(item)) + menu.addSeparator() + menu.addAction("📋 复制路径", lambda: self.copyPath(filepath)) + menu.addAction("🔍 查看属性", lambda: self.showProperties(item)) + else: + # 文件右键菜单 + menu.addAction("📂 打开文件", lambda: self.openFile(filepath)) + menu.addSeparator() + menu.addAction("✏️ 重命名", lambda: self.renameItem(item)) + menu.addAction("🗑️ 删除", lambda: self.deleteItem(item)) + menu.addSeparator() + menu.addAction("📋 复制路径", lambda: self.copyPath(filepath)) + menu.addAction("🔍 查看属性", lambda: self.showProperties(item)) + + # 显示菜单 + menu.exec_(self.mapToGlobal(position)) + + def _saveExpandedState(self): + """保存展开状态""" + expanded_paths = set() + + def collectExpanded(item): + if item.isExpanded(): + path = item.data(0, Qt.UserRole) + if path: + expanded_paths.add(path) + + for i in range(item.childCount()): + collectExpanded(item.child(i)) + + # 遍历所有顶级项目 + for i in range(self.topLevelItemCount()): + collectExpanded(self.topLevelItem(i)) + + return expanded_paths + + def _restoreExpandedState(self, expanded_paths): + """恢复展开状态""" + def restoreExpanded(item): + path = item.data(0, Qt.UserRole) + if path in expanded_paths: + item.setExpanded(True) + + for i in range(item.childCount()): + restoreExpanded(item.child(i)) + + # 遍历所有顶级项目 + for i in range(self.topLevelItemCount()): + restoreExpanded(self.topLevelItem(i)) + + def _refreshWithStatePreservation(self): + """刷新树形视图并保持状态""" + # 保存当前状态 + expanded_paths = self._saveExpandedState() + + # 刷新树形结构 + self.load_file_tree() + + # 恢复展开状态 + self._restoreExpandedState(expanded_paths) + + def createNewFolder(self, parent_item): + """创建新文件夹""" + import os + from PyQt5.QtWidgets import QInputDialog + + parent_path = parent_item.data(0, Qt.UserRole) + folder_name, ok = QInputDialog.getText(self, "新建文件夹", "文件夹名称:") + + if ok and folder_name: + new_folder_path = os.path.join(parent_path, folder_name) + try: + os.makedirs(new_folder_path, exist_ok=True) + self._refreshWithStatePreservation() + print(f"创建文件夹: {new_folder_path}") + except OSError as e: + print(f"创建文件夹失败: {e}") + + def createNewFile(self, parent_item): + """创建新文件""" + import os + from PyQt5.QtWidgets import QInputDialog + + parent_path = parent_item.data(0, Qt.UserRole) + file_name, ok = QInputDialog.getText(self, "新建文件", "文件名称:") + + if ok and file_name: + new_file_path = os.path.join(parent_path, file_name) + try: + with open(new_file_path, 'w', encoding='utf-8') as f: + f.write("") + self._refreshWithStatePreservation() + print(f"创建文件: {new_file_path}") + except OSError as e: + print(f"创建文件失败: {e}") + + def renameItem(self, item): + """重命名文件或文件夹""" + import os + from PyQt5.QtWidgets import QInputDialog + + old_path = item.data(0, Qt.UserRole) + old_name = os.path.basename(old_path) + + new_name, ok = QInputDialog.getText(self, "重命名", "新名称:", text=old_name) + + if ok and new_name and new_name != old_name: + parent_dir = os.path.dirname(old_path) + new_path = os.path.join(parent_dir, new_name) + + try: + os.rename(old_path, new_path) + self._refreshWithStatePreservation() + print(f"重命名: {old_path} -> {new_path}") + except OSError as e: + print(f"重命名失败: {e}") + + def deleteItem(self, item): + """删除文件或文件夹""" + import os + import shutil + from PyQt5.QtWidgets import QMessageBox + + filepath = item.data(0, Qt.UserRole) + is_folder = item.data(0, Qt.UserRole + 1) + + item_type = "文件夹" if is_folder else "文件" + reply = QMessageBox.question( + self, + "确认删除", + f"确定要删除这个{item_type}吗?\n{filepath}", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + + if reply == QMessageBox.Yes: + try: + if is_folder: + shutil.rmtree(filepath) + else: + os.remove(filepath) + self._refreshWithStatePreservation() + print(f"删除{item_type}: {filepath}") + except OSError as e: + print(f"删除{item_type}失败: {e}") + + def copyPath(self, filepath): + """复制路径到剪贴板""" + from PyQt5.QtWidgets import QApplication + + clipboard = QApplication.clipboard() + clipboard.setText(filepath) + print(f"已复制路径: {filepath}") + + def openFile(self, filepath): + """打开文件""" + import os + import subprocess + import platform + + try: + system = platform.system() + if system == "Windows": + os.startfile(filepath) + elif system == "Darwin": # macOS + subprocess.run(["open", filepath]) + else: # Linux + subprocess.run(["xdg-open", filepath]) + print(f"打开文件: {filepath}") + except Exception as e: + print(f"打开文件失败: {e}") + + def showProperties(self, item): + """显示属性面板""" + import os + from PyQt5.QtWidgets import QMessageBox + + filepath = item.data(0, Qt.UserRole) + is_folder = item.data(0, Qt.UserRole + 1) + + try: + stat = os.stat(filepath) + size = stat.st_size + modified = os.path.getmtime(filepath) + + import datetime + modified_str = datetime.datetime.fromtimestamp(modified).strftime('%Y-%m-%d %H:%M:%S') + + item_type = "文件夹" if is_folder else "文件" + size_str = f"{size} 字节" if not is_folder else "文件夹" + + properties = f""" + 路径: {filepath} + 类型: {item_type} + 大小: {size_str} + 修改时间: {modified_str} + """ + + QMessageBox.information(self, "属性", properties.strip()) + + except OSError as e: + QMessageBox.warning(self, "错误", f"无法获取属性: {e}") + + # def mouseDoubleClickEvent(self, event): + # """处理双击事件""" + # item = self.itemAt(event.pos()) + # if item: + # filepath = item.data(0, Qt.UserRole) + # is_folder = item.data(0, Qt.UserRole + 1) + # + # if is_folder: + # # 文件夹:展开/折叠 + # item.setExpanded(not item.isExpanded()) + # else: + # # 文件:检查是否是模型文件 + # if filepath and filepath.lower().endswith(('.egg', '.bam', '.obj', '.fbx', '.gltf', '.glb')): + # self.world.importModel(filepath) + # else: + # # 其他文件:用系统默认程序打开 + # self.openFile(filepath) + # + # super().mouseDoubleClickEvent(event) + + def setupUI(self): + """初始化UI设置""" + self.setHeaderHidden(True) + # 启用多选和拖拽 + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.setDropIndicatorShown(True) # 启用拖放指示线 + + def setupDragDrop(self): + """设置拖拽功能""" + # 使用InternalMove模式以正确显示插入指示线 + self.setDragDropMode(QAbstractItemView.InternalMove) + self.setDefaultDropAction(Qt.DropAction.MoveAction) + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + + def getProjectRootPath(self): + """获取项目根路径下的Resources文件夹,考虑跨平台""" + import os + + # 获取项目根路径 + project_root = os.getcwd() + + # 构建Resources文件夹路径(跨平台) + resources_path = os.path.join(project_root, "Resources") + + # 如果Resources文件夹不存在,创建它 + if not os.path.exists(resources_path): + try: + os.makedirs(resources_path, exist_ok=True) + print(f"创建Resources文件夹: {resources_path}") + except OSError as e: + print(f"无法创建Resources文件夹: {e}") + # 如果无法创建,回退到项目根路径 + return project_root + + return resources_path + + def load_file_tree(self): + """加载树形视图""" + self.clear() + self.current_path = self.getProjectRootPath() + try: + # 创建当前目录的根节点 + root_name = os.path.basename(self.current_path) or self.current_path + root_item = QTreeWidgetItem([f"📁 {root_name}"]) + root_item.setData(0, Qt.UserRole, self.current_path) + root_item.setData(0, Qt.UserRole + 1, True) + self.addTopLevelItem(root_item) + + # 加载当前目录内容 + self.load_directory_tree(self.current_path, root_item) + + # 展开根节点 + root_item.setExpanded(True) + + except PermissionError: + error_item = QTreeWidgetItem(["❌ 无权限访问此目录"]) + self.addTopLevelItem(error_item) + + def load_directory_tree(self, path, parent_item, max_depth=3, current_depth=0): + """递归加载目录树(类似左侧导航面板)""" + if current_depth >= max_depth: + return + + try: + items = os.listdir(path) + items.sort() + + # 分别处理文件夹和文件 + folders = [] + files = [] + + for item in items: + # 跳过隐藏文件和系统文件 + if item.startswith('.') or item.startswith('__'): + continue + + item_path = os.path.join(path, item) + if os.path.isdir(item_path): + folders.append(item) + elif os.path.isfile(item_path): + files.append(item) + + # 先添加文件夹 + for folder in folders: + folder_path = os.path.join(path, folder) + folder_item = self.create_simple_tree_item(folder, folder_path, True) + parent_item.addChild(folder_item) + + # 递归加载子目录(限制深度) + if current_depth < max_depth - 1: + self.load_directory_tree(folder_path, folder_item, max_depth, current_depth + 1) + + # 再添加文件(显示重要文件类型,包括图片和模型) + important_extensions = { + # 编程文件 + '.py', '.js', '.html', '.css', '.java', '.cpp', '.c', '.php', '.rb', '.go', '.rs', + # 文档文件 + '.md', '.txt', '.pdf', '.doc', '.docx', '.rtf', + # 配置和数据文件 + '.json', '.xml', '.yaml', '.yml', '.ini', '.cfg', '.toml', + # 图片文件 + '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.svg', '.ico', '.webp', '.tiff', '.tif', + # 3D模型文件 + '.fbx', '.obj', '.3ds', '.max', '.blend', '.dae', '.gltf', '.glb', '.stl', '.ply', + # 音视频文件 + '.mp3', '.wav', '.mp4', '.avi', '.mov', '.wmv', '.flac', '.aac', + # 压缩文件 + '.zip', '.rar', '.7z', '.tar', '.gz', + # 材质和纹理 + '.mtl', '.mat', '.exr', '.hdr', '.hdri', '.dds', + # CAD文件 + '.dwg', '.dxf', '.step', '.stp', '.iges', '.sldprt', '.sldasm', + # 其他重要文件 + '.exe', '.dll', '.bat', '.sh', '.log' + } + + # 重要文件名(不依赖扩展名) + important_files = {'requirements.txt', 'README.md', 'main.py', 'LICENSE', 'CHANGELOG.md', 'INSTALL.md'} + + for file in files: + file_ext = os.path.splitext(file)[1].lower() + if file_ext in important_extensions or file in important_files: + file_path = os.path.join(path, file) + file_item = self.create_simple_tree_item(file, file_path, False) + parent_item.addChild(file_item) + + except (OSError, PermissionError): + pass + + def create_simple_tree_item(self, name, path, is_folder): + """创建简单的树形项目""" + try: + if is_folder: + # 文件夹项目,展开/折叠图标由CSS样式控制 + item = QTreeWidgetItem([f"📁 {name}"]) + item.setData(0, Qt.UserRole + 1, True) # 标记为文件夹 + else: + icon = self.get_file_icon(name) + item = QTreeWidgetItem([f"{icon} {name}"]) + item.setData(0, Qt.UserRole + 1, False) # 标记为文件 + + item.setData(0, Qt.UserRole, path) + + return item + + except (OSError, PermissionError): + item = QTreeWidgetItem([name]) + item.setData(0, Qt.UserRole, path) + return item + + def get_file_icon(self, filename): + """ + 根据文件扩展名获取图标 + + 这个函数根据文件的扩展名返回对应的Unicode图标字符。 + 如果文件类型不在映射表中,返回默认的文档图标。 + + 参数: + filename (str): 文件名(包含扩展名) + + 返回值: + str: 对应的Unicode图标字符 + """ + # 获取文件扩展名并转换为小写 + ext = os.path.splitext(filename)[1].lower() + + # 文件扩展名到图标的映射表 + icon_map = { + # 编程语言文件 + '.py': '🐍', # Python文件 + '.js': '⚡', # JavaScript文件 + '.html': '🌐', # HTML文件 + '.css': '🎨', # CSS样式文件 + '.java': '☕', # Java文件 + '.cpp': '⚙️', # C++文件 + '.c': '⚙️', # C文件 + '.h': '📋', # 头文件 + '.php': '🐘', # PHP文件 + '.rb': '💎', # Ruby文件 + '.go': '🐹', # Go文件 + '.rs': '🦀', # Rust文件 + '.swift': '🐦', # Swift文件 + '.kt': '🎯', # Kotlin文件 + + # 文档文件 + '.txt': '📄', # 纯文本文件 + '.md': '📝', # Markdown文档 + '.rst': '📝', # reStructuredText文档 + '.pdf': '📕', # PDF文档 + '.doc': '📘', # Word文档 + '.docx': '📘', # Word文档 + '.rtf': '📄', # RTF文档 + '.odt': '📄', # OpenDocument文本 + + # 数据文件 + '.json': '📋', # JSON数据文件 + '.xml': '📋', # XML文件 + '.yaml': '📋', # YAML文件 + '.yml': '📋', # YAML文件 + '.csv': '📊', # CSV表格文件 + '.xls': '📗', # Excel表格 + '.xlsx': '📗', # Excel表格 + '.ods': '📊', # OpenDocument表格 + + # 图像文件 + '.jpg': '🖼️', # JPEG图像 + '.jpeg': '🖼️', # JPEG图像 + '.png': '🖼️', # PNG图像 + '.gif': '🖼️', # GIF图像 + '.bmp': '🖼️', # BMP图像 + '.svg': '🎨', # SVG矢量图 + '.ico': '🎯', # 图标文件 + '.webp': '🖼️', # WebP图像 + '.tiff': '🖼️', # TIFF图像 + '.tif': '🖼️', # TIFF图像 + + # 音视频文件 + '.mp4': '🎬', # MP4视频 + '.avi': '🎬', # AVI视频 + '.mov': '🎬', # MOV视频 + '.wmv': '🎬', # WMV视频 + '.flv': '🎬', # FLV视频 + '.webm': '🎬', # WebM视频 + '.mp3': '🎵', # MP3音频 + '.wav': '🎵', # WAV音频 + '.flac': '🎵', # FLAC音频 + '.aac': '🎵', # AAC音频 + '.ogg': '🎵', # OGG音频 + + # 压缩文件 + '.zip': '📦', # ZIP压缩包 + '.rar': '📦', # RAR压缩包 + '.7z': '📦', # 7Z压缩包 + '.tar': '📦', # TAR归档 + '.gz': '📦', # GZIP压缩 + '.bz2': '📦', # BZIP2压缩 + '.xz': '📦', # XZ压缩 + + # 可执行文件 + '.exe': '⚙️', # Windows可执行文件 + '.msi': '📦', # Windows安装包 + '.deb': '📦', # Debian包 + '.rpm': '📦', # RPM包 + '.dmg': '💿', # macOS磁盘镜像 + '.app': '📱', # macOS应用程序 + + # 系统文件 + '.dll': '🔧', # 动态链接库 + '.so': '🔧', # 共享库 + '.dylib': '🔧', # macOS动态库 + '.lib': '📚', # 静态库 + + # 脚本文件 + '.bat': '📜', # Windows批处理 + '.cmd': '📜', # Windows命令脚本 + '.sh': '📜', # Shell脚本 + '.ps1': '💙', # PowerShell脚本 + '.vbs': '📜', # VBScript脚本 + + # 配置文件 + '.ini': '⚙️', # INI配置文件 + '.cfg': '⚙️', # 配置文件 + '.conf': '⚙️', # 配置文件 + '.config': '⚙️', # 配置文件 + '.toml': '⚙️', # TOML配置文件 + + # 3D模型文件 + '.fbx': '🎭', # FBX模型文件 + '.obj': '🎭', # OBJ模型文件 + '.3ds': '🎭', # 3DS Max模型 + '.max': '🎭', # 3DS Max场景 + '.blend': '🎭', # Blender模型 + '.dae': '🎭', # COLLADA模型 + '.gltf': '🎭', # glTF模型 + '.glb': '🎭', # glTF二进制模型 + '.x3d': '🎭', # X3D模型 + '.ply': '🎭', # PLY模型 + '.stl': '🎭', # STL模型(3D打印) + '.off': '🎭', # OFF模型 + '.3mf': '🎭', # 3MF模型 + '.amf': '🎭', # AMF模型 + '.x': '🎭', # DirectX模型 + '.md2': '🎭', # Quake II模型 + '.md3': '🎭', # Quake III模型 + '.mdl': '🎭', # Source引擎模型 + '.mesh': '🎭', # OGRE模型 + '.scene': '🎭', # OGRE场景 + '.ac': '🎭', # AC3D模型 + '.ase': '🎭', # ASCII Scene Export + '.assbin': '🎭', # Assimp二进制 + '.b3d': '🎭', # Blitz3D模型 + '.bvh': '🎭', # BioVision层次 + '.csm': '🎭', # CharacterStudio Motion + '.hmp': '🎭', # 3D GameStudio模型 + '.irr': '🎭', # Irrlicht场景 + '.irrmesh': '🎭', # Irrlicht网格 + '.lwo': '🎭', # LightWave对象 + '.lws': '🎭', # LightWave场景 + '.ms3d': '🎭', # MilkShape 3D + '.nff': '🎭', # Neutral文件格式 + '.q3o': '🎭', # Quick3D对象 + '.q3s': '🎭', # Quick3D场景 + '.raw': '🎭', # RAW三角形 + '.smd': '🎭', # Valve SMD + '.ter': '🎭', # Terragen地形 + '.uc': '🎭', # Unreal模型 + '.vta': '🎭', # Valve VTA + '.xgl': '🎭', # XGL模型 + '.zgl': '🎭', # ZGL模型 + + # 纹理和材质文件 + '.mtl': '🎨', # OBJ材质文件 + '.mat': '🎨', # 材质文件 + '.sbsar': '🎨', # Substance Archive + '.sbs': '🎨', # Substance Designer + '.sbsm': '🎨', # Substance材质 + '.exr': '🖼️', # OpenEXR高动态范围图像 + '.hdr': '🖼️', # HDR图像 + '.hdri': '🖼️', # HDRI环境贴图 + '.dds': '🖼️', # DirectDraw Surface + '.ktx': '🖼️', # Khronos纹理 + '.astc': '🖼️', # ASTC纹理 + '.pvr': '🖼️', # PowerVR纹理 + '.etc1': '🖼️', # ETC1纹理 + '.etc2': '🖼️', # ETC2纹理 + + # 动画文件 + '.anim': '🎬', # 动画文件 + '.fbx': '🎬', # FBX动画(也可以是模型) + '.bip': '🎬', # Character Studio Biped + '.cal3d': '🎬', # Cal3D动画 + '.motion': '🎬', # 动作文件 + '.mocap': '🎬', # 动作捕捉数据 + + # 其他常见文件 + '.log': '📋', # 日志文件 + '.tmp': '🗂️', # 临时文件 + '.bak': '💾', # 备份文件 + '.old': '📦', # 旧文件 + } + + # 返回对应的图标,如果找不到则返回默认文档图标 + return icon_map.get(ext, '📄') + + def startDrag(self, supportedActions): + """开始拖拽操作""" + selected_items = self.selectedItems() + if not selected_items: + return + + # 创建 MIME 数据 + mimeData = QMimeData() + + # 收集文件路径用于向外拖拽 + urls = [] + internal_paths = [] + + for item in selected_items: + filepath = item.data(0, Qt.UserRole) + if filepath: + internal_paths.append(filepath) + # 检查是否是模型文件(用于向外拖拽) + if filepath.lower().endswith(('.egg', '.bam', '.obj', '.fbx', '.gltf', '.glb')): + urls.append(QUrl.fromLocalFile(filepath)) + + # 设置内部拖拽数据 + mimeData.setText('\n'.join(internal_paths)) + + # 设置向外拖拽数据 + if urls: + 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(selected_items))) + painter.end() + drag.setPixmap(pixmap) + + # 执行拖拽 + drag.exec_(supportedActions) + + def dragEnterEvent(self, event): + """处理拖拽进入事件""" + # 检查是否是内部拖拽 + if event.mimeData().hasText(): + event.acceptProposedAction() + else: + event.ignore() + + def dragMoveEvent(self, event): + """处理拖拽移动事件""" + if not event.mimeData().hasText(): + 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: + for selected_item in selected_items: + if self._isChildOf(target_item, selected_item): + event.ignore() + return + + # 如果拖到文件夹,允许拖到文件夹内部 + if target_item: + is_folder = target_item.data(0, Qt.UserRole + 1) + if is_folder: + # 接受拖放,显示指示框 + event.acceptProposedAction() + return + + + # 调用父类方法处理插入指示线 + event.accept() + super().dragMoveEvent(event) + + def _isChildOf(self, potential_child, potential_parent): + """检查 potential_child 是否是 potential_parent 的子级""" + current = potential_child.parent() + while current: + if current == potential_parent: + return True + current = current.parent() + return False + + def dropEvent(self, event): + """处理拖放事件""" + if not event.mimeData().hasText(): + event.ignore() + return + + drag_paths = event.mimeData().text().split('\n') + if not drag_paths: + event.ignore() + return + + # 获取目标项 + target_item = self.itemAt(event.pos()) + if not target_item: + # 如果拖到空白处,默认放到根目录 + target_path = self.current_path + else: + # 如果是文件夹,就放到里面 + is_folder = target_item.data(0, Qt.UserRole + 1) + if is_folder: + target_path = target_item.data(0, Qt.UserRole) + else: + # 如果是文件,则放到其父目录 + parent_item = target_item.parent() + if parent_item: + target_path = parent_item.data(0, Qt.UserRole) + else: + target_path = self.current_path + + # 执行移动 + self._moveFiles(drag_paths, target_path) + event.acceptProposedAction() + # 让 Qt 更新界面 + super().dropEvent(event) + + def _moveFiles(self, source_paths, target_dir): + """移动文件到目标目录""" + import os + import shutil + from PyQt5.QtWidgets import QMessageBox + + moved_files = [] + failed_files = [] + + for source_path in source_paths: + if not source_path or not os.path.exists(source_path): + continue + + if os.path.isdir(source_path) and target_dir.startswith(source_path): + failed_files.append(f"{source_path} (不能移动到子目录)") + continue + + if os.path.dirname(source_path) == target_dir: + continue + + filename = os.path.basename(source_path) + target_path = os.path.join(target_dir, filename) + + if os.path.exists(target_path): + failed_files.append(f"{filename} (目标已存在)") + continue + + try: + shutil.move(source_path, target_path) + moved_files.append(filename) + print(f"移动文件: {source_path} -> {target_path}") + except Exception as e: + failed_files.append(f"{filename} ({str(e)})") + + # 使用状态保持刷新 + if moved_files: + self._refreshWithStatePreservation() + + if failed_files: + QMessageBox.warning( + self, + "移动失败", + f"以下文件移动失败:\n" + "\n".join(failed_files) + ) + + if moved_files: + print(f"成功移动 {len(moved_files)} 个文件") + + # def mouseDoubleClickEvent(self, event): + # """处理双击事件""" + # item = self.itemAt(event.pos()) + # if item: + # filepath = item.data(0, Qt.UserRole) + # is_folder = item.data(0, Qt.UserRole + 1) + # + # if is_folder: + # # 文件夹:展开/折叠 + # item.setExpanded(not item.isExpanded()) + # else: + # # 文件:检查是否是模型文件 + # if filepath and filepath.lower().endswith(('.egg', '.bam', '.obj', '.fbx', '.gltf', '.glb')): + # self.world.importModel(filepath) + # else: + # # 其他文件:用系统默认程序打开 + # self.openFile(filepath) + # + # super().mouseDoubleClickEvent(event) + + +class CustomConsoleDockWidget(QWidget): + """自定义控制台停靠部件""" + + def __init__(self, world, parent=None): + if parent is None: + parent = wrapinstance(0, QWidget) + super().__init__(parent) + self.world = world + self.setupUI() + self.setupConsoleRedirect() + + def setupUI(self): + """初始化控制台UI""" + layout = QVBoxLayout(self) + + # 控制台工具栏 + toolbar = QHBoxLayout() + + # 清空按钮 + self.clearBtn = QPushButton("清空") + self.clearBtn.clicked.connect(self.clearConsole) + toolbar.addWidget(self.clearBtn) + + # 自动滚动开关 + self.autoScrollBtn = QPushButton("自动滚动") + self.autoScrollBtn.setCheckable(True) + self.autoScrollBtn.setChecked(True) + toolbar.addWidget(self.autoScrollBtn) + + # 时间戳开关 + self.timestampBtn = QPushButton("显示时间") + self.timestampBtn.setCheckable(True) + self.timestampBtn.setChecked(True) + toolbar.addWidget(self.timestampBtn) + + toolbar.addStretch() + layout.addLayout(toolbar) + + # 控制台文本区域 + from PyQt5.QtWidgets import QTextEdit + self.consoleText = QTextEdit() + self.consoleText.setReadOnly(True) + self.consoleText.setStyleSheet(""" + QTextEdit { + background-color: #1e1e1e; + color: #ffffff; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 10pt; + border: 1px solid #3e3e3e; + } + """) + layout.addWidget(self.consoleText) + + # # 命令输入区域 + # inputLayout = QHBoxLayout() + # inputLayout.addWidget(QLabel(">>> ")) + # + # self.commandInput = QLineEdit() + # self.commandInput.setStyleSheet(""" + # QLineEdit { + # background-color: #2d2d2d; + # color: #ffffff; + # font-family: 'Consolas', 'Monaco', monospace; + # font-size: 10pt; + # border: 1px solid #3e3e3e; + # padding: 5px; + # } + # """) + # self.commandInput.returnPressed.connect(self.executeCommand) + # inputLayout.addWidget(self.commandInput) + # + # layout.addLayout(inputLayout) + + # 添加欢迎信息 + self.addMessage("🎮 编辑器控制台已启动", "INFO") + + def setupConsoleRedirect(self): + """设置控制台重定向""" + import sys + + # 保存原始的stdout和stderr + self.original_stdout = sys.stdout + self.original_stderr = sys.stderr + + # 创建自定义输出流 + sys.stdout = ConsoleRedirect(self, "STDOUT") + sys.stderr = ConsoleRedirect(self, "STDERR") + + def addMessage(self, message, msg_type="INFO"): + """添加消息到控制台""" + import datetime + + # 获取当前时间 + timestamp = datetime.datetime.now().strftime("%H:%M:%S") + + # 根据消息类型设置颜色 + color_map = { + "INFO": "#ffffff", # 白色 + "WARNING": "#ffaa00", # 橙色 + "ERROR": "#ff4444", # 红色 + "SUCCESS": "#44ff44", # 绿色 + "STDOUT": "#cccccc", # 浅灰色 + "STDERR": "#ff6666", # 浅红色 + } + + color = color_map.get(msg_type, "#ffffff") + + # 构建HTML格式的消息 + if self.timestampBtn.isChecked(): + html_message = f'[{timestamp}] {message}' + else: + html_message = f'{message}' + + # 添加到控制台 + self.consoleText.append(html_message) + + # 自动滚动到底部 + if self.autoScrollBtn.isChecked(): + scrollbar = self.consoleText.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + + def clearConsole(self): + """清空控制台""" + self.consoleText.clear() + self.addMessage("控制台已清空", "INFO") + + def executeCommand(self): + """执行命令""" + command = self.commandInput.text().strip() + if not command: + return + + # 显示输入的命令 + self.addMessage(f">>> {command}", "INFO") + self.commandInput.clear() + + try: + # 执行Python命令 + if hasattr(self.world, 'executeCommand'): + result = self.world.executeCommand(command) + if result: + self.addMessage(str(result), "SUCCESS") + else: + # 简单的eval执行 + result = eval(command) + if result is not None: + self.addMessage(str(result), "SUCCESS") + + except Exception as e: + self.addMessage(f"错误: {str(e)}", "ERROR") + + def cleanup(self): + """清理资源""" + import sys + + # 恢复原始的stdout和stderr + if hasattr(self, 'original_stdout'): + sys.stdout = self.original_stdout + if hasattr(self, 'original_stderr'): + sys.stderr = self.original_stderr + + +class ConsoleRedirect: + """控制台重定向类""" + + def __init__(self, console_widget, stream_type): + self.console_widget = console_widget + self.stream_type = stream_type + + def write(self, text): + """重定向写入""" + if text.strip(): # 忽略空行 + self.console_widget.addMessage(text.strip(), self.stream_type) + + def flush(self): + """刷新缓冲区""" + pass 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.setupContextMenu() # 初始化右键菜单 self.setupDragDrop() # 设置拖拽功能 @@ -329,10 +1322,82 @@ class CustomTreeWidget(QTreeWidget): self.setDragEnabled(True) self.setAcceptDrops(True) + def setupContextMenu(self): + """设置右键菜单""" + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self.showContextMenu) + + def showContextMenu(self, position): + """显示右键菜单 - 复用主菜单的创建动作""" + if not self.selectedItems(): + print("没有选中的项目,不显示右键菜单") + return + + item = self.selectedItems()[0] + print(f"为项目 '{item.text(0)}' 显示右键菜单") + + # 创建右键菜单 + menu = QMenu(self) + + # 获取主窗口的创建菜单动作 - 关键修改 + if hasattr(self.world, 'main_window'): + main_window = self.world.main_window + create_actions = main_window.getCreateMenuActions() + + # 创建子菜单 - 复用主菜单结构 + createMenu = menu.addMenu('创建') + + # 基础对象 + createMenu.addAction(create_actions['createEmpty']) + + # 3D对象菜单 + create3dObjectMenu = createMenu.addMenu('3D对象') + + # 3D GUI菜单 + create3dGUIMenu = createMenu.addMenu('3D GUI') + create3dGUIMenu.addAction(create_actions['create3DText']) + + # GUI菜单 + createGUIMenu = createMenu.addMenu('GUI') + createGUIMenu.addAction(create_actions['createButton']) + createGUIMenu.addAction(create_actions['createLabel']) + createGUIMenu.addAction(create_actions['createEntry']) + createGUIMenu.addSeparator() + createGUIMenu.addAction(create_actions['createVideoScreen']) + createGUIMenu.addAction(create_actions['createSphericalVideo']) + createGUIMenu.addSeparator() + createGUIMenu.addAction(create_actions['createVirtualScreen']) + + # 光源菜单 + createLightMenu = createMenu.addMenu('光源') + createLightMenu.addAction(create_actions['createSpotLight']) + createLightMenu.addAction(create_actions['createPointLight']) + + else: + # 备用方案:如果无法获取主窗口动作,显示提示 + createMenu = menu.addMenu('创建') + noActionItem = createMenu.addAction('功能不可用') + noActionItem.setEnabled(False) + + # 添加删除选项 + menu.addSeparator() + deleteAction = menu.addAction('删除') + if hasattr(self, 'delete_items'): + deleteAction.triggered.connect(lambda: self.delete_items(self.selectedItems())) + + # 显示菜单 + global_pos = self.mapToGlobal(position) + action = menu.exec_(global_pos) + + print(f"右键菜单已显示在位置: {global_pos}") + if action: + print(f"用户选择了动作: {action.text()}") + else: + print("用户取消了菜单选择") + 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 @@ -347,7 +1412,7 @@ class CustomTreeWidget(QTreeWidget): if not dragged_node or not target_node: event.ignore() return - + # # 检查是否是有效的父子关系 # if self.isValidParentChild(dragged_item, target_item): # # 保存当前的世界坐标 @@ -364,8 +1429,6 @@ class CustomTreeWidget(QTreeWidget): # self.world.updatePropertyPanel(dragged_item) # self.world.property_panel._syncEffectiveVisibility(dragged_node) - - print(f"dragged_node: {dragged_node}, target_node: {target_node}") # 记录拖拽前的父节点 @@ -390,17 +1453,44 @@ class CustomTreeWidget(QTreeWidget): world_hpr = dragged_node.getHpr(self.world.render) world_scale = dragged_node.getScale(self.world.render) + # 检查是否是2D GUI元素 + dragged_type = dragged_item.data(0, Qt.UserRole + 1) + is_2d_gui = dragged_type in {"GUI_BUTTON", "GUI_LABEL", "GUI_ENTRY", "GUI_NODE"} + # 重新父化到新的父节点 if new_parent_node: - dragged_node.reparentTo(new_parent_node) + if is_2d_gui: + # 2D GUI元素需要特殊处理 + if hasattr(new_parent_node, 'getTag') and new_parent_node.getTag("is_gui_element") == "1": + # 目标是GUI元素,直接重新父化 + dragged_node.reparentTo(new_parent_node) + else: + # 目标是3D节点,保持GUI特性,重新父化到aspect2d + from direct.showbase.ShowBase import aspect2d + dragged_node.reparentTo(aspect2d) + print(f"2D GUI元素 {dragged_item.text(0)} 保持在aspect2d下") + else: + # 非GUI元素正常重新父化 + dragged_node.reparentTo(new_parent_node) else: - # 如果新父节点为None,重新父化到render - dragged_node.reparentTo(self.world.render) + # 如果新父节点为None,根据元素类型决定父节点 + if is_2d_gui: + from direct.showbase.ShowBase import aspect2d + dragged_node.reparentTo(aspect2d) + print(f"2D GUI元素 {dragged_item.text(0)} 重新父化到aspect2d") + else: + 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) + # 恢复世界坐标位置(对于2D GUI可能需要调整) + if is_2d_gui: + # 2D GUI元素使用屏幕坐标系,可能需要特殊处理 + dragged_node.setPos(world_pos) + dragged_node.setHpr(world_hpr) + dragged_node.setScale(world_scale) + else: + 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: @@ -444,15 +1534,16 @@ class CustomTreeWidget(QTreeWidget): # 检查是否成为了顶级节点 if not item.parent(): - # 如果节点名称不是"场景",说明意外成为了顶级节点 - if item.text(0) != "场景": + # 通过数据标识判断是否是场景根节点 + scene_root_marker = item.data(0, Qt.UserRole + 1) + if scene_root_marker != "SCENE_ROOT": print(f"⚠️ 检测到节点 {item.text(0)} 意外成为顶级节点,正在修正...") # 找到场景根节点 scene_root = None for i in range(self.topLevelItemCount()): top_item = self.topLevelItem(i) - if top_item.text(0) == "场景": + if top_item.data(0, Qt.UserRole + 1) == "SCENE_ROOT": scene_root = top_item break @@ -462,9 +1553,8 @@ class CustomTreeWidget(QTreeWidget): scene_root.addChild(item) print(f"✅ 已将节点 {item.text(0)} 移回场景根节点下") - def isValidParentChild(self, dragged_item, target_item): - """检查是否是有效的父子关系(防止循环)""" + """检查是否是有效的父子关系(防止循环)+ GUI类型验证""" # 1. 禁止拖放到自身 if dragged_item == target_item: @@ -472,13 +1562,14 @@ class CustomTreeWidget(QTreeWidget): # 2. 禁止拖到根节点之外(根节点本身除外) target_root = self._getRootNode(target_item) - if target_root != "场景": + if not target_root or target_root.data(0, Qt.UserRole + 1) != "SCENE_ROOT": print(f"❌ 目标节点 {target_item.text(0)} 不在场景下") return False - # 3. 禁止拖拽"场景"根节点 + # 3. 禁止拖拽场景根节点 dragged_root = self._getRootNode(dragged_item) - if dragged_item.text(0) == "场景" or dragged_root != "场景": + if (dragged_item.data(0, Qt.UserRole + 1) == "SCENE_ROOT" or + not dragged_root or dragged_root.data(0, Qt.UserRole + 1) != "SCENE_ROOT"): print(f"❌ 禁止拖拽场景根节点或根节点外的节点") return False @@ -490,14 +1581,156 @@ class CustomTreeWidget(QTreeWidget): return False current = current.parent() + # 5. GUI元素类型验证 - 新增功能 + if not self._validateGUITypeCompatibility(dragged_item, target_item): + return False + return True + def _validateGUITypeCompatibility(self, dragged_item, target_item): + """验证GUI元素类型兼容性""" + try: + # 获取节点类型标识 + dragged_type = dragged_item.data(0, Qt.UserRole + 1) + target_type = target_item.data(0, Qt.UserRole + 1) + + # 定义2D GUI元素类型 + gui_2d_types = { + "GUI_BUTTON", # DirectButton + "GUI_LABEL", # DirectLabel + "GUI_ENTRY", # DirectEntry + "GUI_NODE" # 其他2D GUI容器 + } + + # 定义3D GUI元素类型 + gui_3d_types = { + "GUI_3DTEXT", # 3D TextNode + "GUI_VIRTUAL_SCREEN" # Virtual Screen + } + + # 定义3D场景节点类型(可以接受3D GUI元素和其他3D场景元素) + scene_3d_types = { + "SCENE_ROOT", + "SCENE_NODE", + "LIGHT_NODE", + "CAMERA_NODE", + "IMPORTED_MODEL_NODE", + "MODEL_NODE" + } + + # 检查拖拽元素的类型 + is_dragged_2d_gui = dragged_type in gui_2d_types + is_dragged_3d_gui = dragged_type in gui_3d_types + is_dragged_3d_scene = dragged_type in scene_3d_types + + # 检查目标的类型 + is_target_2d_gui = target_type in gui_2d_types + is_target_3d_gui = target_type in gui_3d_types + is_target_3d_scene = target_type in scene_3d_types + + # === 严格的类型隔离验证逻辑 === + + # 1. 2D GUI元素的拖拽限制 - 只能拖拽到其他2D GUI元素下 + if is_dragged_2d_gui: + if is_target_2d_gui: + print(f"✅ 2D GUI元素 {dragged_item.text(0)} 可以拖拽到2D GUI父节点 {target_item.text(0)}") + return True + elif is_target_3d_gui: + print(f"❌ 2D GUI元素 {dragged_item.text(0)} 不能拖拽到3D GUI元素 {target_item.text(0)} 下") + print(" 💡 提示: 2D GUI元素只能作为其他2D GUI元素的子节点") + return False + elif is_target_3d_scene: + print(f"❌ 2D GUI元素 {dragged_item.text(0)} 不能拖拽到3D场景节点 {target_item.text(0)} 下") + print(" 💡 提示: 2D GUI元素应该保持在2D GUI层级结构中") + return False + else: + print(f"❌ 2D GUI元素 {dragged_item.text(0)} 不能拖拽到未知类型节点 {target_item.text(0)} 下") + return False + + # 2. 3D GUI元素的拖拽限制 - 只能拖拽到3D场景节点下 + elif is_dragged_3d_gui: + if is_target_3d_scene: + print(f"✅ 3D GUI元素 {dragged_item.text(0)} 可以拖拽到3D场景节点 {target_item.text(0)}") + return True + elif is_target_2d_gui: + print(f"❌ 3D GUI元素 {dragged_item.text(0)} 不能拖拽到2D GUI元素 {target_item.text(0)} 下") + print(" 💡 提示: 3D GUI元素不能与2D GUI元素建立父子关系") + return False + elif is_target_3d_gui: + print(f"✅ 3D GUI元素 {dragged_item.text(0)} 可以拖拽到其他3D GUI元素 {target_item.text(0)} 下") + print(" 💡 提示: 允许3D GUI元素之间建立父子关系") + return True + else: + print(f"❌ 3D GUI元素 {dragged_item.text(0)} 不能拖拽到未知类型节点 {target_item.text(0)} 下") + return False + + # 3. 3D场景元素的拖拽限制 - 只能拖拽到3D场景节点或3D GUI元素下 + elif is_dragged_3d_scene: + if is_target_3d_scene: + print(f"✅ 3D场景元素 {dragged_item.text(0)} 可以拖拽到3D场景节点 {target_item.text(0)}") + return True + elif is_target_2d_gui: + print(f"❌ 3D场景元素 {dragged_item.text(0)} 不能拖拽到2D GUI元素 {target_item.text(0)} 下") + print(" 💡 提示: 3D场景元素不能与2D GUI元素建立父子关系") + print(" 💡 建议: 将3D场景元素拖拽到其他3D场景节点或场景根节点下") + return False + elif is_target_3d_gui: + print(f"✅ 3D场景元素 {dragged_item.text(0)} 可以拖拽到3D GUI元素 {target_item.text(0)} 下") + print(" 💡 提示: 允许3D场景元素挂载在3D GUI元素下") + return True + else: + print(f"❌ 3D场景元素 {dragged_item.text(0)} 不能拖拽到未知类型节点 {target_item.text(0)} 下") + return False + + # 4. 其他未分类元素 - 严格禁止拖拽到2D GUI下 + else: + if is_target_2d_gui: + print(f"❌ 元素 {dragged_item.text(0)} 不能拖拽到2D GUI元素 {target_item.text(0)} 下") + print(" 💡 提示: 只有2D GUI元素才能作为其他2D GUI元素的子节点") + return False + elif is_target_3d_gui or is_target_3d_scene: + print(f"✅ 允许元素 {dragged_item.text(0)} 拖拽到3D节点 {target_item.text(0)}") + return True + else: + print(f"❌ 元素 {dragged_item.text(0)} 不能拖拽到未知类型节点 {target_item.text(0)} 下") + return False + + # 默认情况(理论上不应该到达这里) + print(f"⚠️ GUI类型验证遇到未处理的情况: {dragged_type} -> {target_type}") + return False + + except Exception as e: + print(f"❌ GUI类型兼容性验证失败: {str(e)}") + # 出错时采用保守策略,禁止拖拽 + return False + + def _getNodeTypeDescription(self, node_type): + """获取节点类型的描述文本(用于错误提示)""" + type_descriptions = { + "GUI_BUTTON": "2D按钮", + "GUI_LABEL": "2D标签", + "GUI_ENTRY": "2D输入框", + "GUI_NODE": "2D GUI容器", + "GUI_3DTEXT": "3D文本", + "GUI_VIRTUAL_SCREEN": "虚拟屏幕", + "SCENE_ROOT": "场景根节点", + "SCENE_NODE": "场景节点", + "LIGHT_NODE": "灯光节点", + "CAMERA_NODE": "相机节点", + "IMPORTED_MODEL_NODE": "导入模型", + "MODEL_NODE": "模型节点" + } + return type_descriptions.get(node_type, f"未知类型({node_type})") + def _getRootNode(self, item): - """获取树中节点的根节点文本""" + """获取树中节点的根节点项""" + if not item: + return None + current = item while current.parent(): current = current.parent() - return current.text(0) + return current def dragEnterEvent(self, event): """处理拖入事件""" @@ -554,33 +1787,668 @@ class CustomTreeWidget(QTreeWidget): super().keyPressEvent(event) def delete_items(self, selected_items): - """删除选中的项目""" + """删除选中的item - 简化版本""" 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}" + # 过滤掉不能删除的节点 + deletable_items = [] + for item in selected_items: + node_type = item.data(0, Qt.UserRole + 1) + panda_node = item.data(0, Qt.UserRole) + + # 跳过场景根节点和主相机 + if (node_type == "SCENE_ROOT" or + (panda_node and hasattr(self.world, 'cam') and panda_node == self.world.cam)): + continue + deletable_items.append(item) + + if not deletable_items: + QMessageBox.information(self, "提示", "没有可删除的节点") + return + + # 确认删除 + item_count = len(deletable_items) + if item_count == 1: + message = f"确定要删除节点 \"{deletable_items[0].text(0)}\" 吗?" + else: + message = f"确定要删除 {item_count} 个节点吗?" - # 创建确认对话框 reply = QMessageBox.question( - self, - title, - message, - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No # 默认选择"取消",防止误删 + self, "确认删除", message, + QMessageBox.Yes | QMessageBox.No, QMessageBox.No ) - # 只有用户确认后才执行删除 - if reply == QMessageBox.Yes: - pass - print(f"✅ 已删除 {item_count} 个节点") \ No newline at end of file + if reply != QMessageBox.Yes: + return + + # 执行删除 + deleted_count = 0 + for item in deletable_items: + try: + panda_node = item.data(0, Qt.UserRole) + if panda_node: + # 清理选择状态 + if (hasattr(self.world, 'selection') and + hasattr(self.world.selection, 'selectedNode') and + self.world.selection.selectedNode == panda_node): + self.world.selection.updateSelection(None) + + # 清理特殊资源(参考interface_manager.py的逻辑) + if hasattr(self.world, 'property_panel'): + self.world.property_panel.removeActorForModel(panda_node) + + # 清理灯光 + if hasattr(panda_node, 'getPythonTag'): + light_object = panda_node.getPythonTag('rp_light_object') + if light_object and hasattr(self.world, 'render_pipeline'): + self.world.render_pipeline.remove_light(light_object) + + # 从world列表中移除 + if hasattr(self.world, 'models') and panda_node in self.world.models: + self.world.models.remove(panda_node) + if hasattr(self.world, 'Spotlight') and panda_node in self.world.Spotlight: + self.world.Spotlight.remove(panda_node) + if hasattr(self.world, 'Pointlight') and panda_node in self.world.Pointlight: + self.world.Pointlight.remove(panda_node) + + # 从Panda3D场景中移除 + panda_node.removeNode() + + # 从Qt树中移除 + parent_item = item.parent() + if parent_item: + parent_item.removeChild(item) + else: + index = self.indexOfTopLevelItem(item) + if index >= 0: + self.takeTopLevelItem(index) + + deleted_count += 1 + print(f"✅ 删除节点: {item.text(0)}") + + except Exception as e: + print(f"❌ 删除节点 {item.text(0)} 失败: {str(e)}") + + # 最终清理 + # if hasattr(self.world, 'property_panel'): + # self.world.property_panel.clearPropertyPanel() + + print(f"✅ 已删除 {deleted_count} 个节点") + + # ==================== 辅助方法 ==================== + def _findSceneRoot(self): + """查找场景根节点""" + for i in range(self.topLevelItemCount()): + top_item = self.topLevelItem(i) + if top_item.data(0, Qt.UserRole + 1) == "SCENE_ROOT": + return top_item + return + + def _generateUniqueNodeName(self, base_name, parent_node): + """生成唯一的节点名称""" + # 获取父节点下所有子节点的名称 + existing_names = set() + for child in parent_node.getChildren(): + existing_names.add(child.getName()) + + # 如果基础名称不存在,直接使用 + if base_name not in existing_names: + return base_name + + # 否则添加数字后缀 + counter = 1 + while f"{base_name}_{counter}" in existing_names: + counter += 1 + + return f"{base_name}_{counter}" + + def add_node_to_tree_widget(self, node, parent_item, node_type): + """将node元素添加到树形控件""" + + BLACK_LIST = {'', '**', 'temp', 'collision'} + + from panda3d.core import CollisionNode + + def should_skip(node): + name = node.getName() + return name in BLACK_LIST or name.startswith('__') or isinstance(node.node(),CollisionNode) or isinstance(node.node(),ModelRoot) or name=="" + + def addNodeToTree(node,parentItem,force = False): + if not force and should_skip(node): + return + nodeItem = QTreeWidgetItem(parentItem,[node.getName()]) + nodeItem.setData(0,Qt.UserRole, node) + nodeItem.setData(0, Qt.UserRole + 1, node_type) + + for child in node.getChildren(): + addNodeToTree(child,nodeItem,force = False) + + try: + from PyQt5.QtWidgets import QTreeWidgetItem + from PyQt5.QtCore import Qt + if node_type == "IMPORTED_MODEL_NODE": + node_name = node.getTag("file") if hasattr(node, 'getTag') else "model" + addNodeToTree(node, parent_item, force=True) + else: + node_name = node.getName() if hasattr(node, 'getName') else "node" + new_qt_item = QTreeWidgetItem(parent_item, [node_name]) + new_qt_item.setData(0, Qt.UserRole, node) + new_qt_item.setData(0, Qt.UserRole + 1, node_type) + + # 展开父节点 + if hasattr(parent_item, 'setExpanded'): + parent_item.setExpanded(True) + + print(f"✅ Qt树节点添加成功: {node_name}") + return new_qt_item + + except Exception as e: + print(f"❌ 添加node到树形控件失败: {str(e)}") + return None + + def update_selection_and_properties(self, node, qt_item): + """更新选择状态和属性面板""" + try: + # 更新选择状态 + if hasattr(self.world, 'selection'): + self.world.selection.updateSelection(node) + + # 更新属性面板 + if hasattr(self.world, 'property_panel'): + self.world.property_panel.updatePropertyPanel(qt_item) + elif hasattr(self.world, 'updatePropertyPanel'): + self.world.updatePropertyPanel(qt_item) + + print(f"✅ 更新选择和属性面板: {qt_item.text(0)}") + + except Exception as e: + print(f"❌ 更新选择和属性面板失败: {str(e)}") + + # ==================== 3D辅助方法 ==================== + def get_target_parents_for_creation(self): + """获取创建目标的父节点列表""" + from PyQt5.QtCore import Qt + + target_parents = [] + + try: + selected_items = self.selectedItems() + + # 检查选中的项目,找出所有有效的父节点 + for item in selected_items: + if self._isValidParentForNewNode(item): + parent_node = item.data(0, Qt.UserRole) + if parent_node: + target_parents.append((item, parent_node)) + print(f"📍 找到有效父节点: {item.text(0)}") + + # 如果没有有效的选中节点,使用场景根节点 + if not target_parents: + print("⚠️ 没有有效选中节点,使用场景根节点") + scene_root = self._findSceneRoot() + if scene_root: + parent_node = scene_root.data(0, Qt.UserRole) + if parent_node: + target_parents.append((scene_root, parent_node)) + else: + # 如果没有_findSceneRoot方法,使用world.render作为默认父节点 + if hasattr(self.world, 'render'): + # 创建一个虚拟的树项目来表示render节点 + class MockTreeItem: + def text(self, column): + return "render" + + mock_item = MockTreeItem() + target_parents.append((mock_item, self.world.render)) + + print(f"📊 总共找到 {len(target_parents)} 个目标父节点") + return target_parents + + except Exception as e: + print(f"❌ 获取目标父节点失败: {str(e)}") + return [] + + def _isValidParentForNewNode(self, item): + """检查节点是否适合作为新节点的父节点-3d""" + if not item: + return False + + # 获取节点类型标识 + node_type = item.data(0, Qt.UserRole + 1) + + # 场景根节点和普通场景节点可以作为父节点 + if node_type in ["SCENE_ROOT", + "SCENE_NODE", "LIGHT_NODE", "CAMERA_NODE", + "IMPORTED_MODEL_NODE", + "GUI_3DTEXT"]: + return True + + # # 模型节点也可以作为父节点 + # panda_node = item.data(0, Qt.UserRole) + # if panda_node and panda_node.hasTag("is_model_root"): + # return True + # + # # 其他类型的节点也可以,但排除一些特殊情况 + # if panda_node: + # # # 排除相机节点 + # # if panda_node == self.world.cam: + # # return False + # # 排除碰撞节点 + # from panda3d.core import CollisionNode + # if isinstance(panda_node.node(), CollisionNode): + # return False + # return True + + return False + + + def get_target_parents_for_gui_creation(self): + """获取GUI创建目标的父节点列表 - 支持GUI元素作为父节点""" + from PyQt5.QtCore import Qt + + target_parents = [] + + try: + selected_items = self.selectedItems() + + # 检查选中的项目,找出所有有效的父节点 + for item in selected_items: + if self.isValidParentForGUI(item): + parent_node = item.data(0, Qt.UserRole) + if parent_node: + target_parents.append((item, parent_node)) # 修复:确保添加到列表 + print(f"📍 找到有效GUI父节点: {item.text(0)}") + else: + print(f"⚠️ GUI父节点 {item.text(0)} 的Panda3D数据为空") + + # 如果没有有效的选中节点,使用场景根节点 + if not target_parents: + print("⚠️ 没有有效选中节点,使用场景根节点") + scene_root = self._findSceneRoot() + if scene_root: + parent_node = scene_root.data(0, Qt.UserRole) + if parent_node: + target_parents.append((scene_root, parent_node)) + else: + if hasattr(self.world, 'render'): + class MockTreeItem: + def text(self, column): + return "render" + + mock_item = MockTreeItem() + target_parents.append((mock_item, self.world.render)) + + print(f"📊 总共找到 {len(target_parents)} 个目标父节点") + return target_parents + + except Exception as e: + print(f"❌ 获取目标父节点失败: {str(e)}") + return [] + + def isValidParentForGUI(self, item): + """检查节点是否适合作为GUI元素的父节点""" + if not item: + return False + + # 获取节点类型标识 + node_type = item.data(0, Qt.UserRole + 1) + + # GUI元素可以作为其他GUI元素的父节点 + if node_type in ["GUI_BUTTON", "GUI_LABEL", "GUI_ENTRY", "GUI_NODE"]: + return True + + # 场景根节点和普通场景节点也可以作为父节点 + if node_type in ["SCENE_ROOT"]: + return True + + return False + + def is_gui_element(self, node): + """判断节点是否是GUI元素""" + if not node: + return False + + # 检查是否有GUI标签 + if hasattr(node, 'getTag'): + return node.getTag("is_gui_element") == "1" + + # 检查是否是DirectGUI对象 + try: + from direct.gui.DirectGuiBase import DirectGuiBase + return isinstance(node, DirectGuiBase) + except ImportError: + return False + + def calculate_relative_gui_position(self, pos, parent_gui): + """计算相对于GUI父节点的位置""" + try: + # 对于GUI子元素,使用相对坐标 + # 这里使用较小的缩放因子,因为是相对于父GUI的位置 + relative_pos = (pos[0] * 0.05, 0, pos[2] * 0.05) + print(f"📐 计算GUI相对位置: {pos} -> {relative_pos}") + return relative_pos + except Exception as e: + print(f"❌ 计算GUI相对位置失败: {str(e)}") + # 如果计算失败,返回默认的屏幕坐标 + return (pos[0] * 0.1, 0, pos[2] * 0.1) + + #---------------------------暂时无用------------------------------- + + def add_existing_node(self, panda_node, node_type="SCENE_NODE", parent_item=None): + """将已存在的Panda3D节点添加到Qt树形控件中 + + Args: + panda_node: 已创建的Panda3D节点 + node_type: 节点类型标识 + parent_item: 父Qt项目,如果为None则使用当前选中项或根节点 + + Returns: + QTreeWidgetItem: 创建的Qt树项目 + """ + try: + if not panda_node: + print("❌ 传入的Panda3D节点为空") + return None + + # 确定父项目 - 保持简单,只处理单个父节点 + if parent_item is None: + # 优先使用当前选中的第一个有效节点作为父节点 + selected_items = self.selectedItems() + if selected_items: + # 找到第一个有效的父节点 + for potential_parent in selected_items: + if self._isValidParentForNewNode(potential_parent): + parent_item = potential_parent + print(f"📍 使用选中节点作为父节点: {parent_item.text(0)}") + break + + # 如果没有找到有效的选中节点 + if not parent_item: + print("⚠️ 所有选中节点都不适合作为父节点,查找场景根节点") + parent_item = self._findSceneRoot() + else: + # 没有选中任何节点,使用场景根节点 + print("📍 没有选中节点,使用场景根节点作为父节点") + parent_item = self._findSceneRoot() + + # 如果场景根节点也找不到,最后尝试render节点 + if not parent_item: + print("📍 场景根节点未找到,尝试使用render节点") + parent_item = self._findRenderItem() + + if not parent_item: + print("❌ 无法找到合适的父节点") + return None + + # 创建Qt树项目 + node_name = panda_node.getName() + new_qt_item = QTreeWidgetItem(parent_item, [node_name]) + new_qt_item.setData(0, Qt.UserRole, panda_node) + new_qt_item.setData(0, Qt.UserRole + 1, node_type) + + # 展开父节点 + parent_item.setExpanded(True) + + print(f"✅ 成功将现有节点添加到树形控件: {node_name} -> 父节点: {parent_item.text(0)}") + return new_qt_item + + except Exception as e: + print(f"❌ 添加现有节点到树形控件失败: {str(e)}") + import traceback + traceback.print_exc() + return None + + def _findRenderItem(self): + """查找render根节点项目""" + try: + root = self.invisibleRootItem() + for i in range(root.childCount()): + item = root.child(i) + if item.text(0) == "render": + return item + + # 如果没找到render节点,返回第一个子项目 + if root.childCount() > 0: + return root.child(0) + + return None + except Exception as e: + print(f"查找render节点失败: {e}") + return None + + def create_item(self, node_type="empty", selected_items=None): + """创建不同类型的场景节点 + + Args: + node_type: 节点类型 ("empty", "spot_light", "point_light") + selected_items: 选中的父节点项目列表,如果为None则使用当前选中项或根节点 + """ + try: + # 确定父节点 + parent_items = self._determineParentItems(selected_items) + if not parent_items: + print("⚠️ 无法确定父节点") + return [] + + created_nodes = [] + + for parent_item in parent_items: + # 验证父节点的有效性 + if not self._isValidParentForNewNode(parent_item): + print(f"⚠️ 节点 {parent_item.text(0)} 不适合作为父节点") + continue + + # 获取父节点的Panda3D对象 + parent_node = parent_item.data(0, Qt.UserRole) + if not parent_node: + print(f"⚠️ 父节点 {parent_item.text(0)} 没有对应的Panda3D对象") + continue + + # 根据节点类型创建不同的节点 + if node_type == "empty": + new_node = self._createEmptyNode(parent_node, parent_item) + elif node_type == "spot_light": + new_node = self._createSpotLightNode(parent_node, parent_item) + elif node_type == "point_light": + new_node = self._createPointLightNode(parent_node, parent_item) + else: + print(f"❌ 不支持的节点类型: {node_type}") + continue + + if new_node: + created_nodes.append(new_node) + + # 如果只创建了一个节点,自动选中它 + if len(created_nodes) == 1: + _, qt_item = created_nodes[0] + self.setCurrentItem(qt_item) + # 更新选择和属性面板 + if hasattr(self.world, 'selection'): + self.world.selection.updateSelection(qt_item.data(0, Qt.UserRole)) + if hasattr(self.world, 'property_panel'): + self.world.property_panel.updatePropertyPanel(qt_item) + + print(f"✅ 总共创建了 {len(created_nodes)} 个 {node_type} 节点") + return created_nodes + + except Exception as e: + print(f"❌ 创建 {node_type} 节点失败: {str(e)}") + import traceback + traceback.print_exc() + return [] + + def _determineParentItems(self, selected_items): + """确定父节点项目列表""" + if selected_items is not None: + return selected_items + + # 使用当前选中的项目 + current_selected = self.selectedItems() + if current_selected: + return current_selected + + # 如果没有选中任何项目,使用场景根节点 + scene_root = self._findSceneRoot() + if scene_root: + return [scene_root] + + return [] + + def _setupNewNodeDefaults(self, node): + """设置新节点的默认属性""" + # 设置默认位置(相对于父节点) + node.setPos(0, 0, 0) + node.setHpr(0, 0, 0) + node.setScale(1, 1, 1) + + # 设置默认可见性 + node.show() + + # 可以根据需要添加更多默认设置 + # 例如:默认材质、碰撞检测等 + + # ==================== 创建节点 ==================== + def _createEmptyNode(self, parent_node, parent_item): + """创建空节点""" + # 生成唯一的节点名称 + node_name = self._generateUniqueNodeName("空节点", parent_node) + + # 在Panda3D场景中创建新节点 + new_panda_node = parent_node.attachNewNode(node_name) + + # 设置新节点的默认属性 + self._setupNewNodeDefaults(new_panda_node) + + # 设置节点标签 + new_panda_node.setTag("is_scene_element", "1") + new_panda_node.setTag("node_type", "empty_node") + new_panda_node.setTag("created_by_user", "1") + + # 在Qt树中创建对应的项目 + new_qt_item = QTreeWidgetItem(parent_item, [node_name]) + new_qt_item.setData(0, Qt.UserRole, new_panda_node) + new_qt_item.setData(0, Qt.UserRole + 1, "SCENE_NODE") + + # 展开父节点 + parent_item.setExpanded(True) + + print(f"✅ 成功创建空节点: {node_name}") + return (new_panda_node, new_qt_item) + + def _createSpotLightNode(self, parent_node, parent_item): + """创建聚光灯节点""" + from RenderPipelineFile.rpcore import SpotLight + from QPanda3D.Panda3DWorld import get_render_pipeline + from panda3d.core import Vec3, NodePath + + try: + render_pipeline = get_render_pipeline() + + # 生成唯一的节点名称 + light_name = self._generateUniqueNodeName(f"Spotlight_{len(self.world.Spotlight)}", parent_node) + + # 创建挂载节点 + light_np = NodePath(light_name) + light_np.reparentTo(parent_node) + + # 创建聚光灯对象 + light = SpotLight() + light.direction = Vec3(0, 0, -1) + light.fov = 70 + light.set_color_from_temperature(5 * 1000.0) + light.energy = 5000 + light.radius = 1000 + light.casts_shadows = True + light.shadow_map_resolution = 256 + light.setPos(0, 0, 0) # 相对于父节点的位置 + + # 添加到渲染管线 + render_pipeline.add_light(light) + + # 设置节点属性和标签 + light_np.setTag("light_type", "spot_light") + light_np.setTag("is_scene_element", "1") + light_np.setTag("light_energy", str(light.energy)) + light_np.setTag("created_by_user", "1") + + # 保存光源对象引用 + light_np.setPythonTag("rp_light_object", light) + + # 添加到管理列表 + self.world.Spotlight.append(light_np) + + # 在Qt树中创建对应的项目 + new_qt_item = QTreeWidgetItem(parent_item, [light_name]) + new_qt_item.setData(0, Qt.UserRole, light_np) + new_qt_item.setData(0, Qt.UserRole + 1, "LIGHT_NODE") + + # 展开父节点 + parent_item.setExpanded(True) + + print(f"✅ 成功创建聚光灯: {light_name}") + + except Exception as e: + print(f"❌ 创建聚光灯失败: {str(e)}") + return None + + def _createPointLightNode(self, parent_node, parent_item): + """创建点光源节点""" + from RenderPipelineFile.rpcore import PointLight + from QPanda3D.Panda3DWorld import get_render_pipeline + from panda3d.core import Vec3, NodePath + + try: + render_pipeline = get_render_pipeline() + + # 生成唯一的节点名称 + light_name = self._generateUniqueNodeName(f"Pointlight_{len(self.world.Pointlight)}", parent_node) + + # 创建挂载节点 + light_np = NodePath(light_name) + light_np.reparentTo(parent_node) + + # 创建点光源对象 + light = PointLight() + light.setPos(0, 0, 0) # 相对于父节点的位置 + light.energy = 5000 + light.radius = 1000 + light.inner_radius = 0.4 + light.set_color_from_temperature(5 * 1000.0) + light.casts_shadows = True + light.shadow_map_resolution = 256 + + # 添加到渲染管线 + render_pipeline.add_light(light) + + # 设置节点属性和标签 + light_np.setTag("light_type", "point_light") + light_np.setTag("is_scene_element", "1") + light_np.setTag("light_energy", str(light.energy)) + light_np.setTag("created_by_user", "1") + + # 保存光源对象引用 + light_np.setPythonTag("rp_light_object", light) + + # 添加到管理列表 + self.world.Pointlight.append(light_np) + + # 在Qt树中创建对应的项目 + new_qt_item = QTreeWidgetItem(parent_item, [light_name]) + new_qt_item.setData(0, Qt.UserRole, light_np) + new_qt_item.setData(0, Qt.UserRole + 1, "LIGHT_NODE") + + # 展开父节点 + parent_item.setExpanded(True) + + print(f"✅ 成功创建点光源: {light_name}") + return (light_np, new_qt_item) + + except Exception as e: + print(f"❌ 创建点光源失败: {str(e)}") + return None + + + + +