EG/core/assembly_interaction.py
2025-12-12 16:16:15 +08:00

2339 lines
106 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
import os
from direct.showbase.DirectObject import DirectObject
from direct.task import Task
from panda3d.core import CollisionTraverser, CollisionNode, CollisionHandlerQueue, CollisionRay
from panda3d.core import BitMask32, Vec3, Point3, Plane
from PyQt5.QtWidgets import QMessageBox, QFileDialog, QDialog, QVBoxLayout, QHBoxLayout
from PyQt5.QtWidgets import QLabel, QPushButton, QTextEdit, QComboBox, QGroupBox
from PyQt5.QtCore import Qt
# 导入维修系统GUI
try:
from core.maintenance_gui import MaintenanceGUI
except ImportError as e:
print(f"⚠️ 导入维修GUI失败: {e}")
MaintenanceGUI = None
class AssemblyInteractionManager(DirectObject):
"""拆装交互管理器(优化版)"""
def __init__(self, world):
DirectObject.__init__(self)
self.world = world
self.config_data = None
self.current_step = 0
self.total_steps = 0
self.is_active = False
# --- 模式控制 ---
self.mode = "training" # 默认训练模式,可选 "training" 或 "exam"
# --- 拖拽相关(优化) ---
self.dragging_model = None
self.drag_offset = Vec3(0, 0, 0)
self.drag_plane = Plane(Vec3(0, 1, 0), Point3(0, 0, 0)) # 初始化一个平面
# --- 安装/拆卸状态管理 ---
self.model_original_positions = {} # 记录模型的原始位置
self.model_current_states = {} # 记录模型当前状态 ('installed' 或 'removed')
# --- UI组件 ---
self.step_dialog = None
# --- 维修系统GUI ---
self.maintenance_gui = None
if MaintenanceGUI:
self.maintenance_gui = MaintenanceGUI(world)
# --- 操作权限控制 ---
self.operation_enabled = True # 是否允许进行操作
# --- 考核相关 ---
self.exam_score = 0 # 考核总分
self.exam_max_score = 0 # 考核满分
self.step_scores = {} # 每步得分记录
# --- 碰撞检测(优化) ---
self.picker_traverser = CollisionTraverser('picker_traverser')
self.collision_handler = CollisionHandlerQueue()
self.picker_ray_node = None
print("拆装交互管理器初始化完成")
def init_exam_mode(self):
"""初始化考核模式"""
print("📝 初始化考核模式...")
# 重置考核数据
self.exam_score = 0
self.exam_max_score = 0
self.step_scores = {}
# 计算总分
steps = self.config_data.get('steps', [])
for i, step_data in enumerate(steps):
step_score = step_data.get('score', 10) # 默认每步10分
self.exam_max_score += step_score
self.step_scores[i] = {
'max_score': step_score,
'current_score': step_score, # 初始满分,操作错误时扣分
'tool_error': False, # 是否有工具错误
'operation_attempts': 0 # 操作尝试次数
}
print(f"📝 考核模式初始化完成,总分: {self.exam_max_score}")
def play_step_audio(self, step_data):
"""播放步骤音频"""
try:
# 考核模式下不播放音频
if self.mode == "exam":
print("🔇 考核模式,音频播放已禁用")
return
# 检查是否启用了自动播放音频
settings = self.config_data.get('settings', {})
auto_play_audio = settings.get('auto_play_audio', True)
if not auto_play_audio:
print("🔇 音频播放已禁用")
return
# 获取音频文件路径
audio_file = step_data.get('audio_file', '')
if not audio_file or audio_file.strip() == '':
print("🔇 当前步骤没有配置音频文件")
return
print(f"🔊 准备播放步骤音频: {audio_file}")
import os
# 检查音频文件格式
file_ext = os.path.splitext(audio_file)[1].lower()
print(f"🔊 音频文件格式: {file_ext}")
# 对于MP3文件优先使用pygame播放
if file_ext in ['.mp3', '.m4a', '.aac']:
print("🔊 检测到MP3/M4A/AAC格式优先尝试pygame播放")
if self.play_audio_with_pygame(audio_file):
return
print("🔊 pygame不可用尝试备用播放方法")
self.play_audio_fallback(audio_file)
return
# 检查音频文件是否存在
import os
if not os.path.isfile(audio_file):
# 如果不是绝对路径,尝试在项目目录下查找
if not os.path.isabs(audio_file):
# 尝试在当前目录、Resources目录等位置查找
possible_paths = [
audio_file,
os.path.join("Resources", audio_file),
os.path.join("Resources", "audio", audio_file),
os.path.join("audio", audio_file),
os.path.join("sounds", audio_file)
]
audio_file_found = None
for path in possible_paths:
if os.path.isfile(path):
audio_file_found = path
break
if audio_file_found:
audio_file = audio_file_found
print(f"🔊 找到音频文件: {audio_file}")
else:
print(f"❌ 找不到音频文件: {audio_file}")
print(f" 尝试的路径: {possible_paths}")
return
else:
print(f"❌ 音频文件不存在: {audio_file}")
return
# 使用Panda3D的音频系统播放音频
if hasattr(self.world, 'loader') and self.world.loader:
try:
# 检查音频系统是否启用
from panda3d.core import AudioManager
print(f"🔊 音频管理器状态: {AudioManager.createAudioManager()}")
# 加载音频文件
print(f"🔊 尝试加载音频文件: {audio_file}")
audio_sound = self.world.loader.loadSfx(audio_file)
if audio_sound:
print(f"✅ 音频文件加载成功: {os.path.basename(audio_file)}")
print(f"🔊 音频状态: {audio_sound.status()}")
audio_length = audio_sound.length()
print(f"🔊 音频长度: {audio_length:.2f}")
# 检查音频文件是否有效
if audio_length <= 0:
print("⚠️ 音频长度为0可能是格式不支持或文件损坏")
print("🔊 尝试使用pygame播放")
if not self.play_audio_with_pygame(audio_file):
self.play_audio_fallback(audio_file)
return
# 设置音量(在播放前设置)
audio_sound.setVolume(0.8)
print(f"🔊 设置音量: 80%")
# 播放音频
audio_sound.play()
print(f"✅ 开始播放音频: {os.path.basename(audio_file)}")
# 等待一小段时间让播放状态更新
import time
time.sleep(0.1)
final_status = audio_sound.status()
print(f"🔊 播放后状态: {final_status}")
# 检查是否真的在播放 (状态码2表示PLAYING)
if final_status == 2: # PLAYING
print("✅ 音频正在播放中")
elif final_status == 1: # READY
print("⚠️ 音频处于就绪状态但未播放尝试pygame")
if not self.play_audio_with_pygame(audio_file):
self.play_audio_fallback(audio_file)
else:
print(f"⚠️ 音频状态异常: {final_status}")
if not self.play_audio_with_pygame(audio_file):
self.play_audio_fallback(audio_file)
else:
print(f"❌ 无法加载音频文件: {audio_file}")
print("🔊 Panda3D加载失败尝试pygame")
if not self.play_audio_with_pygame(audio_file):
self.play_audio_fallback(audio_file)
except Exception as e:
print(f"❌ Panda3D音频播放失败: {e}")
import traceback
traceback.print_exc()
# 尝试使用pygame播放备用方案
print("🔊 Panda3D播放失败尝试pygame")
if not self.play_audio_with_pygame(audio_file):
self.play_audio_fallback(audio_file)
else:
print("⚠️ 找不到Panda3D音频加载器尝试pygame")
if not self.play_audio_with_pygame(audio_file):
self.play_audio_fallback(audio_file)
except Exception as e:
print(f"❌ 音频播放失败: {e}")
import traceback
traceback.print_exc()
def play_audio_with_pygame(self, audio_file):
"""使用pygame播放音频优先方法"""
try:
import pygame
print("🔊 使用pygame播放音频")
# 初始化pygame mixer
if not pygame.mixer.get_init():
pygame.mixer.pre_init(frequency=22050, size=-16, channels=2, buffer=1024)
pygame.mixer.init()
print("🔊 pygame mixer初始化完成")
# 停止当前播放的音频(如果有)
if pygame.mixer.music.get_busy():
pygame.mixer.music.stop()
print("🔊 停止之前的音频")
# 加载并播放音频文件
pygame.mixer.music.load(audio_file)
pygame.mixer.music.set_volume(0.8)
pygame.mixer.music.play()
print(f"✅ pygame开始播放音频: {os.path.basename(audio_file)}")
# 在后台线程中监控播放完成
def wait_for_pygame_completion():
import time
while pygame.mixer.music.get_busy():
time.sleep(0.1)
print(f"🎵 pygame音频播放完成: {os.path.basename(audio_file)}")
import threading
threading.Thread(target=wait_for_pygame_completion, daemon=True).start()
print("🎵 音频将在后台完整播放")
return True
except ImportError:
print("⚠️ pygame未安装")
return False
except Exception as e:
print(f"⚠️ pygame播放失败: {e}")
import traceback
traceback.print_exc()
return False
def play_audio_fallback(self, audio_file):
"""备用音频播放方法"""
import os
print(f"🔊 尝试备用音频播放方法...")
# 方法1: 使用系统默认播放器(最简单)
try:
print("🔊 尝试使用系统默认播放器")
if os.name == 'nt': # Windows
os.startfile(audio_file)
print(f"✅ 使用系统默认播放器播放: {os.path.basename(audio_file)}")
return
elif os.name == 'posix': # Linux/Mac
os.system(f'xdg-open "{audio_file}"')
print(f"✅ 使用系统默认播放器播放: {os.path.basename(audio_file)}")
return
except Exception as e:
print(f"⚠️ 系统默认播放器失败: {e}")
# 方法2: 尝试使用Windows Media Player
try:
if os.name == 'nt': # Windows
print("🔊 尝试使用Windows Media Player")
cmd = f'start wmplayer "{audio_file}"'
os.system(cmd)
print(f"✅ 使用Windows Media Player播放音频: {os.path.basename(audio_file)}")
return
except Exception as e:
print(f"⚠️ Windows Media Player播放失败: {e}")
# 方法3: 使用PowerShell播放WAV文件
try:
if os.name == 'nt':
file_ext = os.path.splitext(audio_file)[1].lower()
if file_ext in ['.wav']:
print("🔊 尝试使用PowerShell播放WAV文件")
import subprocess
cmd = f'powershell -c "(New-Object Media.SoundPlayer \\"{audio_file}\\").PlaySync()"'
subprocess.run(cmd, shell=True)
print(f"✅ 使用PowerShell播放音频: {os.path.basename(audio_file)}")
return
except Exception as e:
print(f"⚠️ PowerShell播放失败: {e}")
# 方法4: 简单的系统调用
try:
print("🔊 尝试使用系统默认播放器")
if os.name == 'nt': # Windows
os.startfile(audio_file)
elif os.name == 'posix': # Linux/Mac
os.system(f'xdg-open "{audio_file}"')
print(f"✅ 使用系统默认播放器播放音频: {os.path.basename(audio_file)}")
except Exception as e:
print(f"❌ 所有备用音频播放方法都失败了: {e}")
print("💡 建议检查:")
print(" 1. 音频文件是否存在且格式正确")
print(" 2. 系统音频驱动是否正常")
print(" 3. 音频文件是否损坏")
def set_operation_enabled(self, enabled):
"""设置操作是否启用"""
self.operation_enabled = enabled
print(f"🔧 操作权限: {'启用' if enabled else '禁用'}")
def check_operation_permission(self):
"""检查操作权限"""
if not self.is_active:
print("⚠️ 交互模式未激活")
return False
# 获取当前步骤数据
if self.current_step >= self.total_steps:
print("⚠️ 所有步骤已完成")
return False
step_data = self.config_data['steps'][self.current_step]
required_tool = step_data.get('required_tool', '')
# 获取当前选择的工具优先从维修GUI获取
current_tool = ""
if self.maintenance_gui:
current_tool = self.maintenance_gui.get_current_tool()
elif hasattr(self, 'step_dialog') and self.step_dialog and hasattr(self.step_dialog, 'tool_combo'):
current_tool = self.step_dialog.tool_combo.currentText()
# 使用 getChinese 处理工具名称
if hasattr(self.world, 'getChinese'):
required_tool = self.world.getChinese(required_tool)
current_tool = self.world.getChinese(current_tool)
print(f"🔧 工具检查: 需要'{required_tool}',当前'{current_tool}'")
# 检查工具是否匹配
tool_matches = self.check_tool_match(current_tool, required_tool)
if self.mode == "training":
# 训练模式显示GUI警告并阻止错误操作
if not tool_matches:
print("⚠️ 训练模式 - 工具不匹配,无法进行操作")
# 在维修GUI中显示警告
if self.maintenance_gui:
warning_msg = f"请先选择 '{required_tool}' 工具!"
self.maintenance_gui.show_warning(warning_msg, 2.0)
# 也在Qt对话框中显示警告仅在非维修系统模式下
elif hasattr(self, 'step_dialog') and self.step_dialog:
QMessageBox.warning(self.step_dialog, "工具不匹配",
f"当前步骤需要使用 '{required_tool}' 工具,请先选择正确的工具!")
return False
return True
else:
# 考核模式:不显示提示,但记录工具错误并阻止操作
if not tool_matches:
print(f"❌ 考核模式 - 工具错误:需要'{required_tool}',实际选择'{current_tool}',操作被阻止")
# 记录工具错误(扣分)
self.record_tool_error()
return False
return True
def check_tool_match(self, current_tool, required_tool):
"""检查工具是否匹配"""
# 如果步骤不要求特定工具,任何工具都可以
if required_tool == "" or required_tool == "" or not required_tool:
return True
# 精确匹配
if current_tool == required_tool:
return True
return False
def record_tool_error(self):
"""记录工具错误(考核模式下扣分)"""
if self.mode != "exam":
return
# 获取当前步骤的考核记录
if self.current_step in self.step_scores:
step_record = self.step_scores[self.current_step]
# 如果是第一次工具错误,扣除一定分数
if not step_record['tool_error']:
step_record['tool_error'] = True
# 扣除该步骤50%的分数作为工具错误惩罚
# penalty = step_record['max_score'] * 0.5
penalty = step_record['max_score']
step_record['current_score'] = max(0, step_record['current_score'] - penalty)
print(f"📝 考核记录:工具错误,扣除{penalty:.0f}分,当前步骤剩余{step_record['current_score']:.0f}")
# 增加操作尝试次数
step_record['operation_attempts'] += 1
def start_interaction_mode(self, mode="training"):
"""启动交互模式"""
try:
# 设置模式
self.mode = mode
print(f"🎯 启动拆装交互模式: {self.mode}")
# 如果没有配置数据,尝试加载
if not self.config_data:
if not self.load_configuration():
return False
if not self.config_data or not self.config_data.get('steps'):
QMessageBox.warning(None, "警告", "没有找到有效的配置步骤!\n请先在'拆装配置'中设置步骤。")
return False
self.total_steps = len(self.config_data['steps'])
self.current_step = 0
self.is_active = True
# 设置维修系统GUI
if self.maintenance_gui:
tools_list = self.config_data.get('tools', [''])
self.maintenance_gui.setup_gui(tools_list, mode)
self.maintenance_gui.show_gui()
print(f"✅ 维修系统GUI已启动模式: {mode}")
# 如果是考核模式,初始化考核相关数据
if self.mode == "exam":
self.init_exam_mode()
# 显示考核开始提示
if self.maintenance_gui:
exam_start_msg = f"📝 考核模式已启动\n\n总分: {self.exam_max_score}\n步骤数: {self.total_steps}\n\n✅ 可以选择和切换工具\n❌ 不会显示步骤描述和语音提示\n❌ 使用错误工具会扣分"
self.maintenance_gui.update_step_info(exam_start_msg)
print("📝 考核开始提示已在GUI中显示")
# 设置碰撞检测
self.setup_picking()
# 拦截Qt层面的鼠标事件处理
print("🔄 拦截Qt层面的鼠标事件处理")
print(f"🔍 检查world对象: {type(self.world).__name__}")
print(f"🔍 是否有qtWidget属性: {hasattr(self.world, 'qtWidget')}")
if hasattr(self.world, 'qtWidget'):
print(f"🔍 qtWidget值: {self.world.qtWidget}")
print(f"🔍 qtWidget类型: {type(self.world.qtWidget)}")
if hasattr(self.world, 'qtWidget') and self.world.qtWidget:
# 备份原有的鼠标事件处理方法
self.original_mouse_press_event = self.world.qtWidget.mousePressEvent
self.original_mouse_release_event = self.world.qtWidget.mouseReleaseEvent
print(f"🔍 原有鼠标事件方法: {self.original_mouse_press_event}")
# 替换为我们的处理方法
self.world.qtWidget.mousePressEvent = self.qt_mouse_press_event
self.world.qtWidget.mouseReleaseEvent = self.qt_mouse_release_event
print("✅ Qt鼠标事件已被拦截")
else:
print("⚠️ 找不到Qt部件使用Panda3D事件系统")
# 作为备用方案仍然绑定Panda3D事件
self.accept("mouse1", self.on_mouse_down)
self.accept("mouse1-up", self.on_mouse_up)
# 绑定键盘备用触发
self.accept("space", self.trigger_drag_by_keyboard)
self.accept("s", self.show_models_status) # S键显示模型状态
self.accept("r", self.record_current_positions) # R键记录当前位置为原始位置
print("✅ 拆装交互事件绑定完成")
print("💡 提示: 按S键可查看所有模型状态按R键重新记录原始位置")
print("拆装交互模式已启动,核心事件已绑定。")
print("请点击目标模型进行操作。")
# 调试:列出场景中的所有模型
self.list_scene_models()
# 显示步骤指引界面维修系统使用GUI界面跳过Qt对话框
if not self.maintenance_gui:
self.show_step_dialog()
# 开始第一步
self.start_current_step()
print(f"{self.total_steps}")
return True
except Exception as e:
QMessageBox.critical(None, "错误", f"启动交互模式失败: {str(e)}")
import traceback
traceback.print_exc()
return False
def load_configuration(self):
"""加载拆装配置"""
try:
file_path, _ = QFileDialog.getOpenFileName(
None, "选择拆装配置文件", "",
"JSON文件 (*.json);;所有文件 (*)"
)
if not file_path:
return False
with open(file_path, 'r', encoding='utf-8') as f:
self.config_data = json.load(f)
self.total_steps = len(self.config_data.get('steps', []))
self.current_step = 0
# 从配置文件中恢复原始位置信息
self.load_original_positions_from_config()
print(f"成功加载配置: {file_path}")
return True
except Exception as e:
QMessageBox.critical(None, "错误", f"加载配置失败: {str(e)}")
return False
def load_original_positions_from_config(self):
"""从配置文件中加载模型的原始位置信息"""
print("📍 从配置文件恢复模型原始位置...")
# 清空现有的位置记录
self.model_original_positions.clear()
self.model_current_states.clear()
# 从配置文件的models部分读取原始位置models是数组格式
models_config = self.config_data.get('models', [])
for model_data in models_config:
model_name = model_data.get('name')
if not model_name:
continue
# 尝试从配置文件中获取原始位置
original_pos_data = model_data.get('original_pos')
if original_pos_data and len(original_pos_data) >= 3:
# 如果配置文件中有原始位置,使用配置文件中的位置
original_pos = Vec3(original_pos_data[0], original_pos_data[1], original_pos_data[2])
self.model_original_positions[model_name] = original_pos
self.model_current_states[model_name] = 'installed'
print(f" 📍 {model_name}: 从配置文件恢复原始位置 {original_pos}")
else:
# 如果配置文件中没有原始位置,使用当前场景中的位置作为原始位置
target_node = self.find_model_node(model_name)
if target_node and not target_node.isEmpty():
current_pos = target_node.getPos()
self.model_original_positions[model_name] = current_pos
self.model_current_states[model_name] = 'installed'
print(f" 📍 {model_name}: 使用当前场景位置作为原始位置 {current_pos}")
else:
print(f"{model_name}: 在场景中找不到对应模型")
# 如果配置文件中没有models部分从步骤中提取模型信息
if not models_config:
print(" 配置文件中没有models部分从步骤中提取模型信息...")
for step_data in self.config_data.get('steps', []):
target_model_name = step_data.get('target_model')
if target_model_name and target_model_name not in self.model_original_positions:
target_node = self.find_model_node(target_model_name)
if target_node and not target_node.isEmpty():
current_pos = target_node.getPos()
self.model_original_positions[target_model_name] = current_pos
self.model_current_states[target_model_name] = 'installed'
print(f" 📍 {target_model_name}: 从场景记录原始位置 {current_pos}")
print(f"📍 原始位置恢复完成,共记录 {len(self.model_original_positions)} 个模型")
# 检查配置文件的步骤完整性
self.check_step_completeness()
def check_step_completeness(self):
"""检查配置文件的步骤完整性"""
print("\n🔍 检查步骤配置完整性...")
steps = self.config_data.get('steps', [])
if not steps:
print("⚠️ 配置文件中没有步骤")
return
# 统计每个模型的拆卸和安装步骤
model_operations = {}
for i, step in enumerate(steps):
target_model = step.get('target_model')
step_type = self.normalize_step_type(step)
if target_model:
if target_model not in model_operations:
model_operations[target_model] = {'disassemble': [], 'assemble': []}
model_operations[target_model][step_type].append(i + 1)
# 检查每个模型是否有配对的拆卸和安装步骤
incomplete_models = []
for model_name, operations in model_operations.items():
disassemble_count = len(operations['disassemble'])
assemble_count = len(operations['assemble'])
print(f" 📋 {model_name}: 拆卸步骤 {disassemble_count} 个, 安装步骤 {assemble_count}")
if disassemble_count > 0 and assemble_count == 0:
incomplete_models.append(model_name)
print(f" ⚠️ 只有拆卸步骤,缺少安装步骤")
elif disassemble_count != assemble_count:
print(f" ⚠️ 拆卸和安装步骤数量不匹配")
if incomplete_models:
print(f"\n⚠️ 发现 {len(incomplete_models)} 个模型缺少安装步骤:")
for model in incomplete_models:
print(f" - {model}")
print("💡 建议为每个拆卸的模型添加对应的安装步骤")
else:
print("\n✅ 所有模型都有完整的拆卸和安装步骤配置")
def setup_picking(self):
"""设置用于鼠标拾取的碰撞检测系统"""
try:
# 创建一个碰撞射线
picker_ray = CollisionRay()
# 创建一个用于放置射线的碰撞节点
picker_ray_node = CollisionNode('mouse_ray')
picker_ray_node.addSolid(picker_ray)
# 设置碰撞掩码,确保它只与可拾取的物体碰撞
# 这里我们假设可拾取的物体有第1位的碰撞比特
picker_ray_node.setFromCollideMask(BitMask32.bit(1))
picker_ray_node.setIntoCollideMask(BitMask32.allOff())
# 将碰撞节点附加到相机上,这样射线就会跟随相机移动
self.picker_ray_node = self.world.camera.attachNewNode(picker_ray_node)
# 将碰撞节点添加到遍历器中,并指定一个处理器来存储碰撞结果
self.picker_traverser.addCollider(self.picker_ray_node, self.collision_handler)
print("射线拾取系统设置完成")
except Exception as e:
print(f"设置碰撞检测失败: {e}")
def show_step_dialog(self):
"""显示步骤指引对话框"""
if self.step_dialog:
self.step_dialog.close()
# 获取主窗口作为父窗口
parent_window = None
if hasattr(self.world, 'main_window') and self.world.main_window:
parent_window = self.world.main_window
self.step_dialog = StepGuideDialog(self, parent_window)
self.step_dialog.show()
def start_current_step(self):
"""开始当前步骤"""
if self.current_step >= self.total_steps:
self.finish_interaction()
return
step_data = self.config_data['steps'][self.current_step]
# 兼容中文和英文配置格式
operation_type = self.normalize_operation_type(step_data)
step_type = self.normalize_step_type(step_data)
target_model_name = step_data.get('target_model', '未设置')
# 调试信息:显示字段映射结果
print(f"🔄 字段映射: '{step_data.get('type', 'N/A')}' -> '{step_type}'")
print(f"🔄 操作映射: '{step_data.get('interaction_type', 'N/A')}' -> '{operation_type}'")
# 使用 getChinese 处理步骤名称
step_name = step_data.get('name', '未命名')
if hasattr(self.world, 'getChinese'):
step_name = self.world.getChinese(step_name)
target_model_name = self.world.getChinese(target_model_name)
print(f"\n=== 开始第 {self.current_step + 1} 步: {step_name} ===")
print(f"目标模型: {target_model_name}")
print(f"操作类型: {operation_type}")
print(f"步骤类型: {step_type}")
# 显示容差信息
if step_type == 'assemble':
snap_tolerance = step_data.get('snap_tolerance') or step_data.get('tolerance', 5.0)
print(f"吸附容差: {snap_tolerance:.1f} 单位")
else:
disassemble_threshold = step_data.get('disassemble_threshold', 5.0)
print(f"拆卸阈值: {disassemble_threshold:.1f} 单位(距离原位)")
# 检查模型的原始位置记录
if target_model_name:
if target_model_name in self.model_original_positions:
original_pos = self.model_original_positions[target_model_name]
print(f"📍 模型 '{target_model_name}' 的原始位置: {original_pos}")
else:
# 如果没有记录,使用当前位置作为原始位置(但会给出警告)
target_node = self.find_model_node(target_model_name)
if target_node and not target_node.isEmpty():
current_pos = target_node.getPos()
self.model_original_positions[target_model_name] = current_pos
self.model_current_states[target_model_name] = 'installed'
print(f"⚠️ 模型 '{target_model_name}' 没有预设原始位置,使用当前位置: {current_pos}")
print(" 建议在配置文件中预设模型的original_position以确保一致性")
# 如果是安装步骤,确保模型显示
if step_type == 'assemble' and target_model_name:
target_node = self.find_model_node(target_model_name)
if target_node and not target_node.isEmpty():
if target_node.isHidden():
print(f"👁️ 安装步骤开始 - 显示隐藏的模型: {target_model_name}")
target_node.show()
else:
print(f"✅ 模型 '{target_model_name}' 已经可见")
# 播放步骤音频(如果有配置)
self.play_step_audio(step_data)
# 更新维修系统GUI的步骤信息
if self.maintenance_gui:
step_name = step_data.get('name', f'步骤 {self.current_step + 1}')
step_description = step_data.get('description', '')
# 使用 getChinese 处理文本
if hasattr(self.world, 'getChineseFont'):
step_name = self.world.getChineseFont()
step_description = self.world.getChineseFont()
if self.mode == "exam":
# 考核模式:只显示步骤编号和名称,不显示描述
step_info = f"Step {self.current_step + 1}/{self.total_steps}: {step_name}"
print(f"📝 考核模式步骤信息: {step_info}")
else:
# 训练模式:显示完整信息
step_info = f"Step: {self.current_step + 1}/{self.total_steps}: {step_name}"
if step_description:
step_info += f"\n{step_description}"
print(f"📚 训练模式步骤信息: {step_info}")
self.maintenance_gui.update_step_info(step_info)
# 更新Qt步骤对话框仅在非维修系统模式下
if self.step_dialog and not self.maintenance_gui:
self.step_dialog.update_step_info(step_data, self.current_step + 1, self.total_steps)
# 准备交互(主要是确保目标模型可以被拾取)
self.prepare_interaction(step_data)
def prepare_interaction(self, step_data):
"""准备交互,主要是设置目标模型的碰撞掩码"""
target_model_name = step_data.get('target_model')
if not target_model_name:
print("警告: 当前步骤没有指定目标模型")
return
target_node = self.find_model_node(target_model_name)
if not target_node:
print(f"警告: 找不到目标模型 '{target_model_name}'")
return
# 给目标模型设置碰撞掩码,使其可以被射线拾取
# BitMask32.bit(1) 对应 setup_picking 中设置的 from_collide_mask
target_node.setCollideMask(BitMask32.bit(1))
print(f"已为模型 '{target_model_name}' 设置碰撞掩码,等待用户点击。")
def find_model_node(self, model_name):
"""在场景中查找指定名称的模型节点"""
# find() 是Panda3D提供的更高效的节点查找方法
return self.world.render.find(f"**/{model_name}")
def list_scene_models(self):
"""列出场景中的所有模型(调试用)"""
print("\n📋 场景中的模型列表:")
all_nodes = self.world.render.findAllMatches("**")
model_count = 0
for node_path in all_nodes:
node_name = node_path.getName()
# 过滤掉系统节点和碰撞节点
if (node_name and
node_name not in ['render', 'camera', 'aspect2d', 'pixel2d', 'cam'] and
not node_name.startswith("modelCollision_") and
not node_name.startswith("collision") and
not node_name.startswith("picker")):
print(f" - {node_name}")
model_count += 1
print(f"总共找到 {model_count} 个模型节点\n")
def show_models_status(self):
"""显示所有模型的当前状态"""
if not self.model_original_positions:
print("📊 没有记录任何模型状态")
return
print("\n📊 模型状态信息:")
for model_name, original_pos in self.model_original_positions.items():
current_state = self.model_current_states.get(model_name, 'unknown')
model_node = self.find_model_node(model_name)
if model_node and not model_node.isEmpty():
current_pos = model_node.getPos()
distance_from_original = (current_pos - original_pos).length()
status_icon = "🔧" if current_state == 'installed' else "📦" if current_state == 'removed' else ""
print(f" {status_icon} {model_name}:")
print(f" 状态: {current_state}")
print(f" 原始位置: {original_pos}")
print(f" 当前位置: {current_pos}")
print(f" 距离原位: {distance_from_original:.2f}")
else:
print(f"{model_name}: 找不到模型节点")
print()
def record_current_positions(self):
"""将当前所有模型的位置记录为原始位置"""
print("\n📍 重新记录所有模型的原始位置...")
# 从步骤中提取所有模型
all_models = set()
for step_data in self.config_data.get('steps', []):
target_model = step_data.get('target_model')
if target_model:
all_models.add(target_model)
# 记录每个模型的当前位置为原始位置
for model_name in all_models:
target_node = self.find_model_node(model_name)
if target_node and not target_node.isEmpty():
current_pos = target_node.getPos()
self.model_original_positions[model_name] = current_pos
self.model_current_states[model_name] = 'installed'
print(f" 📍 {model_name}: 记录新的原始位置 {current_pos}")
else:
print(f"{model_name}: 在场景中找不到对应模型")
print(f"📍 重新记录完成,共记录 {len(self.model_original_positions)} 个模型")
print("💡 提示: 如需永久保存这些位置,请在配置界面中重新保存配置文件")
def normalize_step_type(self, step_data):
"""标准化步骤类型,兼容中文和英文配置"""
# 优先使用英文字段
step_type = step_data.get('step_type')
if step_type:
return step_type
# 兼容中文字段
type_field = step_data.get('type', '')
if type_field == '拆卸':
return 'disassemble'
elif type_field == '安装':
return 'assemble'
else:
return 'disassemble' # 默认值
def normalize_operation_type(self, step_data):
"""标准化操作类型,兼容中文和英文配置"""
# 优先使用英文字段
operation_type = step_data.get('operation_type')
if operation_type:
return operation_type
# 兼容中文字段
interaction_type = step_data.get('interaction_type', '')
if interaction_type == '鼠标拖拽':
return 'drag'
elif interaction_type == '点击触发':
return 'click'
else:
return 'drag' # 默认值
def analyze_final_state(self):
"""分析最终状态,检查哪些模型没有正确回到原位"""
print("\n📊 最终状态分析:")
if not self.model_original_positions:
print("没有模型状态记录")
return
incorrect_models = []
for model_name, original_pos in self.model_original_positions.items():
model_node = self.find_model_node(model_name)
if model_node and not model_node.isEmpty():
current_pos = model_node.getPos()
distance = (current_pos - original_pos).length()
current_state = self.model_current_states.get(model_name, 'unknown')
# 检查是否应该在原位但实际不在原位的模型
if current_state == 'installed' and distance > 1.0: # 容差1.0单位
incorrect_models.append({
'name': model_name,
'expected_pos': original_pos,
'actual_pos': current_pos,
'distance': distance
})
print(f"{model_name}: 应该在原位但偏离了 {distance:.2f} 单位")
print(f" 期望位置: {original_pos}")
print(f" 实际位置: {current_pos}")
elif current_state == 'installed' and distance <= 1.0:
print(f"{model_name}: 正确安装在原位 (偏差 {distance:.2f})")
elif current_state == 'removed':
print(f"📦 {model_name}: 已拆卸 (距离原位 {distance:.2f})")
if incorrect_models:
print(f"\n⚠️ 发现 {len(incorrect_models)} 个模型没有正确回到原位")
print("💡 建议检查安装步骤的配置和吸附容差设置")
else:
print("\n✅ 所有模型都处于正确状态")
def qt_mouse_press_event(self, event):
"""Qt鼠标按下事件处理选择性拦截版"""
from PyQt5.QtCore import Qt
# 只拦截左键,其他按键使用原有处理方式
if event.button() == Qt.LeftButton:
print('🖱️ 左键被拦截处理')
print(f'🖱️ 左键点击位置: ({event.x()}, {event.y()})')
# 检查是否点击在GUI区域
if self.maintenance_gui and self.is_click_in_gui_area(event.x(), event.y()):
print('🎯 点击在GUI区域直接处理工具按钮点击')
# 直接处理工具按钮点击不依赖DirectGUI事件
button_clicked = self.handle_gui_button_click(event.x(), event.y())
if button_clicked:
print(f'🎯 工具按钮点击已处理: {button_clicked}')
event.accept()
return
# 如果不是按钮点击,让原有事件处理器处理
if hasattr(self, 'original_mouse_press_event'):
self.original_mouse_press_event(event)
else:
event.ignore()
return
# 点击在3D区域进行拦截处理
print('🎯 点击在3D区域进行拦截处理')
self.handle_qt_mouse_click(event.x(), event.y())
event.accept()
else:
# 其他按键使用原有处理方式
if hasattr(self, 'original_mouse_press_event'):
self.original_mouse_press_event(event)
else:
event.ignore()
def is_click_in_gui_area(self, click_x, click_y):
"""检查鼠标点击是否在GUI区域高精度版本"""
try:
if not self.maintenance_gui:
return False
# 获取窗口尺寸用于坐标转换
if hasattr(self.world, 'qtWidget') and self.world.qtWidget:
widget_width = self.world.qtWidget.width()
widget_height = self.world.qtWidget.height()
else:
# 使用默认窗口尺寸
widget_width = 1380
widget_height = 750
# 使用与maintenance_gui完全相同的坐标转换算法
aspect_x = (click_x / widget_width) * 2.67 - 1.33
aspect_y = 1.0 - (click_y / widget_height) * 2.0
# 额外的坐标验证与Panda3D标准坐标系对比
# Panda3D aspect2d坐标系: x范围[-1.33, 1.33], y范围[-1.0, 1.0]
normalized_x = (click_x / widget_width) * 2.0 - 1.0 # 标准化到[-1, 1]
normalized_y = 1.0 - (click_y / widget_height) * 2.0 # 标准化到[-1, 1]Y轴翻转
aspect_ratio = widget_width / widget_height
panda_x = normalized_x * aspect_ratio # 应用宽高比
panda_y = normalized_y
print(f"🔍 精确GUI区域检查: Qt({click_x}, {click_y}) -> aspect2d({aspect_x:.3f}, {aspect_y:.3f})")
print(f"🔍 验证坐标转换: 标准化({normalized_x:.3f}, {normalized_y:.3f}) -> Panda3D({panda_x:.3f}, {panda_y:.3f})")
print(f"📏 窗口信息: 尺寸({widget_width}x{widget_height}), 宽高比({aspect_ratio:.3f})")
# 快速检查工具按钮区域
if hasattr(self.maintenance_gui, 'available_tools') and self.maintenance_gui.available_tools:
# 工具按钮区域(底部条带)
tools_area_left = -1.1
tools_area_right = 0.5
tools_area_bottom = -1.0
tools_area_top = -0.5
if (tools_area_left <= aspect_x <= tools_area_right and
tools_area_bottom <= aspect_y <= tools_area_top):
print(f"✅ 点击在工具按钮区域内")
return True
# 检查其他GUI区域步骤显示、当前工具、警告等
other_gui_areas = []
# 步骤显示区域(顶部)
if hasattr(self.maintenance_gui, 'step_text') and self.maintenance_gui.step_text:
other_gui_areas.append({
'name': '步骤显示区域',
'left': -1.8, 'right': 1.8,
'bottom': 0.5, 'top': 1.1
})
# 当前工具显示区域(右侧)
if hasattr(self.maintenance_gui, 'current_tool_text') and self.maintenance_gui.current_tool_text:
other_gui_areas.append({
'name': '当前工具显示区域',
'left': 0.2, 'right': 1.8,
'bottom': 0.6, 'top': 1.0
})
# 警告显示区域(中央)
if hasattr(self.maintenance_gui, 'warning_text') and self.maintenance_gui.warning_text:
other_gui_areas.append({
'name': '警告显示区域',
'left': -1.5, 'right': 1.5,
'bottom': 0.0, 'top': 0.6
})
# 检查其他GUI区域
for area in other_gui_areas:
if (area['left'] <= aspect_x <= area['right'] and
area['bottom'] <= aspect_y <= area['top']):
print(f"✅ 点击在GUI区域内: {area['name']}")
return True
print("❌ 点击不在任何GUI区域内")
return False
except Exception as e:
print(f"❌ 检查GUI区域失败: {e}")
import traceback
traceback.print_exc()
return False
def handle_gui_button_click(self, click_x, click_y):
"""直接处理GUI按钮点击"""
try:
if not self.maintenance_gui:
return None
# 获取窗口尺寸用于坐标转换
if hasattr(self.world, 'qtWidget') and self.world.qtWidget:
widget_width = self.world.qtWidget.width()
widget_height = self.world.qtWidget.height()
else:
widget_width = 1380
widget_height = 750
# 使用与maintenance_gui完全相同的坐标转换算法
aspect_x = (click_x / widget_width) * 2.67 - 1.33
aspect_y = 1.0 - (click_y / widget_height) * 2.0
print(f"🎯 直接处理按钮点击Qt({click_x}, {click_y}) -> aspect2d({aspect_x:.3f}, {aspect_y:.3f})")
# 检查工具按钮点击
if hasattr(self.maintenance_gui, 'available_tools') and self.maintenance_gui.available_tools:
button_width = 0.4
button_height = 0.25
button_spacing = 0.45
start_x = -0.8
start_y = -0.75
click_padding = 0.05 # 点击容差
for i, tool_data in enumerate(self.maintenance_gui.available_tools):
# 获取工具名称
if isinstance(tool_data, dict):
tool_name = tool_data.get('name', str(tool_data))
else:
tool_name = str(tool_data)
# 计算按钮位置
button_x = start_x + i * button_spacing
button_y = start_y
# 计算按钮边界(包含容差)
left = button_x - (button_width/2 + click_padding)
right = button_x + (button_width/2 + click_padding)
bottom = button_y - (button_height/2 + click_padding)
top = button_y + (button_height/2 + click_padding)
print(f"🔍 检查按钮{i} '{tool_name}': 范围x[{left:.3f}, {right:.3f}], y[{bottom:.3f}, {top:.3f}]")
# 检查点击是否在按钮范围内
if (left <= aspect_x <= right and bottom <= aspect_y <= top):
print(f"✅ 直接匹配按钮{i} '{tool_name}'!调用工具选择")
# 直接调用维修GUI的工具选择方法
self.maintenance_gui.on_tool_selected(tool_name)
return tool_name
print("❌ 点击不在任何按钮范围内")
return None
except Exception as e:
print(f"❌ 直接处理按钮点击失败: {e}")
import traceback
traceback.print_exc()
return None
def qt_mouse_release_event(self, event):
"""Qt鼠标释放事件处理维修系统优化版"""
from PyQt5.QtCore import Qt
# 只拦截左键释放,其他按键使用原有处理方式
if event.button() == Qt.LeftButton:
print('🖱️ 左键释放被拦截处理')
# 维修系统模式:先让原有处理器处理
if self.maintenance_gui:
if hasattr(self, 'original_mouse_release_event'):
self.original_mouse_release_event(event)
# 处理3D交互的鼠标释放
self.on_mouse_up()
event.accept()
else:
# 其他按键释放使用原有处理方式
if hasattr(self, 'original_mouse_release_event'):
self.original_mouse_release_event(event)
else:
event.ignore()
def handle_qt_mouse_click(self, x, y):
"""处理Qt鼠标点击转换为3D世界坐标"""
try:
if not self.is_active:
print("⚠️ 交互模式未激活")
return
# 检查操作权限
if not self.check_operation_permission():
return
# 获取当前步骤的目标模型
if self.current_step >= self.total_steps:
print("⚠️ 所有步骤已完成")
return
step_data = self.config_data['steps'][self.current_step]
target_model_name = step_data.get('target_model')
if not target_model_name:
print("⚠️ 当前步骤没有指定目标模型")
return
print(f'🎯 检测点击目标: {target_model_name}')
# 获取窗口尺寸进行坐标转换
if hasattr(self.world, 'qtWidget') and self.world.qtWidget:
widget = self.world.qtWidget
win_width = widget.width()
win_height = widget.height()
# 转换为Panda3D的标准化坐标
mx = 2.0 * x / float(win_width) - 1.0
my = 1.0 - 2.0 * y / float(win_height)
# 执行射线检测
self.perform_ray_cast(mx, my, target_model_name)
else:
print("❌ 找不到Qt部件无法进行坐标转换")
except Exception as e:
print(f"❌ Qt鼠标点击处理失败: {e}")
import traceback
traceback.print_exc()
def perform_ray_cast(self, mx, my, target_model_name):
"""执行射线检测"""
try:
# 获取鼠标射线
from panda3d.core import Point3, Point2, CollisionTraverser, CollisionHandlerQueue
from panda3d.core import CollisionNode, CollisionRay, BitMask32
near_point = Point3()
far_point = Point3()
self.world.cam.node().getLens().extrude(Point2(mx, my), near_point, far_point)
# 转换到世界坐标系
world_near = self.world.render.getRelativePoint(self.world.camera, near_point)
world_far = self.world.render.getRelativePoint(self.world.camera, far_point)
# 进行真正的射线检测
picker = CollisionTraverser()
queue = CollisionHandlerQueue()
# 创建射线碰撞检测
pickerNode = CollisionNode('mouseRay')
pickerNP = self.world.camera.attachNewNode(pickerNode)
# 设置射线的碰撞掩码匹配模型的碰撞掩码第2位
pickerNode.setFromCollideMask(BitMask32.bit(1))
# 使用相机坐标系的点创建射线
direction = far_point - near_point
direction.normalize()
pickerNode.addSolid(CollisionRay(near_point, direction))
picker.addCollider(pickerNP, queue)
picker.traverse(self.world.render)
# 检查是否点击到了目标模型
hit_target_model = False
hit_point = None
if queue.getNumEntries() > 0:
# 遍历所有碰撞结果
for i in range(queue.getNumEntries()):
entry = queue.getEntry(i)
hit_node_path = entry.getIntoNodePath()
hit_pos = entry.getSurfacePoint(self.world.render)
# 检查是否击中了目标模型或其相关的碰撞体
if self.is_target_model_hit(hit_node_path, target_model_name):
print(f"✅ 击中目标模型: {hit_node_path.getName()}")
hit_target_model = True
hit_point = hit_pos
break
# 清理碰撞检测节点
pickerNP.removeNode()
if hit_target_model:
# 查找目标模型节点
target_node = self.find_model_node(target_model_name)
if target_node and not target_node.isEmpty():
# 根据操作类型决定行为
step_data = self.config_data['steps'][self.current_step]
operation_type = self.normalize_operation_type(step_data)
if operation_type == 'drag':
print(f'✅ 开始拖拽目标模型: {target_node.getName()}')
self.start_dragging(target_node, hit_point)
elif operation_type == 'click':
print(f'✅ 点击触发位移模型: {target_node.getName()}')
self.trigger_click_movement(target_node, step_data)
else:
print(f"❌ 未知的操作类型: {operation_type}")
else:
print(f"❌ 找不到目标模型节点: {target_model_name}")
else:
print(f"❌ 没有点击到目标模型 '{target_model_name}',请点击正确的模型")
except Exception as e:
print(f"❌ 射线检测失败: {e}")
import traceback
traceback.print_exc()
def is_target_model_hit(self, hit_node_path, target_model_name):
"""检查击中的节点是否是目标模型"""
hit_name = hit_node_path.getName()
# 直接匹配模型名称
if hit_name == target_model_name:
return True
# 检查是否是模型的碰撞体通常以modelCollision_开头
if hit_name.startswith("modelCollision_"):
collision_model_name = hit_name.replace("modelCollision_", "", 1)
if collision_model_name == target_model_name:
return True
# 检查父节点是否是目标模型
parent = hit_node_path.getParent()
while parent and not parent.isEmpty() and parent != self.world.render:
parent_name = parent.getName()
if parent_name == target_model_name:
return True
parent = parent.getParent()
# 检查子节点路径
current = hit_node_path
while current and not current.isEmpty():
if current.getName() == target_model_name:
return True
current = current.getParent()
return False
def on_mouse_down(self):
"""鼠标按下事件(优化版 v2处理兄弟节点结构的碰撞体"""
if not self.is_active or not self.world.mouseWatcherNode.hasMouse():
return
# 获取鼠标位置并发射射线
mouse_pos = self.world.mouseWatcherNode.getMouse()
picker_ray = self.picker_ray_node.node().getSolid(0)
picker_ray.setFromLens(self.world.camNode, mouse_pos.getX(), mouse_pos.getY())
# 执行遍历
self.picker_traverser.traverse(self.world.render)
if self.collision_handler.getNumEntries() > 0:
# 获取最近的碰撞点
self.collision_handler.sortEntries()
entry = self.collision_handler.getEntry(0)
hit_node_path = entry.getIntoNodePath()
hit_point = entry.getSurfacePoint(self.world.render)
# --- 这是新的逻辑 ---
# 打印被击中的碰撞体名字,用于调试
print(f"射线击中了碰撞节点: {hit_node_path.getName()}")
# 根据命名约定推导模型名称
hit_name = hit_node_path.getName()
derived_model_name = ""
# 假设碰撞节点的命名规则是 "前缀 + 模型名"
if hit_name.startswith("modelCollision_"):
derived_model_name = hit_name.replace("modelCollision_", "", 1)
else:
derived_model_name = hit_name
print(f"根据命名约定,推导出的模型名称为: {derived_model_name}")
# 获取当前步骤的目标模型名称
step_data = self.config_data['steps'][self.current_step]
target_model_name = step_data.get('target_model')
# 检查推导出的名称是否是当前步骤需要操作的目标
if derived_model_name == target_model_name:
print(f"✅ 击中正确!目标是 '{target_model_name}'")
# 根据推导出的正确名称,去场景中查找可以被拖动的那个节点
model_to_drag = self.find_model_node(derived_model_name)
if model_to_drag and not model_to_drag.isEmpty():
self.start_dragging(model_to_drag, hit_point)
else:
print(f"❌ 错误:虽然名称匹配,但在场景中找不到名为 '{derived_model_name}' 的可拖动节点!")
else:
print(f"❌ 击中的不是当前目标模型 (需要 '{target_model_name}', 击中了 '{derived_model_name}')")
def start_dragging(self, model_node, hit_point):
"""开始拖拽模型(优化版:计算偏移量)"""
# 再次检查操作权限(双重保险)
if not self.check_operation_permission():
print("⚠️ 权限检查失败,无法开始拖拽")
return
self.dragging_model = model_node
# 1. 定义一个拖拽平面。这里我们使用一个平行于XZ的平面法线为Y轴
# 并且该平面穿过模型的初始中心点。
# 这意味着模型将在其初始高度上水平移动。
# 您可以根据需要更改法线,例如 Vec3(0, 0, 1) 表示在XY平面上拖拽。
plane_normal = Vec3(0, 1, 0)
self.drag_plane = Plane(plane_normal, model_node.getPos())
# 2. 计算鼠标射线与拖拽平面的交点。
# 这个交点是鼠标在“拖拽世界”中的3D位置。
plane_intersection_point = Point3()
if self.drag_plane.intersectsLine(plane_intersection_point,
self.world.camera.getPos(self.world.render),
hit_point):
# 3. 计算模型中心点和这个交点之间的偏移量。
# 在整个拖拽过程中,我们将维持这个偏移量,以避免模型跳跃。
self.drag_offset = model_node.getPos() - plane_intersection_point
print(f"开始拖拽模型: {model_node.getName()}")
print(f"初始位置: {model_node.getPos()}")
# 启动拖拽任务
self.world.taskMgr.add(self.drag_task, "drag_model_task")
else:
print("警告:无法计算拖拽平面的交点,拖拽取消。")
self.dragging_model = None
def drag_task(self, task):
"""拖拽任务(优化版:应用偏移量)"""
if not self.dragging_model or not self.world.mouseWatcherNode.hasMouse():
# 如果模型被置空或鼠标不在窗口内,则停止任务
return task.done
# 获取最新的鼠标位置
mouse_pos = self.world.mouseWatcherNode.getMouse()
# 再次从相机发射射线以获取鼠标在3D空间中的指向
near_point = Point3()
far_point = Point3()
self.world.cam.node().getLens().extrude(mouse_pos, near_point, far_point)
# 将近点和远点转换到世界坐标系
origin_point = self.world.render.getRelativePoint(self.world.camera, near_point)
direction_point = self.world.render.getRelativePoint(self.world.camera, far_point)
# 计算射线与我们之前定义的拖拽平面的交点
intersection_point = Point3()
if self.drag_plane.intersectsLine(intersection_point, origin_point, direction_point):
# 新的位置 = 当前鼠标在拖拽平面上的3D位置 + 初始偏移量
new_pos = intersection_point + self.drag_offset
self.dragging_model.setPos(new_pos)
# 初始化距离打印时间(如果还没有)
if not hasattr(self, '_last_distance_print_time'):
self._last_distance_print_time = 0
# 在拖拽过程中,实时显示距离原始位置的距离
if hasattr(self, 'config_data') and self.current_step < len(self.config_data.get('steps', [])):
step_data = self.config_data['steps'][self.current_step]
step_type = self.normalize_step_type(step_data)
target_model_name = step_data.get('target_model', '')
if (target_model_name in self.model_original_positions and
hasattr(self, '_last_distance_print_time')):
# 每0.5秒显示一次距离信息
current_time = task.time
if current_time - getattr(self, '_last_distance_print_time', 0) > 0.5:
original_pos = self.model_original_positions[target_model_name]
distance = (new_pos - original_pos).length()
if step_type == 'assemble':
snap_tolerance = step_data.get('snap_tolerance') or step_data.get('tolerance', 5.0)
if distance <= snap_tolerance * 1.2: # 只在接近时显示
print(f"📏 安装拖拽中 - 距离原位: {distance:.2f} (需要 <= {snap_tolerance:.2f})")
else: # 拆卸模式
disassemble_threshold = step_data.get('disassemble_threshold', 5.0)
if distance >= disassemble_threshold * 0.8: # 接近阈值时显示
print(f"📏 拆卸拖拽中 - 距离原位: {distance:.2f} (需要 >= {disassemble_threshold:.2f})")
self._last_distance_print_time = current_time
return task.cont
def stop_dragging(self):
"""停止拖拽"""
if not self.dragging_model:
return
print(f"停止拖拽模型: {self.dragging_model.getName()}")
print(f"最终位置: {self.dragging_model.getPos()}")
# 停止拖拽任务
self.world.taskMgr.remove("drag_model_task")
# 检查是否完成了当前步骤
self.check_step_completion()
# 清理拖拽状态
self.dragging_model = None
def on_mouse_up(self):
"""鼠标抬起事件"""
self.stop_dragging()
def check_step_completion(self):
"""检查步骤是否完成"""
if not self.dragging_model:
return
step_data = self.config_data['steps'][self.current_step]
step_type = self.normalize_step_type(step_data)
print(f"检查步骤完成: {step_type} ----------------------------------------")
target_model_name = step_data.get('target_model', '')
current_pos = self.dragging_model.getPos()
if step_type == 'assemble':
# 安装模式:检查是否接近原始位置
if target_model_name in self.model_original_positions:
original_pos = self.model_original_positions[target_model_name]
distance_to_original = (original_pos - current_pos).length()
# 吸附容差优先使用配置的snap_tolerance否则使用拆卸时的tolerance最后默认5.0
snap_tolerance = step_data.get('snap_tolerance') or step_data.get('tolerance', 5.0)
print(f"🔧 安装模式 - 距离原始位置: {distance_to_original:.2f}")
print(f"🎯 吸附容差: {snap_tolerance:.2f}")
print(f"🔍 吸附条件: {distance_to_original:.2f} <= {snap_tolerance:.2f} = {distance_to_original <= snap_tolerance}")
if distance_to_original <= snap_tolerance:
# 自动吸附到原始位置
print(f"✨ 自动吸附到原始位置!")
print(f"📍 精确位置: {original_pos}")
self.dragging_model.setPos(original_pos)
self.model_current_states[target_model_name] = 'installed'
print(f"步骤 {self.current_step + 1} 完成!模型已安装到原始位置")
self.complete_current_step()
return
else:
print(f"距离原始位置还有 {distance_to_original:.2f} 单位,需要更接近才能自动吸附")
else:
print("⚠️ 没有找到模型的原始位置记录")
else:
# 拆卸模式:检查是否远离原始位置
if target_model_name in self.model_original_positions:
original_pos = self.model_original_positions[target_model_name]
distance_from_original = (current_pos - original_pos).length()
disassemble_threshold = step_data.get('disassemble_threshold', 5.0) # 拆卸距离阈值
print(f"🔧 拆卸模式 - 距离原始位置: {distance_from_original:.2f}")
print(f"🎯 拆卸阈值: {disassemble_threshold:.2f}")
print(f"🔍 拆卸条件: {distance_from_original:.2f} >= {disassemble_threshold:.2f} = {distance_from_original >= disassemble_threshold}")
if distance_from_original >= disassemble_threshold:
print(f"步骤 {self.current_step + 1} 完成!模型已拆卸,距离原位 {distance_from_original:.2f} >= {disassemble_threshold:.2f}")
if target_model_name:
self.model_current_states[target_model_name] = 'removed'
self.complete_current_step()
return
else:
print(f"需要将模型拖拽到距离原位至少 {disassemble_threshold:.2f} 单位(当前 {distance_from_original:.2f}")
else:
# 如果没有原始位置记录,回退到原来的目标位置模式
print("⚠️ 没有找到模型的原始位置记录,使用目标位置模式")
target_pos_data = step_data.get('target_position')
if target_pos_data is None:
# 如果没有定义目标位置,拆卸后即完成
print("拆卸模式 - 没有定义目标位置,视为完成。")
if target_model_name:
self.model_current_states[target_model_name] = 'removed'
self.complete_current_step()
return
target_pos = Vec3(*target_pos_data)
distance = (target_pos - current_pos).length()
tolerance = step_data.get('tolerance', 5)
if distance <= tolerance:
print(f"步骤 {self.current_step + 1} 完成!拆卸到目标位置 距离 {distance:.2f} < 容差 {tolerance}")
if target_model_name:
self.model_current_states[target_model_name] = 'removed'
self.complete_current_step()
else:
print(f"距离目标位置还有 {distance:.2f} 单位,未达到容差 {tolerance}")
def complete_current_step(self):
"""完成当前步骤"""
# 记录操作完成(考核模式下)
if self.mode == "exam" and self.current_step in self.step_scores:
self.step_scores[self.current_step]['operation_attempts'] += 1
print(f"📝 考核记录:步骤 {self.current_step + 1} 完成,操作次数: {self.step_scores[self.current_step]['operation_attempts']}")
# 在进入下一步之前,取消当前模型的碰撞,避免干扰
step_data = self.config_data['steps'][self.current_step]
target_model_name = step_data.get('target_model')
if target_model_name:
node = self.find_model_node(target_model_name)
if node:
node.setCollideMask(BitMask32.allOff())
# 显示当前所有模型的状态
self.show_models_status()
self.current_step += 1
if self.current_step >= self.total_steps:
self.finish_interaction()
else:
self.start_current_step()
def finish_interaction(self):
"""完成所有交互"""
print("\n🎉 所有拆装步骤已完成!")
self.is_active = False
self.ignoreAll()
# 清理维修系统GUI
if self.maintenance_gui:
self.maintenance_gui.cleanup_gui()
print("✅ 维修系统GUI已清理")
# 恢复原有的Qt鼠标事件处理
print("🔄 恢复原有的Qt鼠标事件处理")
if hasattr(self, 'original_mouse_press_event') and hasattr(self.world, 'qtWidget') and self.world.qtWidget:
self.world.qtWidget.mousePressEvent = self.original_mouse_press_event
self.world.qtWidget.mouseReleaseEvent = self.original_mouse_release_event
print("✅ 原有Qt鼠标事件处理已恢复")
else:
print("⚠️ 没有找到备份的Qt事件处理方法")
if self.step_dialog:
self.step_dialog.close()
self.step_dialog = None
# 根据模式显示不同的完成结果
if self.mode == "exam":
# 考核模式:显示详细的考核结果
self.show_exam_results()
else:
# 训练模式:显示训练完成提示
print("\n🎉 训练模式完成!")
completion_msg = "🎉 训练完成!\n\n所有维修步骤已完成!\n现在可以尝试考核模式检验学习成果。"
if self.maintenance_gui:
self.maintenance_gui.update_step_info(completion_msg)
print("📋 训练完成信息已在GUI中显示")
else:
QMessageBox.information(None, "训练完成", completion_msg)
def stop_interaction(self):
"""用户主动停止交互"""
print("\n🛑 用户主动停止拆装交互")
try:
# 设置为非活动状态
self.is_active = False
self.ignoreAll()
# 清理维修系统GUI
if self.maintenance_gui:
self.maintenance_gui.cleanup_gui()
print("✅ 维修系统GUI已清理")
# 恢复原有的Qt鼠标事件处理
print("🔄 恢复原有的Qt鼠标事件处理")
if hasattr(self, 'original_mouse_press_event') and hasattr(self.world, 'qtWidget') and self.world.qtWidget:
self.world.qtWidget.mousePressEvent = self.original_mouse_press_event
self.world.qtWidget.mouseReleaseEvent = self.original_mouse_release_event
print("✅ 原有Qt鼠标事件处理已恢复")
else:
print("⚠️ 没有找到备份的Qt事件处理方法")
# 关闭步骤对话框
if self.step_dialog:
self.step_dialog.close()
self.step_dialog = None
# 显示停止确认信息
if self.mode == "exam":
stop_msg = "🛑 考核已停止\n\n您的考核进度没有被保存。"
else:
stop_msg = "🛑 训练已停止\n\n您可以随时重新开始训练。"
if self.maintenance_gui:
# 如果GUI还在显示停止信息
self.maintenance_gui.update_step_info(stop_msg)
else:
QMessageBox.information(None, "操作停止", stop_msg)
print("✅ 拆装交互已停止")
except Exception as e:
print(f"❌ 停止拆装交互失败: {e}")
import traceback
traceback.print_exc()
def show_exam_results(self):
"""显示考核结果"""
try:
# 计算最终得分
final_score = 0
total_operations = 0
total_errors = 0
for step_record in self.step_scores.values():
final_score += step_record['current_score']
total_operations += step_record['operation_attempts']
if step_record['tool_error']:
total_errors += 1
# 计算得分率和准确率
score_rate = (final_score / self.exam_max_score * 100) if self.exam_max_score > 0 else 0
accuracy_rate = ((self.total_steps - total_errors) / self.total_steps * 100) if self.total_steps > 0 else 100
# 生成考核报告
print(f"\n{'='*50}")
print(f"🎓 维修技能考核结果报告")
print(f"{'='*50}")
print(f"📊 总体成绩:")
print(f" 总得分: {final_score:.0f} / {self.exam_max_score:.0f}")
print(f" 得分率: {score_rate:.1f}%")
print(f" 操作准确率: {accuracy_rate:.1f}%")
print(f" 总操作次数: {total_operations}")
print(f" 错误次数: {total_errors}")
# 评级系统
if score_rate >= 90:
grade = "优秀"
grade_icon = "🏆"
grade_desc = "表现卓越,技能熟练"
elif score_rate >= 80:
grade = "良好"
grade_icon = "🥈"
grade_desc = "表现良好,基本掌握"
elif score_rate >= 60:
grade = "及格"
grade_icon = ""
grade_desc = "达到基本要求"
else:
grade = "不及格"
grade_icon = ""
grade_desc = "需要加强练习"
print(f"\n🏅 评级: {grade_icon} {grade}")
print(f" 评语: {grade_desc}")
# 详细步骤分析
print(f"\n📋 详细步骤分析:")
print(f"{'-'*40}")
perfect_steps = 0
for step_idx, step_record in self.step_scores.items():
step_data = self.config_data['steps'][step_idx]
step_name = step_data.get('name', f'步骤{step_idx+1}')
max_score = step_record['max_score']
current_score = step_record['current_score']
tool_error = step_record['tool_error']
attempts = step_record['operation_attempts']
# 判断步骤完成质量
if current_score >= max_score:
step_status = "🟢 完美"
perfect_steps += 1
elif current_score >= max_score * 0.8:
step_status = "🟡 良好"
elif current_score > 0:
step_status = "🟠 一般"
else:
step_status = "🔴 失败"
print(f"{step_name}:")
print(f" 状态: {step_status}")
print(f" 得分: {current_score:.0f}/{max_score:.0f}")
print(f" 工具使用: {'❌ 错误' if tool_error else '✅ 正确'}")
print(f" 操作次数: {attempts}")
print()
# 生成改进建议
suggestions = []
if total_errors > 0:
suggestions.append("• 注意选择正确的工具进行操作")
if total_operations > self.total_steps * 1.5:
suggestions.append("• 减少不必要的操作,提高操作效率")
if perfect_steps < self.total_steps * 0.5:
suggestions.append("• 多练习以提高操作的精确度")
if score_rate < 80:
suggestions.append("• 建议重新学习相关理论知识")
if suggestions:
print("💡 改进建议:")
for suggestion in suggestions:
print(f" {suggestion}")
print()
print(f"{'='*50}")
# 为GUI显示准备简化版本的报告
gui_report = f"🎓 考核完成!\n\n"
gui_report += f"📊 成绩总览:\n"
gui_report += f"总得分: {final_score:.0f}/{self.exam_max_score:.0f} 分 ({score_rate:.1f}%)\n"
gui_report += f"评级: {grade_icon} {grade}\n"
gui_report += f"操作准确率: {accuracy_rate:.1f}%\n\n"
gui_report += f"完美完成步骤: {perfect_steps}/{self.total_steps}\n"
gui_report += f"总操作次数: {total_operations}\n"
gui_report += f"错误次数: {total_errors}"
# 在维修GUI中显示详细考核结果
if self.maintenance_gui:
# 创建详细的考核结果数据
exam_result_data = {
'final_score': final_score,
'max_score': self.exam_max_score,
'score_rate': score_rate,
'accuracy_rate': accuracy_rate,
'grade': grade,
'grade_icon': grade_icon,
'grade_desc': grade_desc,
'total_operations': total_operations,
'total_errors': total_errors,
'perfect_steps': perfect_steps,
'total_steps': self.total_steps,
'step_details': [],
'suggestions': suggestions
}
# 添加每步详情
for step_idx, step_record in self.step_scores.items():
step_data = self.config_data['steps'][step_idx]
step_name = step_data.get('name', f'步骤{step_idx+1}')
max_score = step_record['max_score']
current_score = step_record['current_score']
tool_error = step_record['tool_error']
attempts = step_record['operation_attempts']
# 判断步骤完成质量
if current_score >= max_score:
step_status = "🟢 完美"
elif current_score >= max_score * 0.8:
step_status = "🟡 良好"
elif current_score > 0:
step_status = "🟠 一般"
else:
step_status = "🔴 失败"
exam_result_data['step_details'].append({
'name': step_name,
'status': step_status,
'current_score': current_score,
'max_score': max_score,
'tool_error': tool_error,
'attempts': attempts
})
# 使用GUI显示考核结果
self.maintenance_gui.show_exam_results(exam_result_data)
print("📋 考核结果已在GUI中显示")
else:
# 如果没有GUI使用简单的控制台输出
print("⚠️ 维修GUI不可用考核结果仅在控制台显示")
# 保存考核结果到文件(可选)
self.save_exam_results(final_score, score_rate, grade)
except Exception as e:
print(f"❌ 显示考核结果失败: {e}")
import traceback
traceback.print_exc()
QMessageBox.critical(None, "错误", f"显示考核结果失败: {str(e)}")
def save_exam_results(self, final_score, score_rate, grade):
"""保存考核结果到文件"""
try:
import datetime
import json
# 生成考核记录
exam_record = {
"timestamp": datetime.datetime.now().isoformat(),
"mode": "exam",
"final_score": final_score,
"max_score": self.exam_max_score,
"score_rate": score_rate,
"grade": grade,
"total_steps": self.total_steps,
"step_details": {}
}
# 添加每步详情
for step_idx, step_record in self.step_scores.items():
step_name = self.config_data['steps'][step_idx].get('name', f'步骤{step_idx+1}')
exam_record["step_details"][step_name] = {
"max_score": step_record['max_score'],
"current_score": step_record['current_score'],
"tool_error": step_record['tool_error'],
"operation_attempts": step_record['operation_attempts']
}
# 保存到文件
filename = f"exam_result_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
filepath = os.path.join(".", filename)
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(exam_record, f, ensure_ascii=False, indent=2)
print(f"📄 考核结果已保存到: {filepath}")
except Exception as e:
print(f"⚠️ 保存考核结果失败: {e}")
# 不影响主流程,只是记录警告
def stop_interaction_mode(self):
"""停止交互模式"""
self.is_active = False
self.ignoreAll()
# 恢复原有的Qt鼠标事件处理
print("🔄 恢复原有的Qt鼠标事件处理")
if hasattr(self, 'original_mouse_press_event') and hasattr(self.world, 'qtWidget') and self.world.qtWidget:
self.world.qtWidget.mousePressEvent = self.original_mouse_press_event
self.world.qtWidget.mouseReleaseEvent = self.original_mouse_release_event
print("✅ 原有Qt鼠标事件处理已恢复")
else:
print("⚠️ 没有找到备份的Qt事件处理方法")
if self.step_dialog:
self.step_dialog.close()
self.step_dialog = None
self.world.taskMgr.remove("drag_model_task")
print("拆装交互模式已停止")
def trigger_drag_by_keyboard(self):
"""通过键盘触发拖拽(备用方案)"""
if not self.is_active or self.dragging_model:
return
print("⌨️ 使用键盘备用方案触发拖拽")
step_data = self.config_data['steps'][self.current_step]
target_model_name = step_data.get('target_model')
if not target_model_name:
print("当前步骤没有指定目标模型")
return
target_node = self.find_model_node(target_model_name)
if not target_node:
print(f"找不到目标模型: {target_model_name}")
return
# 对于键盘触发我们无法获取精确的hit_point
# 所以直接使用模型的中心点作为模拟的点击点。
self.start_dragging(target_node, target_node.getPos())
def trigger_click_movement(self, model_node, step_data):
"""触发点击位移动画"""
try:
# 检查操作权限(双重保险)
if not self.check_operation_permission():
print("⚠️ 权限检查失败,无法进行点击触发位移")
return
step_type = self.normalize_step_type(step_data)
target_model_name = step_data.get('target_model', '')
print(f"🎬 开始点击触发位移: {model_node.getName()}")
print(f"📋 步骤类型: {step_type}")
if step_type == 'disassemble':
# 拆卸模式:移动到目标位置然后隐藏
self.perform_disassemble_click_movement(model_node, step_data)
elif step_type == 'assemble':
# 安装模式:显示模型然后移动到原始位置
self.perform_assemble_click_movement(model_node, step_data)
else:
print(f"❌ 未知的步骤类型: {step_type}")
except Exception as e:
print(f"❌ 点击触发位移失败: {e}")
import traceback
traceback.print_exc()
def perform_disassemble_click_movement(self, model_node, step_data):
"""执行拆卸点击位移"""
target_model_name = step_data.get('target_model', '')
# 记录当前位置为原始位置(如果还没有记录)
if target_model_name not in self.model_original_positions:
current_pos = model_node.getPos()
self.model_original_positions[target_model_name] = current_pos
print(f"📍 记录模型 '{target_model_name}' 的原始位置: {current_pos}")
# 获取目标位移位置
target_pos_data = step_data.get('target_position')
if not target_pos_data:
print("❌ 拆卸步骤缺少target_position配置")
return
target_pos = Vec3(*target_pos_data)
print(f"🎯 目标位置: {target_pos}")
# 获取动画时长
animation_duration = step_data.get('animation_duration', 2.0)
# 创建位移动画序列
from direct.interval.IntervalGlobal import LerpPosInterval, Sequence, Func
move_interval = LerpPosInterval(
model_node,
animation_duration,
target_pos,
name=f"disassemble_{target_model_name}"
)
# 动画完成后的回调
def on_disassemble_complete():
print(f"✅ 拆卸动画完成,隐藏模型: {target_model_name}")
model_node.hide() # 隐藏模型
self.model_current_states[target_model_name] = 'removed'
self.complete_current_step() # 完成当前步骤
# 创建动画序列
disassemble_sequence = Sequence(
move_interval,
Func(on_disassemble_complete),
name=f"disassemble_sequence_{target_model_name}"
)
print(f"🎬 开始拆卸动画,持续时间: {animation_duration}")
disassemble_sequence.start()
def perform_assemble_click_movement(self, model_node, step_data):
"""执行安装点击位移"""
target_model_name = step_data.get('target_model', '')
# 确保模型可见(通常在步骤开始时已经显示,这里是双重保险)
if model_node.isHidden():
print(f"👁️ 点击触发时显示模型: {target_model_name}")
model_node.show()
else:
print(f"✅ 模型 '{target_model_name}' 已经可见,开始安装动画")
# 获取原始位置
if target_model_name not in self.model_original_positions:
print(f"❌ 找不到模型 '{target_model_name}' 的原始位置")
return
original_pos = self.model_original_positions[target_model_name]
print(f"🏠 原始位置: {original_pos}")
# 获取动画时长
animation_duration = step_data.get('animation_duration', 2.0)
# 创建位移动画序列
from direct.interval.IntervalGlobal import LerpPosInterval, Sequence, Func
move_interval = LerpPosInterval(
model_node,
animation_duration,
original_pos,
name=f"assemble_{target_model_name}"
)
# 动画完成后的回调
def on_assemble_complete():
print(f"✅ 安装动画完成,模型回到原位: {target_model_name}")
model_node.setPos(original_pos) # 确保精确位置
self.model_current_states[target_model_name] = 'installed'
self.complete_current_step() # 完成当前步骤
# 创建动画序列
assemble_sequence = Sequence(
move_interval,
Func(on_assemble_complete),
name=f"assemble_sequence_{target_model_name}"
)
print(f"🎬 开始安装动画,持续时间: {animation_duration}")
assemble_sequence.start()
class StepGuideDialog(QDialog):
"""步骤指引对话框UI代码保持不变"""
def __init__(self, interaction_manager, parent=None):
super().__init__(parent)
self.interaction_manager = interaction_manager
self.current_required_tool = "" # 当前步骤要求的工具
self.mode = interaction_manager.mode # 获取模式
self.chinese_font = None
if hasattr(interaction_manager.world,'getChineseFont'):
self.chinese_font = interaction_manager.world.getChineseFont()
self.setupUI()
def setupUI(self):
if self.mode == "exam":
self.setWindowTitle("拆装考核")
else:
self.setWindowTitle("拆装步骤指引")
self.setFixedSize(450, 400)
self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint)
layout = QVBoxLayout(self)
# 步骤信息
self.step_info_label = QLabel("准备开始...")
self.step_info_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #2E86C1;")
if self.chinese_font:
self.step_info_label.setFont(self.chinese_font)
layout.addWidget(self.step_info_label)
# 步骤描述(考核模式下隐藏)
if self.mode != "exam":
self.step_desc_text = QTextEdit()
self.step_desc_text.setMaximumHeight(100)
self.step_desc_text.setReadOnly(True)
if self.chinese_font:
self.step_desc_text.setFont(self.chinese_font)
layout.addWidget(self.step_desc_text)
else:
self.step_desc_text = None
# 工具选择组
tool_group = QGroupBox("工具选择")
if self.chinese_font:
tool_group.setFont(self.chinese_font)
tool_layout = QVBoxLayout(tool_group)
# 在训练模式下显示要求的工具,考核模式下不显示
if self.mode != "exam":
self.required_tool_label = QLabel("当前步骤要求工具: 无")
self.required_tool_label.setStyleSheet("font-weight: bold; color: #E74C3C;")
if self.chinese_font:
self.required_tool_label.setFont(self.chinese_font)
tool_layout.addWidget(self.required_tool_label)
else:
self.required_tool_label = None
current_tool_layout = QHBoxLayout()
current_tool_label = QLabel("当前选择工具:")
if self.chinese_font:
current_tool_label.setFont(self.chinese_font)
current_tool_layout.addWidget(current_tool_label)
self.current_tool_combo = QComboBox()
self.current_tool_combo.addItem("") # 默认只有"无"选项
self.current_tool_combo.currentTextChanged.connect(self.on_tool_changed)
if self.chinese_font:
self.current_tool_combo.setFont(self.chinese_font)
current_tool_layout.addWidget(self.current_tool_combo)
tool_layout.addLayout(current_tool_layout)
# 在训练模式下显示工具状态,考核模式下不显示
if self.mode != "exam":
self.tool_status_label = QLabel("✅ 工具匹配,可以进行操作")
self.tool_status_label.setStyleSheet("color: #27AE60; font-weight: bold;")
if self.chinese_font:
self.tool_status_label.setFont(self.chinese_font)
tool_layout.addWidget(self.tool_status_label)
else:
self.tool_status_label = None
layout.addWidget(tool_group)
# 操作提示(考核模式下隐藏)
if self.mode != "exam":
self.operation_label = QLabel("操作提示:")
self.operation_label.setStyleSheet("font-weight: bold;")
if self.chinese_font:
self.operation_label.setFont(self.chinese_font)
layout.addWidget(self.operation_label)
self.operation_text = QTextEdit()
self.operation_text.setMaximumHeight(80)
self.operation_text.setReadOnly(True)
if self.chinese_font:
self.operation_text.setFont(self.chinese_font)
layout.addWidget(self.operation_text)
else:
self.operation_label = None
self.operation_text = None
# 按钮
button_layout = QHBoxLayout()
self.skip_button = QPushButton("跳过当前步骤")
if self.chinese_font:
self.skip_button.setFont(self.chinese_font)
self.skip_button.clicked.connect(self.skip_current_step)
button_layout.addWidget(self.skip_button)
self.stop_button = QPushButton("停止交互")
if self.chinese_font:
self.stop_button.setFont(self.chinese_font)
self.stop_button.clicked.connect(self.stop_interaction)
button_layout.addWidget(self.stop_button)
layout.addLayout(button_layout)
# 初始化工具状态
self.sync_current_tool()
self.update_tool_status()
def sync_current_tool(self):
"""初始化拆装工具状态"""
# 从配置文件中加载工具列表
self.load_tools_from_config()
# 默认选择"无"
self.current_tool_combo.setCurrentText("")
print(f"🔧 初始化拆装工具: 无")
def load_tools_from_config(self):
"""从配置文件中加载工具列表"""
# 清空现有工具选项(保留"无"
self.current_tool_combo.clear()
self.current_tool_combo.addItem("")
# 从配置文件中加载工具
if hasattr(self.interaction_manager, 'config_data') and self.interaction_manager.config_data:
tools = self.interaction_manager.config_data.get('tools', [])
for tool in tools:
tool_name = tool.get('name', '')
if tool_name and tool_name != "":
self.current_tool_combo.addItem(tool_name)
print(f"🔧 加载工具: {tool_name}")
if len(tools) > 0:
print(f"🔧 从配置文件加载了 {len(tools)} 个工具")
else:
print("🔧 配置文件中没有定义工具")
else:
print("⚠️ 无法访问配置数据")
def on_tool_changed(self, tool_name):
"""工具改变时的处理"""
print(f"🔧 用户选择拆装工具: {tool_name}")
# 拆装工具不需要同步到编辑器的工具管理器
# 这里是独立的拆装工具选择
# 更新工具状态显示
self.update_tool_status()
def update_tool_status(self):
"""更新工具状态显示"""
current_tool = self.current_tool_combo.currentText()
required_tool = self.current_required_tool
# 检查工具是否匹配
tool_matches = self.check_tool_permission(current_tool, required_tool)
if self.mode == "training":
# 训练模式:显示工具匹配状态
if tool_matches:
self.tool_status_label.setText("✅ 工具匹配,可以进行操作")
self.tool_status_label.setStyleSheet("color: #27AE60; font-weight: bold;")
# 启用交互操作
self.interaction_manager.set_operation_enabled(True)
else:
self.tool_status_label.setText(f"❌ 工具不匹配,需要选择 '{required_tool}' 工具")
self.tool_status_label.setStyleSheet("color: #E74C3C; font-weight: bold;")
# 禁用交互操作
self.interaction_manager.set_operation_enabled(False)
else:
# 考核模式:不显示工具状态,但仍然需要正确的工具才能操作
# 这里不再总是启用操作,而是让权限检查在实际操作时进行
self.interaction_manager.set_operation_enabled(True)
def check_tool_permission(self, current_tool, required_tool):
"""检查工具权限"""
# 如果步骤不要求特定工具,任何工具都可以
if required_tool == "" or required_tool == "" or not required_tool:
return True
# 精确匹配
if current_tool == required_tool:
return True
return False
def update_step_info(self, step_data, current_step, total_steps):
step_name = step_data.get('name', f'步骤 {current_step}')
if self.mode == "exam":
# 考核模式:显示考核信息
step_score = step_data.get('score', 10)
self.step_info_label.setText(f"{current_step}/{total_steps} 步: {step_name} (分值: {step_score})")
else:
# 训练模式:正常显示
self.step_info_label.setText(f"{current_step}/{total_steps} 步: {step_name}")
# 为动态文本设置中文字体
if self.chinese_font:
self.step_info_label.setFont(self.chinese_font)
# 步骤描述(考核模式下不显示)
if self.step_desc_text is not None:
step_desc = step_data.get('description', '无描述')
self.step_desc_text.setPlainText(step_desc)
if self.chinese_font:
self.step_desc_text.setFont(self.chinese_font)
# 更新工具要求
self.current_required_tool = step_data.get('required_tool', '')
if self.required_tool_label is not None:
self.required_tool_label.setText(f"当前步骤要求工具: {self.current_required_tool}")
if self.chinese_font:
self.required_tool_label.setFont(self.chinese_font)
# 更新工具状态
self.update_tool_status()
operation_type = self.interaction_manager.normalize_operation_type(step_data)
step_type = self.interaction_manager.normalize_step_type(step_data)
target_model = step_data.get('target_model', '未指定')
if operation_type == 'drag':
if step_type == 'assemble':
snap_tolerance = step_data.get('snap_tolerance') or step_data.get('tolerance', 5.0)
operation_hint = f"🔧 安装操作\n请用鼠标左键点击并拖拽模型 '{target_model}' 到原始位置附近。\n当距离原始位置 {snap_tolerance:.1f} 单位内时,模型会自动吸附到正确位置。"
else:
disassemble_threshold = step_data.get('disassemble_threshold', 5.0)
operation_hint = f"🔧 拆卸操作\n请用鼠标左键点击并拖拽模型 '{target_model}' 脱离原位。\n拖拽到距离原始位置 {disassemble_threshold:.1f} 单位外即可完成。"
elif operation_type == 'click':
if step_type == 'assemble':
animation_duration = step_data.get('animation_duration', 2.0)
operation_hint = f"🔧 安装操作(点击触发)\n请点击模型 '{target_model}' 触发自动安装。\n模型将自动移动到原始位置(动画时长 {animation_duration:.1f}秒)。"
else:
target_pos_data = step_data.get('target_position')
animation_duration = step_data.get('animation_duration', 2.0)
if target_pos_data:
from panda3d.core import Vec3
target_pos = Vec3(*target_pos_data)
operation_hint = f"🔧 拆卸操作(点击触发)\n请点击模型 '{target_model}' 触发自动拆卸。\n模型将自动移动到位置 {target_pos}(动画时长 {animation_duration:.1f}秒),然后隐藏。"
else:
operation_hint = f"🔧 拆卸操作(点击触发)\n请点击模型 '{target_model}' 触发自动拆卸。\n⚠️ 警告缺少target_position配置"
else:
# 未知操作类型的回退提示
if step_type == 'assemble':
operation_hint = f"🔧 安装操作\n请操作模型 '{target_model}' 将其安装到原位。"
else:
operation_hint = f"🔧 拆卸操作\n请操作模型 '{target_model}' 将其拆卸。"
# 操作提示(考核模式下不显示)
if self.operation_text is not None:
self.operation_text.setPlainText(operation_hint)
if self.chinese_font:
self.operation_text.setFont(self.chinese_font)
def skip_current_step(self):
if self.interaction_manager.is_active:
# 停止可能正在进行的拖拽
if self.interaction_manager.dragging_model:
self.interaction_manager.stop_dragging()
self.interaction_manager.complete_current_step()
def stop_interaction(self):
self.interaction_manager.stop_interaction_mode()
self.close()
def closeEvent(self, event):
reply = QMessageBox.question(self, "确认", "是否要停止拆装交互?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
self.stop_interaction()
event.accept()
else:
event.ignore()