diff --git a/config(example).json b/config(example).json index 4b21358..dc23b02 100644 --- a/config(example).json +++ b/config(example).json @@ -13,5 +13,18 @@ "move_max_speed": { "xy": 3000, "z": 200 + }, + "home_positions": { + "x": 134, + "y": 123, + "z": 10 + }, + "hotend_temp_range": { + "min": 0, + "max": 300 + }, + "bed_temp_range": { + "min": 0, + "max": 50 } } \ No newline at end of file diff --git a/install.sh b/install.sh index 441c231..5e6fda9 100755 --- a/install.sh +++ b/install.sh @@ -63,9 +63,17 @@ echo "激活虚拟环境并升级 pip..." source "$VENVDIR/bin/activate" python -m pip install --upgrade pip setuptools wheel +PYTHON_VERSION=$(ls $VENVDIR/lib/ | grep python3) + + if [ "${install_pyqt_via_pip:-0}" = "1" ]; then echo "在 venv 中通过 pip 安装 PyQt6(回退)..." pip install PyQt6 +else + echo "系统级 PyQt6 已安装,复制到虚拟环境中以供使用..." + cp -r /usr/lib/python3/dist-packages/PyQt6 "$VENVDIR/lib/$PYTHON_VERSION/site-packages/" + cp -r /usr/lib/python3/dist-packages/PyQt6-*.dist-info "$VENVDIR/lib/$PYTHON_VERSION/site-packages/" + cp -r /usr/lib/python3/dist-packages/PyQt6_sip-*.dist-info "$VENVDIR/lib/$PYTHON_VERSION/site-packages/" || true fi REQ_FILE="$SCRIPT_DIR/requirements.txt" diff --git a/pages/control_page.py b/pages/control_page.py index e740e11..aa12727 100644 --- a/pages/control_page.py +++ b/pages/control_page.py @@ -275,7 +275,6 @@ class ControlPage(QWidget): def _on_config_changed(self, config_instance): self._load_limits() self._load_speeds() - print(f"z-max-s:{self.move_speed_z}") # ── 状态管理 ────────────────────────────────────────── diff --git a/pages/setting_page.py b/pages/setting_page.py index 9f847d9..ec30e19 100644 --- a/pages/setting_page.py +++ b/pages/setting_page.py @@ -8,6 +8,7 @@ from PyQt6.QtWidgets import ( QPushButton, QLineEdit, QMessageBox, + QDialog, QFormLayout, QComboBox, QListWidgetItem, @@ -28,6 +29,7 @@ import re from utils.wifi_manager import WifiManager from utils.floating_keyboard import FloatingKeyboard from utils.get_bootstrap_icon import get_colored_icon, get_colored_pixmap +from utils.modern_confirm import ModernConfirmDialog class DragScrollArea(QScrollArea): @@ -344,7 +346,7 @@ class SettingPage(QWidget): color: #f2f2f2; font-size: 30px; padding: 10px; - background-color: #3f3f3f; + background-color: transparent; } QMessageBox QPushButton { min-width: 130px; @@ -1016,32 +1018,25 @@ class SettingPage(QWidget): self.settings_stack.addWidget(self._wrap_scroll(power_widget)) def _confirm_reboot(self): - reply = QMessageBox.question( - self, "确认重启", - "确定要重启系统吗?\n所有未保存的数据将丢失。", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No - ) - if reply == QMessageBox.StandardButton.Yes: + dlg = ModernConfirmDialog(self, "确认重启", "确定要重启系统吗?\n所有未保存的数据将丢失。", "question-circle.svg", confirm_text="重启", cancel_text="取消") + if dlg.exec() == QDialog.DialogCode.Accepted: + # QTimer.singleShot(500, lambda: print("test sudo reboot")) + QTimer.singleShot(500, lambda: os.system("sudo reboot")) self._styled_message( "info", self, "重启", "系统正在重启..." ) - QTimer.singleShot(500, lambda: os.system("sudo reboot")) def _confirm_shutdown(self): - reply = QMessageBox.question( - self, "确认关机", - "确定要关闭系统吗?\n关闭后需要手动重新开机。", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No - ) - if reply == QMessageBox.StandardButton.Yes: + dlg = ModernConfirmDialog(self, "确认关机", "确定要关闭系统吗?\n关闭后需要手动重新开机。", "question-circle.svg", confirm_text="关机", cancel_text="取消") + if dlg.exec() == QDialog.DialogCode.Accepted: + # QTimer.singleShot(500, lambda: print("test sudo poweroff")) + QTimer.singleShot(500, lambda: os.system("sudo poweroff")) self._styled_message( "info", self, "关机", "系统正在关机..." ) - QTimer.singleShot(500, lambda: os.system("sudo poweroff")) + def init_todo_settings(self): todo_widget = QWidget() diff --git a/pages/status_page.py b/pages/status_page.py index d324289..0effa47 100644 --- a/pages/status_page.py +++ b/pages/status_page.py @@ -71,14 +71,19 @@ class CardFrame(QFrame): class TempGauge(QWidget): """温度计指示器""" - def __init__(self, label="Tool", parent=None): + def __init__(self, label="Tool", temp_range=(0, 300), parent=None): super().__init__(parent) - self.setFixedSize(100, 80) + self.setFixedSize(160, 90) self._label = label self._actual = 0.0 self._target = 0.0 + self._max_temp = temp_range[1] + self._min_temp = temp_range[0] - def set_value(self, actual, target): + def set_value(self, actual, target, temp_range=None): + if temp_range is not None: + self._max_temp = temp_range[1] + self._min_temp = temp_range[0] self._actual = actual self._target = target self.update() @@ -89,14 +94,14 @@ class TempGauge(QWidget): w, h = self.width(), self.height() # 背景条 - bar_x, bar_w = 16, 20 + bar_x, bar_w = 60, 20 bar_y, bar_h = 10, 56 p.setPen(QPen(QColor("#555555"), 1)) p.setBrush(QBrush(QColor("#2a2a2a"))) p.drawRoundedRect(bar_x, bar_y, bar_w, bar_h, 4, 4) # 填充柱(按温度比例,最高 300°C) - ratio = min(max(self._actual / 300, 0), 1) + ratio = min(max((self._actual - self._min_temp) / (self._max_temp - self._min_temp), 0), 1) fill_h = int((bar_h - 4) * ratio) if fill_h > 0: grad = QLinearGradient(0, bar_y + bar_h, 0, bar_y) @@ -108,28 +113,29 @@ class TempGauge(QWidget): # 目标值标记线 if self._target > 0: - tgt_y = bar_y + bar_h - int((bar_h - 4) * min(self._target / 300, 1)) - p.setPen(QPen(QColor("#ffffff"), 2)) + tgt_y = bar_y + bar_h - int((bar_h - 4) * min((self._target - self._min_temp) / (self._max_temp - self._min_temp), 1)) + p.setPen(QPen(QColor("#888888"), 2)) p.drawLine(bar_x - 2, tgt_y, bar_x + bar_w + 2, tgt_y) # 文字 font = QFont("sans-serif", 11, QFont.Weight.Bold) p.setFont(font) - p.setPen(QPen(QColor("#e0e0e0"))) - p.drawText(44, 16, w - 44, 24, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, - f"{self._actual:.0f}°") + temp_to_hex_soft = lambda t: "#{:02x}{:02x}{:02x}".format(*((lambda x: ((int(255*(0.3+0.7*(x/0.5)**0.8)), int(180*(x/0.5)**0.9), 255)if x < 0.5 else(255, int(180*(1-((x-0.5)/0.5)**1.2)), 80)))(max(0, min(t - self._min_temp, self._max_temp - self._min_temp))/(self._max_temp - self._min_temp)))) + p.setPen(QPen(QColor(temp_to_hex_soft(self._actual)))) + p.drawText(0, int(bar_y + bar_h*(1-ratio)-12), w - 44, 24, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + f"{self._actual:>5.1f}°C") if self._target > 0: - tgt_y = bar_y + bar_h - int((bar_h - 4) * min(self._target / 300, 1)) + tgt_y = bar_y + bar_h - int((bar_h - 4) * min((self._target - self._min_temp) / (self._max_temp - self._min_temp), 1)) font2 = QFont("sans-serif", 10) p.setFont(font2) p.setPen(QPen(QColor("#888888"))) - p.drawText(44, tgt_y - 10, w - 44, 20, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, - f"→ {self._target:.0f}°") + p.drawText(90, tgt_y - 10, w - 44, 20, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + f"{self._target:.1f}°C") font3 = QFont("sans-serif", 10, QFont.Weight.Bold) p.setFont(font3) p.setPen(QPen(QColor("#aaaaaa"))) - p.drawText(0, h - 20, w, 20, Qt.AlignmentFlag.AlignCenter, self._label) + p.drawText(0, h - 20, w-20, 20, Qt.AlignmentFlag.AlignCenter, self._label) @@ -155,6 +161,8 @@ class StatusPage(QWidget): self.config_parser = ConfigParse() self.config_parser.config_changed.connect(self._on_config_changed) self.gcode_dir = self.config_parser.gcode_dir + self.tool_temp_range = (self.config_parser.hotend_temp_range.get("min", 0), self.config_parser.hotend_temp_range.get("max", 300)) + self.bed_temp_range = (self.config_parser.bed_temp_range.get("min", 0), self.config_parser.bed_temp_range.get("max", 120)) self._loaded_file = None self.init_ui() @@ -165,6 +173,8 @@ class StatusPage(QWidget): def _on_config_changed(self, config_instance): self.gcode_dir = self.config_parser.gcode_dir + self.tool_temp_range = (self.config_parser.hotend_temp_range.get("min", 0), self.config_parser.hotend_temp_range.get("max", 300)) + self.bed_temp_range = (self.config_parser.bed_temp_range.get("min", 0), self.config_parser.bed_temp_range.get("max", 120)) def fresh_status_valve(self): data = self.api_client.get_status() @@ -272,8 +282,8 @@ class StatusPage(QWidget): self._temp_card = CardFrame("温度") temp_row = QHBoxLayout() temp_row.setSpacing(8) - self._tool_gauge = TempGauge("喷头") - self._bed_gauge = TempGauge("热床") + self._tool_gauge = TempGauge("喷头", self.tool_temp_range) + self._bed_gauge = TempGauge("热床", self.bed_temp_range) temp_row.addWidget(self._tool_gauge) temp_row.addWidget(self._bed_gauge) temp_row.addStretch() @@ -361,8 +371,8 @@ class StatusPage(QWidget): """) # 温度 - self._tool_gauge.set_value(self.tool_temp_actual, self.tool_temp_target) - self._bed_gauge.set_value(self.bed_temp_actual, self.bed_temp_target) + self._tool_gauge.set_value(self.tool_temp_actual, self.tool_temp_target, self.tool_temp_range) + self._bed_gauge.set_value(self.bed_temp_actual, self.bed_temp_target, self.bed_temp_range) # G-code 模型加载与进度更新 if self.file_name and self.file_name != "None": @@ -380,48 +390,3 @@ class StatusPage(QWidget): is_printing = self.state.startswith("Printing") or self.state.startswith("Paused") self.gcode_viewer.update_by_filepos(self.filepos, is_printing) - - - #TODO: Better Gcode Parser, this one is too slow for large files, need to optimize or use a separate thread to load - # def load_gcode_vertices(self, path): - # vertices = [] - - # x = 0 - # y = 0 - # z = 0 - - # with open(path, "r", encoding="utf-8", errors="ignore") as f: - # for line in f: - # line = line.strip() - - # if not line: - # continue - - # if line.startswith("G0") or line.startswith("G1"): - # old_x = x - # old_y = y - # old_z = z - - # mx = re.search(r"X([-0-9.]+)", line) - # my = re.search(r"Y([-0-9.]+)", line) - # mz = re.search(r"Z([-0-9.]+)", line) - - # if mx: - # x = float(mx.group(1)) - - # if my: - # y = float(my.group(1)) - - # if mz: - # z = float(mz.group(1)) - - # vertices.append({ - # "x1": old_x, - # "y1": old_y, - # "z1": old_z, - # "x2": x, - # "y2": y, - # "z2": z, - # }) - - # return vertices diff --git a/utils/aio_print_api.py b/utils/aio_print_api.py index 44be304..1da53f2 100644 --- a/utils/aio_print_api.py +++ b/utils/aio_print_api.py @@ -1,4 +1,5 @@ import requests +import random class AIOPrrintSystemAPI: def __init__(self, api_url="http://127.0.0.1:5001/api/v1", api_key=""): @@ -22,18 +23,18 @@ class AIOPrrintSystemAPI: # 'job': { # 'estimatedPrintTime': 1234, # 'filament': {'length': 765, 'volume': 24356}, - # 'file': {'display_name': 'Test File','date': None, 'name': '20260508141659_085359c9908947bebcaa0fe7490641e8.gcode', 'origin': 'local', 'path': None, 'size': 1468987}, + # 'file': {'display_name': 'Test File','date': None, 'name': '20260508141659_085359c9908947bebcaa0fe7490641e8.gcode', 'origin': 'local', 'path': None, 'size': 20934490}, # 'lastPrintTime': None, # 'user': None # }, # 'progress': { - # 'completion': 74.8, - # 'filepos': 1234, - # 'printTime': 1235, - # 'printTimeLeft': 6353, + # 'completion': random.uniform(0, 100.0), + # 'filepos': 20934490, + # 'printTime': random.randint(1234,54321), + # 'printTimeLeft': random.randint(1234,54321), # 'printTimeLeftOrigin': 5366 # }, - # 'state': 'Operational' + # 'state': 'Printing' # }, # 'status': { # 'sd': {'ready': False}, @@ -44,19 +45,19 @@ class AIOPrrintSystemAPI: # 'closedOrError': False, # 'error': False, # 'finishing': False, - # 'operational': True, + # 'operational': False, # 'paused': False, # 'pausing': False, - # 'printing': False, + # 'printing': True, # 'ready': True, # 'resuming': False, # 'sdReady': False # }, - # 'text': 'Operational test' + # 'text': 'Printing' # }, # 'temperature': { - # 'bed': {'actual': 85, 'offset': 0, 'target': 56}, - # 'tool0': {'actual': 0.0, 'offset': 0, 'target': 340} + # 'bed': {'actual': random.randint(0,100), 'offset': 0, 'target': random.randint(0,100)}, + # 'tool0': {'actual': random.randint(0,300), 'offset': 0, 'target': random.randint(0,300)} # } # } # } diff --git a/utils/config_parse.py b/utils/config_parse.py index 70c5e05..69a9cf7 100644 --- a/utils/config_parse.py +++ b/utils/config_parse.py @@ -15,6 +15,8 @@ class ConfigParse(QObject): self.gcode_dir = None self.move_axis_area = None self.move_max_speed = None + self.hotend_temp_range = None + self.bed_temp_range = None self.config = self._load_config() self._parse_config() @@ -67,4 +69,6 @@ class ConfigParse(QObject): self.move_axis_area = self.config.get("move_axis_area", None) self.move_max_speed = self.config.get("move_max_speed", {"x": 3000, "y": 3000, "z": 200}) self.home_positions = self.config.get("home_positions", {"x": 134, "y": 123, "z": 10}) - self.move_max_speed = self.config.get("move_max_speed", None) \ No newline at end of file + self.move_max_speed = self.config.get("move_max_speed", None) + self.hotend_temp_range = self.config.get("hotend_temp_range", {"min": 0, "max": 300}) + self.bed_temp_range = self.config.get("bed_temp_range", {"min": 0, "max": 120}) \ No newline at end of file diff --git a/utils/gcode_viewer.py b/utils/gcode_viewer.py index 275fe2a..5832007 100644 --- a/utils/gcode_viewer.py +++ b/utils/gcode_viewer.py @@ -17,6 +17,7 @@ class GCodeParseWorker(QThread): points = [] colors = [] type_segments = {} + segment_zs = {} type_visibility = {} x = y = z = e = 0.0 @@ -25,6 +26,7 @@ class GCodeParseWorker(QThread): current_segment_type = 'OTHER' segment_start = 0 type_visibility['TRAVEL'] = False + relative_e = False min_x = min_y = min_z = float('inf') @@ -32,11 +34,16 @@ class GCodeParseWorker(QThread): current_offset = 0 max_z_seen = -999.0 + z_deltas = [] + last_layer_z = None layer_map = [(0, 0)] + layer_grids = {} + segment_visibility = {} - def add_segment(t_name, start, length): + def add_segment(t_name, start, length, z_val): if length > 0: type_segments.setdefault(t_name, []).append((start, length)) + segment_zs[(start, length)] = z_val try: with open(self.filepath, 'rb') as f: @@ -113,7 +120,10 @@ class GCodeParseWorker(QThread): if seg_type != current_segment_type: if vertex_idx > segment_start: - add_segment(current_segment_type, segment_start, vertex_idx - segment_start) + seg_len = vertex_idx - segment_start + add_segment(current_segment_type, segment_start, seg_len, z) + segment_visibility[(segment_start, seg_len)] = (current_segment_type != 'TRAVEL') + # add_segment(current_segment_type, segment_start, vertex_idx - segment_start, z) current_segment_type = seg_type segment_start = vertex_idx @@ -127,6 +137,18 @@ class GCodeParseWorker(QThread): if new_z > max_z_seen and is_extrusion: max_z_seen = new_z layer_map.append((current_offset, vertex_idx)) + if last_layer_z is not None: + dz = round(new_z - last_layer_z, 4) + if 0.01 < dz < 2.0: + z_deltas.append(dz) + last_layer_z = new_z + + # 建立二维占据网络 + if is_extrusion: + grid_z = round(new_z, 2) + gx = int(new_x / 2.0) + gy = int(new_y / 2.0) + layer_grids.setdefault(grid_z,set()).add((gx, gy)) points.extend([x, y, z, new_x, new_y, new_z]) colors.extend([*c, *c]) @@ -144,12 +166,47 @@ class GCodeParseWorker(QThread): pass if vertex_idx > segment_start: - add_segment(current_segment_type, segment_start, vertex_idx - segment_start) + # add_segment(current_segment_type, segment_start, vertex_idx - segment_start, z) + seg_len = vertex_idx - segment_start + add_segment(current_segment_type, segment_start, seg_len, z) + segment_visibility[(segment_start, seg_len)] = True + + # 判断哪些segment在内部 + for type_name, segments in type_segments.items(): + if type_name in ('WALL-INNER', 'SUPPORT', 'FILL'): + for start, length in segments: + try: + px = points[start*3] + py = points[start*3+1] + pz = round(points[start*3+2], 2) + gx = int(px / 2.0) + gy = int(py / 2.0) + gird = layer_grids.get(pz, set()) + if not gird: + continue + neighbors = 0 + # for ox in range(-2,3): + # for oy in range(-2,3): + # if (gx + ox, gy + oy) in gird: + # neighbors += 1 + for ox, oy in [(1,0), (-1,0), (0,1), (0,-1)]: + if (gx+ox, gy+oy) in gird: + neighbors += 1 + if neighbors >= 4: + segment_visibility[(start, length)] = False + except Exception as e: + pass cx = (min_x + max_x) / 2.0 if max_x >= min_x else 110.0 cy = (min_y + max_y) / 2.0 if max_y >= min_y else 110.0 cz = (min_z + max_z) / 2.0 if max_z >= min_z else 0.0 + from collections import Counter + layer_height = 0.2 + if z_deltas: + counter = Counter(z_deltas) + layer_height = counter.most_common(1)[0][0] + result = { 'vertices': np.array(points, dtype=np.float32) if vertex_idx > 0 else np.zeros((0,), dtype=np.float32), 'colors': np.array(colors, dtype=np.float32) if vertex_idx > 0 else np.zeros((0,), dtype=np.float32), @@ -159,7 +216,10 @@ class GCodeParseWorker(QThread): 'center_z': cz, 'type_segments': type_segments, 'type_visibility': type_visibility, - 'layer_map': layer_map + 'layer_map': layer_map, + 'segment_visibility': segment_visibility, + 'segment_zs': segment_zs, + 'layer_height': layer_height } self.finished.emit(result) except Exception as e: @@ -281,6 +341,12 @@ class GCodeViewerWidget(QOpenGLWidget): self.aColor_location = None self.uMVP_location = None + # 简化参数 + self.enable_lod = True + self.lod_factor = 0.0 + self.visible_top_layers = 0 + self.visible_bottom_layers = 0 + # ── 公开接口 ── def load_gcode(self, filepath: str): if hasattr(self, '_worker') and self._worker.isRunning(): @@ -302,8 +368,13 @@ class GCodeViewerWidget(QOpenGLWidget): self.center_y = result['center_y'] self.center_z = result['center_z'] self.type_segments = result['type_segments'] + self.segment_zs = result['segment_zs'] self.type_visibility = result['type_visibility'] self.layer_map = result['layer_map'] + self.segment_visibility = result['segment_visibility'] + + self.min_z = np.min(self.vertices[2::3]) if self.vertex_count > 0 else 0.0 + self.max_z = np.max(self.vertices[2::3]) if self.vertex_count > 0 else 0.0 self.vbo_ready = False # 初始时先不显示,让 update_by_filepos 决定,或者如果是0则自动更新 @@ -311,6 +382,7 @@ class GCodeViewerWidget(QOpenGLWidget): target = 0 if self.layer_map: target = self.layer_map[-1][1] # 全显 + self.layer_height = result.get('layer_height', 0.2) self.progress_vertices = target self.last_reported_filepos = -1 @@ -433,24 +505,88 @@ class GCodeViewerWidget(QOpenGLWidget): # 因此通过动态加粗像素级线宽来性能无损地模拟出“体积感”) for pass_idx in range(2): if pass_idx == 0: - gl.glLineWidth(6.0) # 底线宽度(加大以模拟体积轮廓) + if self.enable_lod: + gl.glLineWidth(3.0) # 底线宽度(加大以模拟体积轮廓) + else: + gl.glLineWidth(6.0) # 底线宽度(加大以模拟体积轮廓) self.shader_program.setUniformValue(self.uDarken_location, 0.8) # 加深颜色至 40% 亮度 else: - gl.glLineWidth(3.0) # 主体宽度(加大以模拟线条厚度) + if self.enable_lod: + gl.glLineWidth(1.5) # 主体宽度(加大以模拟线条厚度) + else: + gl.glLineWidth(3.0) # 主体宽度(加大以模拟线条厚度) self.shader_program.setUniformValue(self.uDarken_location, 1.0) # 保持原色 # 按类型分段绘制 + # for type_name, segments in self.type_segments.items(): + # if not self.type_visibility.get(type_name, True): + # continue + # for start, length in segments: + # if start >= self.progress_vertices: + # continue + # end = start + length + # visible_start = start + # visible_count = length + # if end > self.progress_vertices: + # visible_count = self.progress_vertices - start + # if visible_count > 0: + # gl.glDrawArrays(gl.GL_LINES, visible_start, visible_count) + + # 按类型分段绘制(带LOD) for type_name, segments in self.type_segments.items(): if not self.type_visibility.get(type_name, True): continue + # 不同类型的简化倍率 + simplify_mul = 1 + if type_name == 'FILL': + simplify_mul = 3 + elif type_name == 'SUPPORT': + simplify_mul = 4 + elif type_name == 'TRAVEL': + simplify_mul = 8 + # 根据缩放动态增强简化 + zoom_mul = max(1.0, abs(self.view_zoom) / 250.0) + # 最终步进 + lod_step = 1 + if self.enable_lod: + lod_step = int(max(1, self.lod_factor * simplify_mul * zoom_mul)) + seg_index = 0 for start, length in segments: + if not self.segment_visibility.get((start, length), True): + continue + if type_name in ('WALL-INNER', 'SUPPORT', 'FILL'): + seg_z = self.segment_zs.get((start, length), self.center_z) + top_limit = self.max_z - self.visible_top_layers * self.layer_height if self.visible_top_layers > 0 else self.min_z - 1.0 + bottom_limit = self.min_z + self.visible_bottom_layers * self.layer_height if self.visible_bottom_layers > 0 else self.max_z + 1.0 + if bottom_limit <= seg_z <= top_limit: + continue + + seg_index += 1 + # LOD抽样 + if lod_step > 1: + if (seg_index % lod_step) != 0: + continue if start >= self.progress_vertices: continue + end = start + length visible_start = start visible_count = length if end > self.progress_vertices: visible_count = self.progress_vertices - start + if visible_count <= 0: + continue + + # 超远距离时进一步减少绘制长度 + if self.enable_lod: + if type_name in ('FILL','SUPPORT','WALL-INNER'): + if abs(self.view_zoom) > 400: + if(start // 2) % 3 != 0: + continue + if abs(self.view_zoom) > 700: + if(start // 2) % 6 != 0: + continue + # visible_count = visible_count // 2 if visible_count > 0: gl.glDrawArrays(gl.GL_LINES, visible_start, visible_count) diff --git a/utils/gcode_viewer_deepseek.py b/utils/gcode_viewer_deepseek.py new file mode 100644 index 0000000..734e4fe --- /dev/null +++ b/utils/gcode_viewer_deepseek.py @@ -0,0 +1,601 @@ +import numpy as np +import math +import bisect +from PyQt6.QtOpenGLWidgets import QOpenGLWidget +from PyQt6.QtCore import Qt, QEvent, QThread, pyqtSignal +from PyQt6.QtGui import QTouchEvent, QSurfaceFormat, QMatrix4x4, QVector4D +from PyQt6.QtOpenGL import QOpenGLShaderProgram, QOpenGLShader, QOpenGLBuffer + +class GCodeParseWorker(QThread): + finished = pyqtSignal(dict) + + def __init__(self, filepath, type_map, default_colors, parent=None): + super().__init__(parent) + self.filepath = filepath + self.TYPE_MAP = type_map + self.DEFAULT_COLORS = default_colors + + def run(self): + points = [] + colors = [] + type_segments = {} + type_visibility = {'TRAVEL': False} + segment_types = [] # 每条线段对应的类型名称(长度为线段数) + + x = y = z = e = 0.0 + vertex_idx = 0 + feature_type = 'OTHER' + current_segment_type = 'OTHER' + segment_start = 0 + relative_e = False + + min_x = min_y = min_z = float('inf') + max_x = max_y = max_z = float('-inf') + + current_offset = 0 + max_z_seen = -999.0 + layer_map = [(0, 0)] + + lod_steps = [2, 4] + lod_levels = {1: None} + for s in lod_steps: + lod_levels[s] = {'points': [], 'colors': [], 'count': 0} + + grid_size = 10 + grid_segments = {} + + def add_segment(t_name, start, length): + if length > 0: + type_segments.setdefault(t_name, []).append((start, length)) + + try: + with open(self.filepath, 'rb') as f: + for line_bytes in f: + current_offset += len(line_bytes) + line = line_bytes.decode('utf-8', errors='ignore').strip() + if not line: + continue + if line.startswith('M82'): + relative_e = False + continue + if line.startswith('M83'): + relative_e = True + continue + + if line.startswith(';'): + if line.startswith(';TYPE:'): + raw_type = line.split(':', 1)[1].strip() + else: + raw_type = line[1:].strip() + if raw_type not in self.TYPE_MAP and not any(k in raw_type.lower() for k in ('perimeter', 'infill', 'material', 'skirt/brim')): + continue + if 'Skirt/Brim' in raw_type: + raw_type = 'Skirt' + new_type = self.TYPE_MAP.get(raw_type, 'OTHER') + if new_type != feature_type: + feature_type = new_type + if feature_type not in type_visibility: + type_visibility[feature_type] = True + continue + + if line.startswith(('G0', 'G1')): + new_x, new_y, new_z = x, y, z + e_val = 0.0 + has_e = False + parts = line.split(';')[0].split() + for p in parts[1:]: + try: + if p.startswith('X'): new_x = float(p[1:]) + elif p.startswith('Y'): new_y = float(p[1:]) + elif p.startswith('Z'): new_z = float(p[1:]) + elif p.startswith('E'): + e_val = float(p[1:]) + has_e = True + except ValueError: + pass + + if new_x == x and new_y == y and new_z == z: + if has_e: + if relative_e: e += e_val + else: e = e_val + continue + + is_extrusion = False + if has_e: + if relative_e: + is_extrusion = e_val > 0 + new_e = e + e_val + else: + is_extrusion = e_val > e + new_e = e_val + else: + new_e = e + + if is_extrusion: + seg_type = feature_type + c = self.DEFAULT_COLORS.get(feature_type, self.DEFAULT_COLORS['OTHER']) + else: + seg_type = 'TRAVEL' + c = self.DEFAULT_COLORS['TRAVEL'] + if 'TRAVEL' not in type_visibility: + type_visibility['TRAVEL'] = False + + # 记录当前线段的类型 + segment_types.append(seg_type) + + if seg_type != current_segment_type: + if vertex_idx > segment_start: + add_segment(current_segment_type, segment_start, vertex_idx - segment_start) + current_segment_type = seg_type + segment_start = vertex_idx + + if new_x < min_x: min_x = new_x + if new_x > max_x: max_x = new_x + if new_y < min_y: min_y = new_y + if new_y > max_y: max_y = new_y + if new_z < min_z: min_z = new_z + if new_z > max_z: max_z = new_z + + if new_z > max_z_seen and is_extrusion: + max_z_seen = new_z + layer_map.append((current_offset, vertex_idx)) + + points.extend([x, y, z, new_x, new_y, new_z]) + colors.extend([*c, *c]) + + for step in lod_steps: + if vertex_idx % (step * 2) == 0: + lod_levels[step]['points'].extend([x, y, z, new_x, new_y, new_z]) + lod_levels[step]['colors'].extend([*c, *c]) + lod_levels[step]['count'] += 2 + + vertex_idx += 2 + x, y, z, e = new_x, new_y, new_z, new_e + + elif line.startswith('G92'): + parts = line.split(';')[0].split() + for p in parts[1:]: + try: + if p.startswith('E'): e = float(p[1:]) + elif p.startswith('X'): x = float(p[1:]) + elif p.startswith('Y'): y = float(p[1:]) + elif p.startswith('Z'): z = float(p[1:]) + except ValueError: + pass + + if vertex_idx > segment_start: + add_segment(current_segment_type, segment_start, vertex_idx - segment_start) + + cx = (min_x + max_x) / 2.0 if max_x >= min_x else 110.0 + cy = (min_y + max_y) / 2.0 if max_y >= min_y else 110.0 + cz = (min_z + max_z) / 2.0 if max_z >= min_z else 0.0 + + if vertex_idx > 0 and max_x > min_x and max_y > min_y: + dx = (max_x - min_x) / grid_size + dy = (max_y - min_y) / grid_size + for i in range(grid_size): + for j in range(grid_size): + grid_segments[(i, j)] = [] + # 填充网格,同时每个格子存储 (线段起始顶点索引, 类型) + for seg_idx in range(0, vertex_idx, 2): + px = points[seg_idx * 3] + py = points[seg_idx * 3 + 1] + gx = min(int((px - min_x) / dx), grid_size - 1) + gy = min(int((py - min_y) / dy), grid_size - 1) + seg_type = segment_types[seg_idx // 2] + grid_segments[(gx, gy)].append((seg_idx, seg_type)) + + lod_levels[1] = {'points': points, 'colors': colors, 'count': vertex_idx} + + result = { + 'lod_levels': lod_levels, + 'vertex_count': vertex_idx, + 'center_x': cx, + 'center_y': cy, + 'center_z': cz, + 'type_segments': type_segments, + 'type_visibility': type_visibility, + 'layer_map': layer_map, + 'grid_segments': grid_segments, + 'grid_size': grid_size, + 'bbox': (min_x, max_x, min_y, max_y, min_z, max_z) + } + self.finished.emit(result) + except Exception as e: + print("ParseGCode Error:", e) + self.finished.emit({}) + + +class GCodeViewerWidget(QOpenGLWidget): + TYPE_MAP = { + 'External perimeter': 'WALL-OUTER', + 'Perimeter': 'WALL-INNER', + 'Overhang perimeter': 'WALL-OUTER', + 'Solid infill': 'SKIN', + 'Top solid infill': 'SKIN', + 'Bridge infill': 'SKIN', + 'Internal infill': 'FILL', + 'Support material': 'SUPPORT', + 'Support material interface': 'SUPPORT-INTERFACE', + 'Skirt': 'SKIRT', + 'Brim': 'SKIRT', + 'Custom': 'OTHER', + } + + DEFAULT_COLORS = { + 'WALL-OUTER': (0.92, 0.55, 0.22), + 'WALL-INNER': (0.25, 0.50, 0.81), + 'FILL': (0.80, 0.75, 0.29), + 'SKIN': (0.62, 0.38, 0.70), + 'SUPPORT': (0.34, 0.70, 0.34), + 'SUPPORT-INTERFACE': (0.17, 0.42, 0.17), + 'SKIRT': (0.00, 1.00, 1.00), + 'OTHER': (0.67, 0.67, 0.67), + 'TRAVEL': (0.25, 0.31, 0.38), + } + + VERTEX_SHADER = """ + attribute vec3 aPos; + attribute vec3 aColor; + varying vec3 vColor; + uniform mat4 uMVP; + void main() { + gl_Position = uMVP * vec4(aPos, 1.0); + vColor = aColor; + } + """ + + FRAGMENT_SHADER = """ + precision mediump float; + varying vec3 vColor; + uniform float uDarken; + void main() { + gl_FragColor = vec4(vColor * uDarken, 1.0); + } + """ + + def __init__(self, parent=None): + fmt = QSurfaceFormat() + fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGLES) + fmt.setVersion(2, 0) + super().__init__(parent) + self.setMinimumSize(400, 300) + self.setAttribute(Qt.WidgetAttribute.WA_AcceptTouchEvents, True) + self.setFormat(fmt) + + self.lod_levels = {} + self.vbos = {} + self.vertex_count = 0 + + self.type_segments = {} + self.type_visibility = {} + + self.view_rot_x = -60.0 + self.view_rot_z = 45.0 + self.view_zoom = -250.0 + self.view_trans_x = 0.0 + self.view_trans_y = 0.0 + + self.progress_vertices = 0 + self.layer_map = [(0, 0)] + + self.center_x = 110.0 + self.center_y = 110.0 + self.center_z = 0.0 + + self.grid_segments = {} # 格子 -> [(seg_idx, type_name)] + self.grid_size = 10 + self.bbox = (0, 0, 0, 0, 0, 0) + + self.last_mouse_pos = None + self._touch_points = {} + self._pinch_start_dist = 0.0 + self._pinch_start_zoom = 0.0 + self._pinch_start_center = None + self._pinch_start_trans = (0.0, 0.0) + self._ignore_wheel = False + self._rot_sensitivity = 0.1 + + self.shader_program = None + self.aPos_location = None + self.aColor_location = None + self.uMVP_location = None + self.uDarken_location = None + + def load_gcode(self, filepath: str): + if hasattr(self, '_worker') and self._worker.isRunning(): + self._worker.terminate() + self._worker.wait() + self._worker = GCodeParseWorker(filepath, self.TYPE_MAP, self.DEFAULT_COLORS) + self._worker.finished.connect(self._on_parse_finished) + self._worker.start() + + def _on_parse_finished(self, result: dict): + if not result: + return + self.lod_levels = result['lod_levels'] + self.vertex_count = result['vertex_count'] + self.center_x = result['center_x'] + self.center_y = result['center_y'] + self.center_z = result['center_z'] + self.type_segments = result['type_segments'] + self.type_visibility = result['type_visibility'] + self.layer_map = result['layer_map'] + self.grid_segments = result['grid_segments'] + self.grid_size = result['grid_size'] + self.bbox = result['bbox'] + self.progress_vertices = self.vertex_count + self.vbos.clear() + self.update() + + def update_by_filepos(self, filepos: int, is_printing: bool = True): + if not self.layer_map: + return + if not is_printing: + target_vertices = self.vertex_count + else: + keys = [item[0] for item in self.layer_map] + idx = bisect.bisect_right(keys, filepos) + target_vertices = 0 if idx == 0 else self.layer_map[idx-1][1] + if target_vertices != self.progress_vertices: + self.progress_vertices = target_vertices + self.update() + + def update_switch(self, type_name: str, visible: bool): + if type_name in self.type_visibility: + self.type_visibility[type_name] = visible + self.update() + + def set_view_angles(self, rot_x: float, rot_z: float, zoom: float = None): + self.view_rot_x = max(-90.0, min(0.0, rot_x)) + self.view_rot_z = rot_z + if zoom is not None: + self.view_zoom = zoom + self.update() + + def initializeGL(self): + import OpenGL.GL as gl + gl.glClearColor(0.15, 0.15, 0.15, 1.0) + gl.glEnable(gl.GL_DEPTH_TEST) + self.shader_program = QOpenGLShaderProgram() + self.shader_program.addShaderFromSourceCode(QOpenGLShader.ShaderTypeBit.Vertex, self.VERTEX_SHADER) + self.shader_program.addShaderFromSourceCode(QOpenGLShader.ShaderTypeBit.Fragment, self.FRAGMENT_SHADER) + self.shader_program.link() + self.aPos_location = self.shader_program.attributeLocation("aPos") + self.aColor_location = self.shader_program.attributeLocation("aColor") + self.uMVP_location = self.shader_program.uniformLocation("uMVP") + self.uDarken_location = self.shader_program.uniformLocation("uDarken") + + def resizeGL(self, w, h): + import OpenGL.GL as gl + gl.glViewport(0, 0, w, h) + + def _build_mvp(self): + mat = QMatrix4x4() + aspect = self.width() / self.height() if self.height() else 1 + mat.perspective(45.0, aspect, 1.0, 1000.0) + mat.translate(self.view_trans_x, self.view_trans_y, self.view_zoom) + mat.rotate(self.view_rot_x, 1.0, 0.0, 0.0) + mat.rotate(self.view_rot_z, 0.0, 0.0, 1.0) + mat.translate(-self.center_x, -self.center_y, -self.center_z) + return mat + + def _is_tile_visible(self, gx, gy, mvp_mat): + min_x, max_x, min_y, max_y, min_z, max_z = self.bbox + if max_x <= min_x or max_y <= min_y: + return True + dx = (max_x - min_x) / self.grid_size + dy = (max_y - min_y) / self.grid_size + cx = min_x + dx * (gx + 0.5) + cy = min_y + dy * (gy + 0.5) + cz = (min_z + max_z) * 0.5 + v = mvp_mat * QVector4D(cx, cy, cz, 1.0) + if v.w() == 0: + return False + ndc_x = v.x() / v.w() + ndc_y = v.y() / v.w() + ndc_z = v.z() / v.w() + return -1.5 <= ndc_x <= 1.5 and -1.5 <= ndc_y <= 1.5 and -2.0 <= ndc_z <= 2.0 + + def paintGL(self): + import OpenGL.GL as gl + gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT) + + if self.vertex_count == 0: + return + + actual_display_verts = min(self.vertex_count, self.progress_vertices) + + if actual_display_verts > 500000: + desired_step = 4 + elif actual_display_verts > 200000: + desired_step = 2 + else: + desired_step = 1 + + if abs(self.view_zoom) > 600 and desired_step < 4: + desired_step = 4 + elif abs(self.view_zoom) > 300 and desired_step < 2: + desired_step = 2 + + if desired_step not in self.vbos: + self._create_vbo_for_step(desired_step) + + vbo_vertices, vbo_colors, vbo_count = self.vbos[desired_step] + if vbo_count == 0: + return + + scale = desired_step * 2 + lod_progress = actual_display_verts // scale + if lod_progress > vbo_count: + lod_progress = vbo_count + + self.shader_program.bind() + mvp_mat = self._build_mvp() + self.shader_program.setUniformValue(self.uMVP_location, mvp_mat) + + vbo_vertices.bind() + self.shader_program.setAttributeBuffer(self.aPos_location, gl.GL_FLOAT, 0, 3, 0) + self.shader_program.enableAttributeArray(self.aPos_location) + + vbo_colors.bind() + self.shader_program.setAttributeBuffer(self.aColor_location, gl.GL_FLOAT, 0, 3, 0) + self.shader_program.enableAttributeArray(self.aColor_location) + + gl.glDepthFunc(gl.GL_LEQUAL) + + use_culling = (desired_step == 1 and actual_display_verts > 100000 and self.grid_segments) + + if use_culling: + # 空间剔除分支:现在每个格子条目是 (seg_idx, type_name) + for pass_idx in range(2): + if pass_idx == 0: + gl.glLineWidth(6.0) + self.shader_program.setUniformValue(self.uDarken_location, 0.8) + else: + gl.glLineWidth(3.0) + self.shader_program.setUniformValue(self.uDarken_location, 1.0) + + for (gx, gy), seg_entries in self.grid_segments.items(): + if not self._is_tile_visible(gx, gy, mvp_mat): + continue + for seg_idx, seg_type in seg_entries: + # 跳过不可见类型 + if not self.type_visibility.get(seg_type, True): + continue + if seg_idx < actual_display_verts: + gl.glDrawArrays(gl.GL_LINES, seg_idx, 2) + else: + # 普通渲染分支 + for pass_idx in range(2): + if pass_idx == 0: + gl.glLineWidth(6.0) + self.shader_program.setUniformValue(self.uDarken_location, 0.8) + else: + gl.glLineWidth(3.0) + self.shader_program.setUniformValue(self.uDarken_location, 1.0) + + for type_name, segments in self.type_segments.items(): + if not self.type_visibility.get(type_name, True): + continue + for start, length in segments: + lod_start = start // scale + lod_count = length // scale + if lod_start >= vbo_count: + continue + end = min(lod_start + lod_count, lod_progress) + actual_count = end - lod_start + if actual_count > 0: + gl.glDrawArrays(gl.GL_LINES, lod_start, actual_count) + + gl.glDepthFunc(gl.GL_LESS) + self.shader_program.disableAttributeArray(self.aPos_location) + self.shader_program.disableAttributeArray(self.aColor_location) + vbo_vertices.release() + vbo_colors.release() + self.shader_program.release() + + def _create_vbo_for_step(self, step): + import OpenGL.GL as gl + lod_data = self.lod_levels.get(step) + if not lod_data: + lod_data = self.lod_levels[1] + step = 1 + + verts = np.array(lod_data['points'], dtype=np.float32) + cols = np.array(lod_data['colors'], dtype=np.float32) + + vbo_v = QOpenGLBuffer(QOpenGLBuffer.Type.VertexBuffer) + vbo_c = QOpenGLBuffer(QOpenGLBuffer.Type.VertexBuffer) + vbo_v.create() + vbo_v.bind() + vbo_v.allocate(verts.tobytes(), verts.nbytes) + vbo_v.release() + vbo_c.create() + vbo_c.bind() + vbo_c.allocate(cols.tobytes(), cols.nbytes) + vbo_c.release() + + self.vbos[step] = (vbo_v, vbo_c, lod_data['count']) + + # ── 交互部分保持不变 ── + def mousePressEvent(self, event): + self.last_mouse_pos = event.position() + + def mouseMoveEvent(self, event): + if self.last_mouse_pos is None: + return + dx = event.position().x() - self.last_mouse_pos.x() + dy = event.position().y() - self.last_mouse_pos.y() + if event.buttons() & Qt.MouseButton.LeftButton: + self.view_rot_x += dy * self._rot_sensitivity + self.view_rot_x = max(-90.0, min(0.0, self.view_rot_x)) + self.view_rot_z += dx * self._rot_sensitivity + self.last_mouse_pos = event.position() + self.update() + + def wheelEvent(self, event): + delta = event.angleDelta().y() / 120 + self.view_zoom += delta * 10 + self.update() + + def event(self, e): + if e.type() in (QEvent.Type.TouchBegin, QEvent.Type.TouchUpdate, QEvent.Type.TouchEnd): + self._ignore_wheel = True + self.touchEvent(e) + return True + elif e.type() == QEvent.Type.Wheel: + if self._ignore_wheel: + self._ignore_wheel = False + return True + return super().event(e) + + def touchEvent(self, event: QTouchEvent): + points = event.points() + if not points: + return + if event.type() == QEvent.Type.TouchEnd: + self._touch_points.clear() + self._pinch_start_center = None + self._pinch_start_dist = 0.0 + event.accept() + return + if len(points) == 1: + p = points[0] + if p.id() not in self._touch_points or self._pinch_start_center is not None: + self._touch_points.clear() + self._touch_points[p.id()] = p.position() + self._pinch_start_center = None + else: + last = self._touch_points[p.id()] + dx = p.position().x() - last.x() + dy = p.position().y() - last.y() + self.view_rot_x += dy * self._rot_sensitivity + self.view_rot_x = max(-90.0, min(0.0, self.view_rot_x)) + self.view_rot_z += dx * self._rot_sensitivity + self._touch_points[p.id()] = p.position() + self.update() + elif len(points) == 2: + p1, p2 = points[0], points[1] + dx_p = p1.position().x() - p2.position().x() + dy_p = p1.position().y() - p2.position().y() + dist = math.hypot(dx_p, dy_p) + center_x = (p1.position().x() + p2.position().x()) / 2.0 + center_y = (p1.position().y() + p2.position().y()) / 2.0 + if self._pinch_start_center is None: + self._pinch_start_dist = dist + self._pinch_start_zoom = self.view_zoom + self._pinch_start_center = (center_x, center_y) + self._pinch_start_trans = (self.view_trans_x, self.view_trans_y) + self._touch_points.clear() + else: + if self._pinch_start_dist > 0: + scale = dist / self._pinch_start_dist + self.view_zoom = self._pinch_start_zoom * (1 / scale) + dcx = center_x - self._pinch_start_center[0] + dcy = center_y - self._pinch_start_center[1] + pan_speed = abs(self.view_zoom) * 0.002 + self.view_trans_x = self._pinch_start_trans[0] + dcx * pan_speed + self.view_trans_y = self._pinch_start_trans[1] - dcy * pan_speed + self.update() + event.accept() \ No newline at end of file diff --git a/utils/gcode_viewer_old.py b/utils/gcode_viewer_old.py new file mode 100644 index 0000000..275fe2a --- /dev/null +++ b/utils/gcode_viewer_old.py @@ -0,0 +1,572 @@ +import numpy as np +from PyQt6.QtOpenGLWidgets import QOpenGLWidget +from PyQt6.QtCore import Qt, QEvent, QThread, pyqtSignal +from PyQt6.QtGui import QTouchEvent, QSurfaceFormat +from PyQt6.QtOpenGL import QOpenGLShaderProgram, QOpenGLShader, QOpenGLBuffer + +class GCodeParseWorker(QThread): + finished = pyqtSignal(dict) + + def __init__(self, filepath, type_map, default_colors, parent=None): + super().__init__(parent) + self.filepath = filepath + self.TYPE_MAP = type_map + self.DEFAULT_COLORS = default_colors + + def run(self): + points = [] + colors = [] + type_segments = {} + type_visibility = {} + + x = y = z = e = 0.0 + vertex_idx = 0 + feature_type = 'OTHER' + current_segment_type = 'OTHER' + segment_start = 0 + type_visibility['TRAVEL'] = False + relative_e = False + + min_x = min_y = min_z = float('inf') + max_x = max_y = max_z = float('-inf') + + current_offset = 0 + max_z_seen = -999.0 + layer_map = [(0, 0)] + + def add_segment(t_name, start, length): + if length > 0: + type_segments.setdefault(t_name, []).append((start, length)) + + try: + with open(self.filepath, 'rb') as f: + for line_bytes in f: + current_offset += len(line_bytes) + line = line_bytes.decode('utf-8', errors='ignore').strip() + if not line: + continue + if line.startswith('M82'): + relative_e = False + continue + if line.startswith('M83'): + relative_e = True + continue + + if line.startswith(';'): + if line.startswith(';TYPE:'): + raw_type = line.split(':', 1)[1].strip() + else: + raw_type = line[1:].strip() + if raw_type not in self.TYPE_MAP and not any(k in raw_type.lower() for k in ('perimeter', 'infill', 'material', 'skirt/brim')): + continue + if 'Skirt/Brim' in raw_type: + raw_type = 'Skirt' + + new_type = self.TYPE_MAP.get(raw_type, 'OTHER') + if new_type != feature_type: + feature_type = new_type + if feature_type not in type_visibility: + type_visibility[feature_type] = True + continue + + if line.startswith(('G0', 'G1')): + new_x, new_y, new_z = x, y, z + e_val = 0.0 + has_e = False + parts = line.split(';')[0].split() + for p in parts[1:]: + try: + if p.startswith('X'): new_x = float(p[1:]) + elif p.startswith('Y'): new_y = float(p[1:]) + elif p.startswith('Z'): new_z = float(p[1:]) + elif p.startswith('E'): + e_val = float(p[1:]) + has_e = True + except ValueError: + pass + + if new_x == x and new_y == y and new_z == z: + if has_e: + if relative_e: e += e_val + else: e = e_val + continue + + is_extrusion = False + if has_e: + if relative_e: + is_extrusion = e_val > 0 + new_e = e + e_val + else: + is_extrusion = e_val > e + new_e = e_val + else: + new_e = e + + if is_extrusion: + seg_type = feature_type + c = self.DEFAULT_COLORS.get(feature_type, self.DEFAULT_COLORS['OTHER']) + else: + seg_type = 'TRAVEL' + c = self.DEFAULT_COLORS['TRAVEL'] + if 'TRAVEL' not in type_visibility: + type_visibility['TRAVEL'] = False + + if seg_type != current_segment_type: + if vertex_idx > segment_start: + add_segment(current_segment_type, segment_start, vertex_idx - segment_start) + current_segment_type = seg_type + segment_start = vertex_idx + + if new_x < min_x: min_x = new_x + if new_x > max_x: max_x = new_x + if new_y < min_y: min_y = new_y + if new_y > max_y: max_y = new_y + if new_z < min_z: min_z = new_z + if new_z > max_z: max_z = new_z + + if new_z > max_z_seen and is_extrusion: + max_z_seen = new_z + layer_map.append((current_offset, vertex_idx)) + + points.extend([x, y, z, new_x, new_y, new_z]) + colors.extend([*c, *c]) + vertex_idx += 2 + x, y, z, e = new_x, new_y, new_z, new_e + elif line.startswith('G92'): + parts = line.split(';')[0].split() + for p in parts[1:]: + try: + if p.startswith('E'): e = float(p[1:]) + elif p.startswith('X'): x = float(p[1:]) + elif p.startswith('Y'): y = float(p[1:]) + elif p.startswith('Z'): z = float(p[1:]) + except ValueError: + pass + + if vertex_idx > segment_start: + add_segment(current_segment_type, segment_start, vertex_idx - segment_start) + + cx = (min_x + max_x) / 2.0 if max_x >= min_x else 110.0 + cy = (min_y + max_y) / 2.0 if max_y >= min_y else 110.0 + cz = (min_z + max_z) / 2.0 if max_z >= min_z else 0.0 + + result = { + 'vertices': np.array(points, dtype=np.float32) if vertex_idx > 0 else np.zeros((0,), dtype=np.float32), + 'colors': np.array(colors, dtype=np.float32) if vertex_idx > 0 else np.zeros((0,), dtype=np.float32), + 'vertex_count': vertex_idx, + 'center_x': cx, + 'center_y': cy, + 'center_z': cz, + 'type_segments': type_segments, + 'type_visibility': type_visibility, + 'layer_map': layer_map + } + self.finished.emit(result) + except Exception as e: + print("ParseGCode Error:", e) + self.finished.emit({}) + +class GCodeViewerWidget(QOpenGLWidget): + """ + 3D G-code 预览控件(OpenGL ES / eglfs 兼容版) + 使用可编程管线替代固定管线,适配树莓派 4B + """ + + # PrusaSlicer 类型映射、颜色定义等保持不变 + TYPE_MAP = { + 'External perimeter': 'WALL-OUTER', + 'Perimeter': 'WALL-INNER', + 'Overhang perimeter': 'WALL-OUTER', + 'Solid infill': 'SKIN', + 'Top solid infill': 'SKIN', + 'Bridge infill': 'SKIN', + 'Internal infill': 'FILL', + 'Support material': 'SUPPORT', + 'Support material interface': 'SUPPORT-INTERFACE', + 'Skirt': 'SKIRT', + 'Brim': 'SKIRT', + 'Custom': 'OTHER', + } + + DEFAULT_COLORS = { + 'WALL-OUTER': (0.92, 0.55, 0.22), # 0xeb8b38 + 'WALL-INNER': (0.25, 0.50, 0.81), # 0x4080cf + 'FILL': (0.80, 0.75, 0.29), # 0xccc04b + 'SKIN': (0.62, 0.38, 0.70), # 0x9e60b3 + 'SUPPORT': (0.34, 0.70, 0.34), # 0x57b357 + 'SUPPORT-INTERFACE': (0.17, 0.42, 0.17), # 0x2b6b2b + 'SKIRT': (0.00, 1.00, 1.00), # 0x00ffff + 'OTHER': (0.67, 0.67, 0.67), # 0xaaaaaa + 'TRAVEL': (0.25, 0.31, 0.38), # 0x405060 + } + + # 顶点着色器(GLSL ES 1.00) + VERTEX_SHADER = """ + attribute vec3 aPos; + attribute vec3 aColor; + varying vec3 vColor; + uniform mat4 uMVP; + void main() { + gl_Position = uMVP * vec4(aPos, 1.0); + vColor = aColor; + } + """ + + # 片段着色器(添加可调节颜色深度的 uniform 以实现边界加深) + FRAGMENT_SHADER = """ + precision mediump float; + varying vec3 vColor; + uniform float uDarken; + void main() { + gl_FragColor = vec4(vColor * uDarken, 1.0); + } + """ + + def __init__(self, parent=None): + # 请求 OpenGL ES 2.0 上下文 + fmt = QSurfaceFormat() + fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGLES) + fmt.setVersion(2, 0) + + super().__init__(parent) + self.setMinimumSize(400, 300) + self.setAttribute(Qt.WidgetAttribute.WA_AcceptTouchEvents, True) + self.setFormat(fmt) + # 数据缓冲区 + self.vertices = None + self.colors = None + self.vertex_count = 0 + self.vbo_vertices = None + self.vbo_colors = None + self.vbo_ready = False + + # 类型分段 + self.type_segments = {} + self.type_visibility = {} + self.current_type = 'OTHER' + + # 视角参数 + self.view_rot_x = -60.0 + self.view_rot_z = 45.0 + self.view_zoom = -250.0 # 稍微退后一点以便看到整个打印平台 + self.view_trans_x = 0.0 + self.view_trans_y = 0.0 + + # ... + self.progress_ratio = 1.0 + self.progress_vertices = 0 + + # 按需渲染:缓存当前的 filepos 对应的顶点数 + self.layer_map = [(0, 0)] # (offset_bytes, vertex_idx) + self.last_reported_filepos = -1 + + # 模型中心点 + self.center_x = 110.0 + self.center_y = 110.0 + self.center_z = 0.0 + + # 触摸状态 + self.last_mouse_pos = None + self._touch_points = {} + self._pinch_start_dist = 0.0 + self._pinch_start_zoom = 0.0 + self._pinch_start_center = None + self._pinch_start_trans = (0.0, 0.0) + self._ignore_wheel = False + self._rot_sensitivity = 0.1 + + # 着色器程序 + self.shader_program = None + self.aPos_location = None + self.aColor_location = None + self.uMVP_location = None + + # ── 公开接口 ── + def load_gcode(self, filepath: str): + if hasattr(self, '_worker') and self._worker.isRunning(): + self._worker.terminate() + self._worker.wait() + + self._worker = GCodeParseWorker(filepath, self.TYPE_MAP, self.DEFAULT_COLORS) + self._worker.finished.connect(self._on_parse_finished) + self._worker.start() + + def _on_parse_finished(self, result: dict): + if not result: + return + + self.vertices = result['vertices'] + self.colors = result['colors'] + self.vertex_count = result['vertex_count'] + self.center_x = result['center_x'] + self.center_y = result['center_y'] + self.center_z = result['center_z'] + self.type_segments = result['type_segments'] + self.type_visibility = result['type_visibility'] + self.layer_map = result['layer_map'] + + self.vbo_ready = False + # 初始时先不显示,让 update_by_filepos 决定,或者如果是0则自动更新 + # 这里把 progress_vertices 赋予当前的 target + target = 0 + if self.layer_map: + target = self.layer_map[-1][1] # 全显 + + self.progress_vertices = target + self.last_reported_filepos = -1 + self.update() + + def update_processes(self, progress: float): + pass + + def update_by_filepos(self, filepos: int, is_printing: bool = True): + import bisect + if not hasattr(self, 'layer_map') or not self.layer_map: + return + + if not is_printing: + # 不在打印途中时,渲染包含所有顶点(完整模型) + target_vertices = self.vertex_count + else: + # 使用二分查找快速定位当前 filepos 对应的最多展示顶点数 + keys = [item[0] for item in self.layer_map] + idx = bisect.bisect_right(keys, filepos) + target_vertices = 0 if idx == 0 else self.layer_map[idx-1][1] + + # 按需渲染:只有当层级(计算出的可见顶点数)真正发生跳变时才调用 update() + if target_vertices != getattr(self, 'progress_vertices', -1): + self.progress_vertices = target_vertices + self.update() + + def update_switch(self, type_name: str, visible: bool): + if type_name in self.type_visibility: + self.type_visibility[type_name] = visible + self.update() + + def set_view_angles(self, rot_x: float, rot_z: float, zoom: float = None): + self.view_rot_x = max(-90.0, min(0.0, rot_x)) + self.view_rot_z = rot_z + if zoom is not None: + self.view_zoom = zoom + self.update() + + if length == 0: + return + self.type_segments.setdefault(type_name, []).append((start, length)) + + # ── OpenGL 可编程管线初始化 ── + def initializeGL(self): + import OpenGL.GL as gl + gl.glClearColor(0.15, 0.15, 0.15, 1.0) + gl.glEnable(gl.GL_DEPTH_TEST) + gl.glLineWidth(1.0) + + # 编译着色器 + self.shader_program = QOpenGLShaderProgram() + self.shader_program.addShaderFromSourceCode(QOpenGLShader.ShaderTypeBit.Vertex, self.VERTEX_SHADER) + self.shader_program.addShaderFromSourceCode(QOpenGLShader.ShaderTypeBit.Fragment, self.FRAGMENT_SHADER) + self.shader_program.link() + + # 获取属性和 uniform 位置 + self.aPos_location = self.shader_program.attributeLocation("aPos") + self.aColor_location = self.shader_program.attributeLocation("aColor") + self.uMVP_location = self.shader_program.uniformLocation("uMVP") + self.uDarken_location = self.shader_program.uniformLocation("uDarken") + + # 创建缓冲对象 + self.vbo_vertices = QOpenGLBuffer(QOpenGLBuffer.Type.VertexBuffer) + self.vbo_colors = QOpenGLBuffer(QOpenGLBuffer.Type.VertexBuffer) + + def resizeGL(self, w, h): + import OpenGL.GL as gl + gl.glViewport(0, 0, w, h) + + # ── 构建 MVP 矩阵(替代 glOrtho/glRotate) ── + def _build_mvp(self): + from PyQt6.QtGui import QMatrix4x4 + mat = QMatrix4x4() + aspect = self.width() / self.height() if self.height() else 1 + mat.perspective(45.0, aspect, 1.0, 1000.0) + + mat.translate(self.view_trans_x, self.view_trans_y, self.view_zoom) + mat.rotate(self.view_rot_x, 1.0, 0.0, 0.0) + mat.rotate(self.view_rot_z, 0.0, 0.0, 1.0) + mat.translate(-self.center_x, -self.center_y, -self.center_z) + + return mat + + # ── 渲染 ── + def paintGL(self): + import OpenGL.GL as gl + gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT) + + if self.vertices is None or self.vertex_count == 0: + return + + if not self.vbo_ready: + self._create_vbos() + self.vbo_ready = True + + # 使用着色器程序 + self.shader_program.bind() + + # 设置 MVP 矩阵 + import math + from PyQt6.QtGui import QMatrix4x4 + mvp_mat = self._build_mvp() + self.shader_program.setUniformValue(self.uMVP_location, mvp_mat) + + # 绑定 VBO + self.vbo_vertices.bind() + self.shader_program.setAttributeBuffer(self.aPos_location, gl.GL_FLOAT, 0, 3, 0) + self.shader_program.enableAttributeArray(self.aPos_location) + + self.vbo_colors.bind() + self.shader_program.setAttributeBuffer(self.aColor_location, gl.GL_FLOAT, 0, 3, 0) + self.shader_program.enableAttributeArray(self.aColor_location) + + # 允许 z-fighting 覆盖,用于同一位置多次渲染线条 + gl.glDepthFunc(gl.GL_LEQUAL) + + # 渲染两次:一次绘制加粗加深的边界底线,一次绘制正常宽度的原色骨架线 + # (在树莓派等性能有限的平台上,使用真实的3D圆柱/方块代替线条会导致顶点数暴增十几倍直接卡顿, + # 因此通过动态加粗像素级线宽来性能无损地模拟出“体积感”) + for pass_idx in range(2): + if pass_idx == 0: + gl.glLineWidth(6.0) # 底线宽度(加大以模拟体积轮廓) + self.shader_program.setUniformValue(self.uDarken_location, 0.8) # 加深颜色至 40% 亮度 + else: + gl.glLineWidth(3.0) # 主体宽度(加大以模拟线条厚度) + self.shader_program.setUniformValue(self.uDarken_location, 1.0) # 保持原色 + + # 按类型分段绘制 + for type_name, segments in self.type_segments.items(): + if not self.type_visibility.get(type_name, True): + continue + for start, length in segments: + if start >= self.progress_vertices: + continue + end = start + length + visible_start = start + visible_count = length + if end > self.progress_vertices: + visible_count = self.progress_vertices - start + if visible_count > 0: + gl.glDrawArrays(gl.GL_LINES, visible_start, visible_count) + + # 恢复默认深度测试模式 + gl.glDepthFunc(gl.GL_LESS) + + self.shader_program.disableAttributeArray(self.aPos_location) + self.shader_program.disableAttributeArray(self.aColor_location) + self.vbo_vertices.release() + self.vbo_colors.release() + self.shader_program.release() + + def _create_vbos(self): + if self.vbo_vertices.isCreated(): + self.vbo_vertices.destroy() + if self.vbo_colors.isCreated(): + self.vbo_colors.destroy() + + self.vbo_vertices.create() + self.vbo_vertices.bind() + self.vbo_vertices.allocate(self.vertices.tobytes(), self.vertices.nbytes) + self.vbo_vertices.release() + + self.vbo_colors.create() + self.vbo_colors.bind() + self.vbo_colors.allocate(self.colors.tobytes(), self.colors.nbytes) + self.vbo_colors.release() + + # ── 触摸/鼠标交互(完全不变) ── + def mousePressEvent(self, event): + self.last_mouse_pos = event.position() + + def mouseMoveEvent(self, event): + if self.last_mouse_pos is None: + return + dx = event.position().x() - self.last_mouse_pos.x() + dy = event.position().y() - self.last_mouse_pos.y() + if event.buttons() & Qt.MouseButton.LeftButton: + self.view_rot_x += dy * self._rot_sensitivity + self.view_rot_x = max(-90.0, min(0.0, self.view_rot_x)) # 限制垂直视角的翻转 + self.view_rot_z += dx * self._rot_sensitivity + self.last_mouse_pos = event.position() + self.update() + + def wheelEvent(self, event): + delta = event.angleDelta().y() / 120 + self.view_zoom += delta * 10 + self.update() + + def event(self, e): + if e.type() in (QEvent.Type.TouchBegin, QEvent.Type.TouchUpdate, QEvent.Type.TouchEnd): + self._ignore_wheel = True + self.touchEvent(e) + return True + elif e.type() == QEvent.Type.Wheel: + if self._ignore_wheel: + self._ignore_wheel = False + return True + return super().event(e) + + def touchEvent(self, event: QTouchEvent): + points = event.points() + if not points: + return + + if event.type() == QEvent.Type.TouchEnd: + self._touch_points.clear() + self._pinch_start_center = None + self._pinch_start_dist = 0.0 + event.accept() + return + + if len(points) == 1: + p = points[0] + # 如果是刚检测到单指,或者从双指变回单指 + if p.id() not in self._touch_points or self._pinch_start_center is not None: + self._touch_points.clear() + self._touch_points[p.id()] = p.position() + self._pinch_start_center = None + else: + last = self._touch_points[p.id()] + dx = p.position().x() - last.x() + dy = p.position().y() - last.y() + self.view_rot_x += dy * self._rot_sensitivity + self.view_rot_x = max(-90.0, min(0.0, self.view_rot_x)) # 限制垂直视角的翻转 + self.view_rot_z += dx * self._rot_sensitivity + self._touch_points[p.id()] = p.position() + self.update() + + elif len(points) == 2: + p1, p2 = points[0], points[1] + dx_p = p1.position().x() - p2.position().x() + dy_p = p1.position().y() - p2.position().y() + dist = (dx_p**2 + dy_p**2)**0.5 + center_x = (p1.position().x() + p2.position().x()) / 2.0 + center_y = (p1.position().y() + p2.position().y()) / 2.0 + + # 初始化双指状态 + if self._pinch_start_center is None: + self._pinch_start_dist = dist + self._pinch_start_zoom = self.view_zoom + self._pinch_start_center = (center_x, center_y) + self._pinch_start_trans = (self.view_trans_x, self.view_trans_y) + self._touch_points.clear() # 清除单指记录 + else: + # 缩放 (双指捏合) + if self._pinch_start_dist > 0: + scale = dist / self._pinch_start_dist + self.view_zoom = self._pinch_start_zoom * (1 / scale) + + # 平移 (双指并行移动) + dcx = center_x - self._pinch_start_center[0] + dcy = center_y - self._pinch_start_center[1] + pan_speed = abs(self.view_zoom) * 0.002 + self.view_trans_x = self._pinch_start_trans[0] + dcx * pan_speed + self.view_trans_y = self._pinch_start_trans[1] - dcy * pan_speed + + self.update() + event.accept() \ No newline at end of file diff --git a/utils/modern_confirm.py b/utils/modern_confirm.py new file mode 100644 index 0000000..4fd1723 --- /dev/null +++ b/utils/modern_confirm.py @@ -0,0 +1,87 @@ +from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QWidget +from PyQt6.QtCore import Qt, QSize +from PyQt6.QtGui import QPixmap +from .get_bootstrap_icon import get_colored_pixmap, get_colored_icon + + +class ModernConfirmDialog(QDialog): + """符合项目深色圆角主题的现代确认对话框 + + 用法:dlg = ModernConfirmDialog(parent, title, message, icon_name) + if dlg.exec() == QDialog.DialogCode.Accepted: ... + """ + + def __init__(self, parent: QWidget, title: str, message: str, icon_name: str = "question-circle.svg", + confirm_text: str = "确定", cancel_text: str = "取消"): + super().__init__(parent) + self.setWindowTitle(title) + self.setModal(True) + self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False) + self.setFixedWidth(560) + + # 样式 + self.setStyleSheet( + """ + QDialog { background-color: #3f3f3f; border: 2px solid #646464; border-radius: 12px; } + QLabel#title { color: #f2f2f2; font-size: 22px; font-weight: 700; background-color: transparent;} + QLabel#message { color: #dcdcdc; font-size: 18px; background-color: transparent;} + QPushButton#confirm { min-width: 140px; min-height: 48px; font-size: 18px; font-weight: 700; color: #ffffff; background-color: #2f6f91; border: 2px solid #4a9fc8; border-radius: 10px; } + QPushButton#confirm:hover { background-color: #3a85b3; } + QPushButton#cancel { min-width: 120px; min-height: 48px; font-size: 18px; font-weight: 600; color: #f2f2f2; background-color: #555555; border: 2px solid #707070; border-radius: 10px; } + QPushButton#cancel:hover { background-color: #636363; } + """ + ) + + root = QVBoxLayout(self) + root.setContentsMargins(20, 20, 20, 20) + root.setSpacing(16) + + top_row = QHBoxLayout() + top_row.setSpacing(16) + + # 图标 + icon_label = QLabel() + icon_label.setStyleSheet("background-color: transparent;") + icon_pix = get_colored_pixmap(icon_name, "#4a9fc8", 64, 64) + if isinstance(icon_pix, QPixmap): + icon_label.setPixmap(icon_pix.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) + icon_label.setFixedSize(72, 72) + top_row.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignTop) + + # 文本列 + text_col = QVBoxLayout() + title_lbl = QLabel(title) + title_lbl.setObjectName("title") + text_col.addWidget(title_lbl) + + msg_lbl = QLabel(message) + msg_lbl.setObjectName("message") + msg_lbl.setWordWrap(True) + text_col.addWidget(msg_lbl) + + top_row.addLayout(text_col) + root.addLayout(top_row) + + # 按钮行 + btn_row = QHBoxLayout() + btn_row.addStretch() + + cancel_btn = QPushButton(cancel_text) + cancel_btn.setObjectName("cancel") + cancel_icon = get_colored_icon("x-circle.svg", "#f2f2f2", 20, 20) + if cancel_icon: + cancel_btn.setIcon(cancel_icon) + cancel_btn.setIconSize(QSize(18, 18)) + cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(cancel_btn) + + confirm_btn = QPushButton(confirm_text) + confirm_btn.setObjectName("confirm") + confirm_icon = get_colored_icon("check-circle.svg", "#ffffff", 20, 20) + if confirm_icon: + confirm_btn.setIcon(confirm_icon) + confirm_btn.setIconSize(QSize(18, 18)) + confirm_btn.clicked.connect(self.accept) + btn_row.addWidget(confirm_btn) + + root.addLayout(btn_row)