import sys import numpy as np from PyQt5.QtWidgets import QApplication, QOpenGLWidget, QVBoxLayout, QWidget from PyQt5.QtCore import Qt, pyqtSignal from OpenGL.GL import * from OpenGL.GLU import * from OpenGL.GLUT import glutBitmapCharacter, glutStrokeCharacter, GLUT_BITMAP_HELVETICA_18, GLUT_BITMAP_TIMES_ROMAN_24 # GLUT_BITMAP_HELVETICA_18是运行时调用,标红正常 class Simple3DWidget(QOpenGLWidget): pointPicked = pyqtSignal(float, float, float) def __init__(self, parent=None): super().__init__(parent) self.vertices = np.array([]) # 顶点数据 self.colors = np.array([]) # 颜色数据 self.triangles = np.array([], dtype=np.int32) # 三角面数据 ← 新增 self.normals = np.array([]) # 法线数据 ← 新增 self.rotation = [0.0, 0.0] # 旋转角度 [俯仰, 偏航] self.zoom = -5.0 # 视距(负值表示拉远) self.pan = [0.0, 0.0] # 平移偏移 [x, y] self.last_mouse_pos = None # 鼠标位置 self.setMouseTracking(True) # 启用鼠标跟踪 self.display_mode = 'points' # 默认显示模式: 点云 self.axes_display_mode = False self.axes_world_display_mode = True # 🔧 模型变换 self.model_rotation = [0.0, 0.0] # 模型的 [俯仰, 偏航] self.model_pan = [0.0, 0.0] # 模型平移 (x, y) self.model_scale = 1.0 # 模型缩放 # 🎯 视角(世界)变换 self.view_rotation = [0.0, 0.0] # 模型的 [俯仰, 偏航] self.view_pan = [0.0, 0.0] # 视点平移 (X, Y),用于 Ctrl+右键 self.view_distance = 8.0 # 视点到目标的距离 #选点模式 self.selected_point = None # 存储选中的点坐标 self.picking = False # 是否处于拾取模式 self.picking_color_map = {} # 顶点索引 → 唯一颜色(用于反查) def initializeGL(self): """初始化 OpenGL 状态""" print("✅ 3D OpenGL 初始化") glEnable(GL_DEPTH_TEST) glEnable(GL_COLOR_MATERIAL) glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE) glEnable(GL_LIGHT0) glLightfv(GL_LIGHT0, GL_POSITION, (1.0, 1.0, 1.0, 0.0)) # 平行光 glClearColor(0.1, 0.1, 0.1, 1.0) def resizeGL(self, width, height): """窗口大小改变时调用""" print(f"✅ 调整大小: {width}x{height}") if height == 0: height = 1 glViewport(0, 0, width, height) self.update() # 重新绘制 def paintGL(self): glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) glEnable(GL_DEPTH_TEST) glMatrixMode(GL_PROJECTION) glLoadIdentity() gluPerspective(45, self.width() / max(self.height(), 1), 0.1, 100.0) glMatrixMode(GL_MODELVIEW) glLoadIdentity() # ✅ 使用 gluLookAt 构建轨道摄像机 # 视角参数 yaw = self.view_rotation[1] # 偏航角(左右) pitch = self.view_rotation[0] # 俯仰角(上下) distance = self.view_distance # 1. 计算摄像机位置(绕原点轨道) radius = distance cam_x = radius * np.cos(np.radians(yaw)) * np.cos(np.radians(pitch)) cam_y = radius * np.sin(np.radians(pitch)) cam_z = radius * np.sin(np.radians(yaw)) * np.cos(np.radians(pitch)) # 2. 目标点 = 世界原点 (0,0,0) target_x, target_y, target_z = 0.0, 0.0, 0.0 # 3. 上方向(防止翻滚) up_x, up_y, up_z = 0.0, 1.0, 0.0 # ✅ 设置摄像机(绕原点轨道旋转) gluLookAt( cam_x, cam_y, cam_z, # 摄像机位置 target_x, target_y, target_z, # 看向目标(世界原点) up_x, up_y, up_z # 上方向 ) # ✅ 应用视点平移(Ctrl+右键平移) glTranslatef(self.view_pan[0], self.view_pan[1], 0) # ✅ 绘制世界坐标系(在原点) if self.axes_world_display_mode: self.drawWorldAxes() # ✅ 模型变换 glPushMatrix() glTranslatef(self.model_pan[0], self.model_pan[1], 0) glRotatef(self.model_rotation[0], 1, 0, 0) glRotatef(self.model_rotation[1], 0, 1, 0) glScalef(self.model_scale, self.model_scale, self.model_scale) if self.axes_display_mode: self.drawModelAxes() self._draw_model() glPopMatrix() def _draw_model(self): """私有方法:绘制模型(含高亮)""" if len(self.vertices) == 0: return if self.display_mode == 'points': glPointSize(8.0) glBegin(GL_POINTS) try: for i, v in enumerate(self.vertices): # 判断是否是选中点 if self.selected_point is not None and np.allclose(v, self.selected_point, atol=1e-6): glColor3f(1.0, 0.0, 0.0) # 红色高亮 else: glColor3f(*self.colors[i]) # 原始颜色 glVertex3f(v[0], v[1], v[2]) finally: glEnd() # ✅ 保证执行 elif self.display_mode == 'surface' and len(self.triangles) > 0: # ✅ 开启光照 glEnable(GL_LIGHTING) try: # ✅ 预计算选中点的索引(避免在 glBegin 内部调用 np.allclose) highlight_indices = set() if self.selected_point is not None: for idx, v in enumerate(self.vertices): if np.allclose(v, self.selected_point, atol=1e-6): highlight_indices.add(idx) glBegin(GL_TRIANGLES) try: for tri in self.triangles: for idx in tri: # 高亮三角形中包含选中点的顶点 if idx in highlight_indices: glColor3f(1.0, 0.0, 0.0) else: glColor3f(*self.colors[idx]) # 设置法线(如果存在且有效) if len(self.normals) > idx: n = self.normals[idx] if not np.any(np.isnan(n)) and not np.any(np.isinf(n)): glNormal3f(*n) glVertex3f(*self.vertices[idx]) finally: glEnd() # ✅ 保证结束绘制 finally: glDisable(GL_LIGHTING) # ✅ 保证关闭光照 def renderText(self, x, y, z, text): """ 在指定的三维坐标位置渲染文本。 :param x: X轴坐标 :param y: Y轴坐标 :param z: Z轴坐标 :param text: 要渲染的文本内容 """ glRasterPos3f(x, y, z) # 设置文本位置 for ch in text: glutBitmapCharacter(GLUT_BITMAP_TIMES_ROMAN_24, ord(ch)) # 渲染每个字符 def drawWorldAxes(self): """绘制固定的世界坐标系(左下角)""" glPushMatrix() try: # 移动到左下角 glTranslatef(-4.0, -4.0, -5.0) glLineWidth(2.0) glBegin(GL_LINES) # X (红) glColor3f(1, 0, 0) glVertex3f(0, 0, 0) glVertex3f(1000, 0, 0) # Y (绿) glColor3f(0, 1, 0) glVertex3f(0, 0, 0) glVertex3f(0, 1000, 0) # Z (蓝) glColor3f(0, 0, 1) glVertex3f(0, 0, 0) glVertex3f(0, 0, 1000) glEnd() # 结束绘制 # ✅ 确保 glEnd() 后再绘制文本,避免 OpenGL 状态混乱 # 绘制文本标签 glColor3f(1.0, 0.0, 0.0) self.renderText(1.5, 0, 0, 'X') glColor3f(0.0, 1.0, 0.0) self.renderText(0, 1.5, 0, 'Y') glColor3f(0.0, 0.0, 1.0) self.renderText(0, 0, 1.5, 'Z') finally: # ✅ 无论是否出错,都确保弹出矩阵栈 glPopMatrix() def drawModelAxes(self): """绘制随模型移动的坐标系""" glPushMatrix() glLineWidth(2.5) glBegin(GL_LINES) # X glColor3f(1, 0, 0) glVertex3f(0, 0, 0); glVertex3f(2, 0, 0) # Y glColor3f(0, 1, 0) glVertex3f(0, 0, 0); glVertex3f(0, 2, 0) # Z glColor3f(0, 0, 1) glVertex3f(0, 0, 0); glVertex3f(0, 0, 2) glEnd() # 绘制文本标签 glColor3f(1.0, 0.0, 0.0) # 设置颜色为红色 self.renderText(1.5, 0, 0, 'X') # X轴标签 glColor3f(0.0, 1.0, 0.0) # 设置颜色为绿色 self.renderText(0, 1.5, 0, 'Y') # Y轴标签 glColor3f(0.0, 0.0, 1.0) # 设置颜色为蓝色 self.renderText(0, 0, 1.5, 'Z') # Z轴标签 glPopMatrix() def set_data(self, vertices, colors, triangles=None, normals=None): """设置 3D 数据(支持 mesh)""" self.vertices = np.array(vertices, dtype=np.float32) self.colors = np.array(colors, dtype=np.float32) if triangles is not None: self.triangles = np.array(triangles, dtype=np.int32) else: self.triangles = np.array([]) if normals is not None: self.normals = np.array(normals, dtype=np.float32) else: self.normals = np.array([]) print(f"✅ 设置数据: {len(self.vertices)} 个顶点, {len(self.triangles)} 个三角面") self.update() def mousePressEvent(self, event): self.last_mouse_pos = event.pos() #二维坐标,仅用来计算旋转/移动的量 if event.button() == Qt.LeftButton: if self.picking == True: self._do_picking(event.pos()) super().mousePressEvent(event) #调用父类中默认的方法,获得预设的一些功能 def _do_picking(self, pos): x, y = pos.x(), pos.y() self.makeCurrent() self._render_for_picking() pixel = glReadPixels(x, self.height() - y - 1, 1, 1, GL_RGB, GL_FLOAT) r, g, b = pixel[0][0] print(f"Read color: ({r:.5f}, {g:.5f}, {b:.5f})") # ✅ 关键修复:转成 float 再 round,避免虽然值一样,但 Python 字典认为 np.float32(0.01176) ≠ float(0.01176)! key = (round(float(r), 5), round(float(g), 5), round(float(b), 5)) if key in self.picking_color_map: index = self.picking_color_map[key] picked_point = self.vertices[index] self.selected_point = picked_point print(f"🎯 拾取到点: {picked_point}, 索引: {index}") self.pointPicked.emit(*picked_point) else: print(f"❌ 未找到对应点,key={key} 不在 map 中") self.update() def mouseMoveEvent(self, event): if self.last_mouse_pos is None: return dx = event.x() - self.last_mouse_pos.x() dy = event.y() - self.last_mouse_pos.y() is_ctrl = event.modifiers() & Qt.ControlModifier if event.buttons() & Qt.LeftButton: if is_ctrl: # Ctrl + 左键:旋转世界(视角) self.view_rotation[0] += dy * 0.5 # 俯仰 self.view_rotation[1] += dx * 0.5 # 偏航 self.view_rotation[0] = max(-89.0, min(89.0, self.view_rotation[0])) else: # 左键:旋转模型 self.model_rotation[0] += dy * 0.5 self.model_rotation[1] += dx * 0.5 elif event.buttons() & Qt.RightButton: if is_ctrl: # Ctrl + 右键:平移世界(视点平移) self.view_pan[0] += dx * 0.01 self.view_pan[1] -= dy * 0.01 else: # 右键:平移模型 self.model_pan[0] += dx * 0.01 self.model_pan[1] -= dy * 0.01 self.last_mouse_pos = event.pos() self.update() def wheelEvent(self, event): delta = event.angleDelta().y() #view_distance 相机到模型的距离,delta:鼠标滚动的值 self.view_distance -= delta * 0.005 #距离的范围限定 self.view_distance = max(1.0, min(50.0, self.view_distance)) self.update() def toggle_display_mode(self): """切换显示模式:点云 <-> 表面""" if self.display_mode == 'points': self.display_mode = 'surface' else: self.display_mode = 'points' self.update() # 切换模式后重新绘制 def toggle_axes_display_mode(self): "切换坐标系显示模式" if self.axes_display_mode == False: self.axes_display_mode = True else: self.axes_display_mode = False self.update() def _generate_picking_colors(self): """为每个顶点生成唯一颜色(用于拾取)""" if len(self.vertices) == 0: return np.array([]) colors = np.zeros((len(self.vertices), 3), dtype=np.float32) for i in range(len(self.vertices)): # 用整数编码成 RGB(最多支持 ~1677 万个点) color_id = i + 1 # 从 1 开始,避免 0,0,0(黑色)误判 r = (color_id & 0xFF) / 255.0 g = ((color_id >> 8) & 0xFF) / 255.0 b = ((color_id >> 16) & 0xFF) / 255.0 colors[i] = [r, g, b] self.picking_color_map[(r, g, b)] = i # 反向映射 return colors def _render_for_picking(self): if len(self.vertices) == 0: return glPushAttrib(GL_ALL_ATTRIB_BITS) glDisable(GL_LIGHTING) glDisable(GL_TEXTURE_2D) glShadeModel(GL_FLAT) glClearColor(0, 0, 0, 0) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) glEnable(GL_DEPTH_TEST) # --- 复制 paintGL 的投影和视图变换 --- glMatrixMode(GL_PROJECTION) glLoadIdentity() gluPerspective(45, self.width() / max(self.height(), 1), 0.1, 100.0) glMatrixMode(GL_MODELVIEW) glLoadIdentity() yaw = self.view_rotation[1] pitch = self.view_rotation[0] distance = self.view_distance cam_x = distance * np.cos(np.radians(yaw)) * np.cos(np.radians(pitch)) cam_y = distance * np.sin(np.radians(pitch)) cam_z = distance * np.sin(np.radians(yaw)) * np.cos(np.radians(pitch)) gluLookAt(cam_x, cam_y, cam_z, 0, 0, 0, 0, 1, 0) glTranslatef(self.view_pan[0], self.view_pan[1], 0) glPushMatrix() glTranslatef(self.model_pan[0], self.model_pan[1], 0) glRotatef(self.model_rotation[0], 1, 0, 0) glRotatef(self.model_rotation[1], 0, 1, 0) glScalef(self.model_scale, self.model_scale, self.model_scale) # ✅ 用唯一颜色绘制顶点 glPointSize(8.0) glBegin(GL_POINTS) for i, v in enumerate(self.vertices): idx = i + 1 r = (idx) & 0xFF g = (idx >> 8) & 0xFF b = (idx >> 16) & 0xFF glColor3f(r / 255.0, g / 255.0, b / 255.0) glVertex3f(v[0], v[1], v[2]) glEnd() glPopMatrix() glPopAttrib() def point_mode(self): """切换选点模式""" if self.picking == False: self.picking = True print(f"已进入选点模式:{self.picking}") else: self.picking = False print(f"已退出选点模式:{self.picking}") # self.update() # 切换模式后重新绘制 #统一颜色显示方式 def _get_picking_color(self, index): idx = index + 1 r = (idx) & 0xFF g = (idx >> 8) & 0xFF b = (idx >> 16) & 0xFF return (r / 255.0, g / 255.0, b / 255.0)