import sys import numpy as np from PyQt5.QtGui import QColor, QFont 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.edges = np.array([]) #轮廓边 self.face_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 = 'surface' # 默认显示模式: 点云 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 = {} # 顶点索引 → 唯一颜色(用于反查) #选色模式 self.set_color = False self.highlighted_face_indices = [] # 存储所有要高亮的面索引 self.setFont(QFont("SimHei", 10)) # 让 renderText 使用黑体 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() # --- 设置摄像机 --- 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, 0.0, 0.0, 1.0, 0.0 ) 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) # 所有轮廓线已在 load_data_from_file 中提取为 self.edges # 绘制模型坐标轴 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': # ✅ 点云模式:只显示顶点 glDisable(GL_LIGHTING) glPointSize(5.0) glBegin(GL_POINTS) 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, 1.0, 0.0) # 黄色高亮 else: if i < len(self.colors): color = self.colors[i] glColor3f(color[0], color[1], color[2]) else: glColor3f(0.8, 0.8, 0.8) glVertex3f(v[0], v[1], v[2]) glEnd() # 额外高亮选中点 if self.selected_point is not None: glPointSize(12.0) glBegin(GL_POINTS) glColor3f(1.0, 1.0, 0.0) glVertex3f(self.selected_point[0], self.selected_point[1], self.selected_point[2]) glEnd() elif self.display_mode == 'surface' and len(self.triangles) > 0: # ✅ 开启光照 glEnable(GL_LIGHTING) try: 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: # 将 triangles 转为 list of tuples 便于索引查找(只做一次) # 如果 self.triangles 是 numpy 数组,转换为列表 triangles_list = [tuple(tri) for tri in self.triangles] if isinstance(self.triangles, np.ndarray) else self.triangles for tri_idx, tri in enumerate(self.triangles): # 判断当前三角形是否在高亮区域 is_face_highlighted = ( hasattr(self, 'highlighted_face_indices') and isinstance(self.highlighted_face_indices, (list, set)) and tri_idx in self.highlighted_face_indices ) for idx in tri: # 优先级:共面高亮 > 选中点高亮 > 正常颜色 if is_face_highlighted: glColor3f(1.0, 1.0, 1.0) # 白色高亮(共面区域) elif 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]) except Exception as e: print(f"Error in drawing triangles: {e}") finally: glEnd() # === 绘制轮廓线(静态,来自 self.edges)=== glDisable(GL_LIGHTING) if len(self.edges) > 0: glLineWidth(2.5) glColor3f(0.0, 0.0, 0.0) # 黑色轮廓线 glBegin(GL_LINES) try: for edge in self.edges: v0 = self.vertices[edge[0]] v1 = self.vertices[edge[1]] glVertex3f(v0[0], v0[1], v0[2]) glVertex3f(v1[0], v1[1], v1[2]) finally: glEnd() glEnable(GL_LIGHTING) 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, silhouette_edges=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([]) if silhouette_edges is not None: self.edges = np.array(silhouette_edges, dtype=np.int32) # ← 存为 self.edges else: self.edges = np.array([]) # ✅ 修正:打印 self.edges,不是 self.silhouette_edges print(f"✅ 设置数据: {len(self.vertices)} 个顶点, {len(self.triangles)} 个三角面") print(f" 轮廓线数量: {len(self.edges)} 条") # ✅ 正确字段名 self.update() def mousePressEvent(self, event): self.last_mouse_pos = event.pos() #二维坐标,仅用来计算旋转/移动的量 if event.button() == Qt.LeftButton: if self.set_color == True: self._do_color_pick(event.pos()) if self.picking == True: self._do_picking(event.pos()) super().mousePressEvent(event) #调用父类中默认的方法,获得预设的一些功能 def _do_color_pick(self, pos): """ 点击屏幕某点,找到对应的三角形, 然后通过“法线连通性”扩展出整个平面区域, 将该区域所有三角形的顶点颜色设为白色(或加深) """ # 1. 射线拾取:获取被点击的三角形索引 picked_tri_idx = self._ray_cast_triangle_index(pos) if picked_tri_idx is None: return # 没点中任何面 print(f"选中三角形: {picked_tri_idx}") # 2. 获取该三角形的法线(作为基准) base_normal = self.face_normals[picked_tri_idx] tolerance = 0.05 # 法线夹角余弦容忍度(越小越严格) # 3. 使用广度优先搜索(BFS)扩展所有“共面”三角形 from collections import deque queue = deque([picked_tri_idx]) visited = set() visited.add(picked_tri_idx) region_triangles = [] # 存储属于这个“面区域”的所有三角面索引 while queue: tri_idx = queue.popleft() region_triangles.append(tri_idx) # 获取当前三角形的三个顶点 v0, v1, v2 = self.triangles[tri_idx] all_vertices = [v0, v1, v2] # 遍历所有邻接三角形(共享边的三角形) for i in range(3): v_a = all_vertices[i] v_b = all_vertices[(i + 1) % 3] edge = tuple(sorted([v_a, v_b])) # 边(无序) # 查找共享这条边的其他三角形(需预建边到面的映射,或遍历) # 这里简化:遍历所有三角形找邻接 for neighbor_idx in range(len(self.triangles)): if neighbor_idx in visited: continue tri = self.triangles[neighbor_idx] tri_edges = [ tuple(sorted([tri[0], tri[1]])), tuple(sorted([tri[1], tri[2]])), tuple(sorted([tri[2], tri[0]])), ] if edge in tri_edges: # 是邻接三角形,检查法线是否接近 neighbor_normal = self.face_normals[neighbor_idx] dot = np.dot(base_normal, neighbor_normal) if abs(dot) > (1 - tolerance): # 法线夹角小 visited.add(neighbor_idx) queue.append(neighbor_idx) # =================================== # ✅ 核心修改:不再修改顶点颜色 # 而是将共面区域的三角形索引保存到 self.highlighted_face_indices # 由 paintGL 在渲染时决定是否高亮 # =================================== self.highlighted_face_indices = region_triangles # 可选:打印调试信息 print(f"✅ 共面区域包含 {len(self.highlighted_face_indices)} 个三角形") # 触发重绘 self.update() # 调用 paintGL 重新渲染 def _is_adjacent(self, tri_idx1, tri_idx2): """判断两个三角形是否共享一条边(即邻接)""" v1 = set(self.triangles[tri_idx1]) v2 = set(self.triangles[tri_idx2]) return len(v1 & v2) == 2 # 共享两个顶点 → 共享一条边 def _ray_cast_triangle_index(self, pos): """ 从屏幕点击位置发射一条射线,检测与哪个三角形相交(最近的) 返回相交的三角形索引,无则返回 None """ width, height = self.width(), self.height() x, y = pos.x(), pos.y() # 1. 获取当前模型视图和投影矩阵 modelview = np.array(self.get_current_matrix()[0], dtype=np.float64) projection = np.array(self.get_current_matrix()[1], dtype=np.float64) viewport = (0, 0, width, height) # 2. 将屏幕坐标转换为世界空间中的两个点(近平面和远平面) try: # 注意:OpenGL 的 Y 轴向下,所以要翻转 y world_near = gluUnProject(x, height - y, 0.0, modelview, projection, viewport) world_far = gluUnProject(x, height - y, 1.0, modelview, projection, viewport) except Exception as e: print(f"gluUnProject failed: {e}") return None # 3. 构造射线方向 ray_origin = np.array(world_near) ray_direction = np.array(world_far) - np.array(world_near) ray_direction = ray_direction / (np.linalg.norm(ray_direction) + 1e-8) # 4. 遍历所有三角形,做射线相交检测 best_t = float('inf') picked_idx = None for idx, triangle in enumerate(self.triangles): v0_idx, v1_idx, v2_idx = triangle v0 = np.array(self.vertices[v0_idx]) v1 = np.array(self.vertices[v1_idx]) v2 = np.array(self.vertices[v2_idx]) result = self._ray_triangle_intersect( ray_origin, ray_direction, v0, v1, v2 ) if result is not None: t, u, v = result if t < best_t and t > 0: best_t = t picked_idx = idx return picked_idx def _ray_triangle_intersect(self, ray_origin, ray_direction, v0, v1, v2, eps=1e-6): """ Möller–Trumbore 射线-三角形相交算法 返回: (t, u, v) 或 None(无交点) t: 射线上交点距离 u,v: 重心坐标 """ # 三角形边向量 edge1 = v1 - v0 edge2 = v2 - v0 # P = ray_direction × edge2 P = np.cross(ray_direction, edge2) det = np.dot(edge1, P) if abs(det) < eps: return None # 射线与三角形平行 inv_det = 1.0 / det T = ray_origin - v0 u = np.dot(T, P) * inv_det if u < 0.0 or u > 1.0: return None Q = np.cross(T, edge1) v = np.dot(ray_direction, Q) * inv_det if v < 0.0 or u + v > 1.0: return None t = np.dot(edge2, Q) * inv_det if t > eps: return t, u, v # 相交 return None from OpenGL.GL import glGetDoublev, GL_MODELVIEW_MATRIX, GL_PROJECTION def get_current_matrix(self): """ 安全获取当前 Modelview 和 Projection 矩阵 必须在 OpenGL 上下文中调用(比如在 paintGL 之后) """ try: modelview = glGetDoublev(GL_MODELVIEW_MATRIX) projection = glGetDoublev(GL_PROJECTION_MATRIX) # 注意:是 GL_PROJECTION_MATRIX,不是 GL_PROJECTION return modelview, projection except Exception as e: print(f"获取矩阵失败: {e}") return None, None def _do_picking(self, pos): x, y = pos.x(), pos.y() self.makeCurrent() self._render_for_picking() #再渲染一个当前的模型到gpu #提取pos坐标像素点在窗口中的实际颜色piexel pixel = glReadPixels(x, self.height() - y - 1, 1, 1, GL_RGB, GL_FLOAT) #将rgb分量的值提取出来 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 #设置断点::参数入栈函数,GL_ALL_ATTRIB_BITS:当前OPENGL的所有参数 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 self.set_color = False print(f"已进入选点模式:{self.picking}") else: self.picking = False print(f"已退出选点模式:{self.picking}") # self.update() # 切换模式后重新绘制 def color_mode(self): """切换选色模式""" if self.set_color == False: self.set_color = True self.picking = False #打开选色则关闭选点 print(f"已进入选色模式:{self.set_color}") else: self.set_color = False print(f"已退出选色模式:{self.set_color}") #统一颜色显示方式 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)