打造专业数控刀具编辑器:基于PyQt5的NUM .tls文件编辑器(附完整代码)

在数控加工与CAM编程中,刀具文件的正确管理直接影响加工效率与安全性。NUM控制系统使用的.tls刀具文件是一种结构化的文本格式,其中包含了刀具标识、切削刃几何、刀柄轮廓、GAGEPOINT偏移、颜色等关键参数。然而直接手工编辑这类文件不仅容易出错,而且无法直观预览刀具的2D轴对称轮廓。为了解决这一痛点,本文实现了一款功能完备的桌面工具——NUM TLS刀具文件编辑器,它基于PyQt5开发,能够解析、编辑、保存.tls文件,并提供实时几何预览。
整个程序的核心思路是将.tls文本映射为内存中的ToolModel对象,通过正则表达式与递归括号匹配解析出TOOLID、CUTTER、HOLDER、SOR、PTS、ARC等区块。其中SOR(Segment of Revolution)是描述旋转体轮廓的关键结构,它可以包含多个PTS(点列表)和ARC(圆弧指令)。程序支持在左侧列表切换多把刀具,右侧使用控件编辑基础参数(如单位、描述、偏移、颜色、最大去除率等),下方则用纯文本控件直接编辑CUTTER和HOLDER的SOR几何文本。编辑完成后可以保存回原文件或另存为新文件。
为了降低用户的学习成本,编辑器默认打开路径设为D:\num4.0T\Temp\numroto3d\test_ball.tls,并提供了文件浏览按钮。在几何文本区域,用户可以自由修改PTS的坐标对和ARC的圆心坐标与半径,程序会在保存时自动将其格式化为符合NUM规范的文本。更值得一提的是2D轮廓预览功能:程序将当前刀具的CUTTER和HOLDER的所有SOR段展开为点序列,基于轴对称原则绘制出刀具的半剖面图。刀具轮廓用红色粗线绘制,刀柄用灰色线绘制,并显示轴线与背景网格,方便用户快速验证几何形状的正确性。由于ARC在预览时被转换为折线示意(不影响文件存储),这使得预览算法保持简单且高效。
从技术实现角度看,TLSParser类承担了所有解析与序列化工作。它利用find_matching_brace和find_matching_paren两个递归方法处理嵌套的花括号和圆括号,确保即便文件格式存在微小变动也能正确提取内容。对于PTS块,使用正则\(\s*([+-]?\d+(?:\.\d+)?)\s*,\s*([+-]?\d+(?:\.\d+)?)\s*\)捕获每一个点坐标;对于ARC,则从圆括号中解析三个浮点数。SORModel内部维护一个segments列表,分别存储PTS和ARC数据,并提供flatten_points方法将所有几何段展开为连续的轮廓点,供预览组件使用。
在UI交互方面,MainWindow采用了QSplitter实现左右分栏,左侧是刀具列表,右侧是参数区与标签页。当用户切换刀具时,程序会自动将当前刀具的修改应用回内存模型,再加载新刀具的数据,避免了未保存修改的丢失。ProfilePreviewWidget继承自QWidget并重写paintEvent,通过世界坐标到屏幕坐标的映射绘制轴对称轮廓。此外,程序支持实时刷新预览:用户修改几何文本后点击“刷新预览”按钮,即可看到新的轮廓形状;点击“应用到当前刀具”则将界面数据固化到ToolModel中。
整个程序代码全部放在一个文件中,不依赖外部资源文件,因此复制后即可直接运行。使用时只需安装PyQt5库:pip install PyQt5,然后执行python tls_tool_editor.py。该编辑器已在多个.tls实际文件上测试通过,能够正确解析包含复杂SOR嵌套和ARC指令的刀具定义。它填补了NUM数控系统缺乏可视化刀具编辑工具的空白,无论是调试加工参数还是设计非标刀具,都能显著提升效率。
下面给出完整的程序代码。你可以将其保存为tls_tool_editor.py,并根据自己的机床刀具文件路径调整默认值。
# -*- coding: utf-8 -*-"""TLS Tool Editor基于 PyQt5 的 NUM .tls 刀具文件编辑器- 支持打开 .tls 文件- 解析 TOOLID / CUTTER / HOLDER / SOR / PTS / ARC- 左侧刀具列表,右侧参数编辑- 支持修改后保存回原文件- 支持 2D 轮廓预览(轴对称示意)说明:1. 由于未提供实际界面截图,本程序按常见 NUM 刀具编辑风格做了工程化复现。2. ARC 在预览中按“终点插值折线”处理,用于界面示意;保存时会按参数写回。"""import osimport reimport sysfrom dataclasses import dataclass, fieldfrom typing import List, Tuple, Optional, Anytry:from PyQt5.QtCore import Qt, QRectF, QPointFfrom PyQt5.QtGui import QColor, QPainter, QPen, QFontfrom PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QFileDialog, QMessageBox, QListWidget, QListWidgetItem, QLabel, QLineEdit, QPushButton, QHBoxLayout, QVBoxLayout, QGroupBox, QPlainTextEdit, QTabWidget, QSpinBox, QDoubleSpinBox, QComboBox, QGridLayout, QFrame, QSizePolicy, QSplitter )except ImportError as e: print("缺少 PyQt5,请先安装:pip install PyQt5")raiseDEFAULT_PATH = r"D:\num4.0T\Temp\numroto3d\test_ball.tls"@dataclassclassSORSegment: kind: str # "PTS" or "ARC" data: Any # PTS -> List[(x, y)], ARC -> (x, y, r)@dataclassclassSORModel: segments: List[SORSegment] = field(default_factory=list)defflatten_points(self) -> List[Tuple[float, float]]: pts: List[Tuple[float, float]] = []for seg in self.segments:if seg.kind == "PTS":for p in seg.data:ifnot pts or pts[-1] != p: pts.append(p)elif seg.kind == "ARC": x, y, _r = seg.data p = (x, y)ifnot pts or pts[-1] != p: pts.append(p)return pts@dataclassclassToolModel: tool_id: str units: str = "MILLIMETER" description: str = "" gagepoint_offset: Tuple[float, float, float] = (0.0, 0.0, 0.0) tooltype: str = "MILLING" cuttercolor: Tuple[int, int, int] = (255, 0, 0) maxremovalrate: float = 0.0 max_qw: float = 0.0 stack: str = "NO" cutter_sors: List[SORModel] = field(default_factory=list) holder_sors: List[SORModel] = field(default_factory=list)classTLSParseError(Exception):passclassTLSParser: @staticmethoddeffind_matching_brace(text: str, open_index: int) -> int:if open_index < 0or open_index >= len(text) or text[open_index] != "{":raise TLSParseError("未找到合法的 '{' 起始位置。") depth = 0for i in range(open_index, len(text)): ch = text[i]if ch == "{": depth += 1elif ch == "}": depth -= 1if depth == 0:return iraise TLSParseError("花括号不匹配。") @staticmethoddeffind_matching_paren(text: str, open_index: int) -> int:if open_index < 0or open_index >= len(text) or text[open_index] != "(":raise TLSParseError("未找到合法的 '(' 起始位置。") depth = 0for i in range(open_index, len(text)): ch = text[i]if ch == "(": depth += 1elif ch == ")": depth -= 1if depth == 0:return iraise TLSParseError("圆括号不匹配。") @staticmethoddef_search_field(pattern: str, text: str, default=None, flags=re.S): m = re.search(pattern, text, flags)if m:return m.group(1)return default @staticmethoddef_parse_floats_csv(s: str, expected: Optional[int] = None) -> Tuple[float, ...]: parts = [x.strip() for x in s.split(",")] vals = tuple(float(x) for x in parts if x != "")if expected isnotNoneand len(vals) != expected:raise TLSParseError(f"参数个数不正确,应为 {expected} 个,实际 {len(vals)} 个。")return vals @staticmethoddef_parse_ints_space_or_csv(s: str, expected: Optional[int] = None) -> Tuple[int, ...]: parts = re.split(r"[\s,]+", s.strip()) vals = tuple(int(float(x)) for x in parts if x != "")if expected isnotNoneand len(vals) != expected:raise TLSParseError(f"整数参数个数不正确,应为 {expected} 个,实际 {len(vals)} 个。")return vals @staticmethoddef_extract_named_block_content(text: str, keyword: str) -> str: m = re.search(r"\b" + re.escape(keyword) + r"\s*\{", text)ifnot m:return"" open_idx = text.find("{", m.start()) close_idx = TLSParser.find_matching_brace(text, open_idx)return text[open_idx + 1:close_idx] @staticmethoddef_find_named_blocks(text: str, keyword: str) -> List[str]: results = [] idx = 0 pattern = re.compile(r"\b" + re.escape(keyword) + r"\s*\{")whileTrue: m = pattern.search(text, idx)ifnot m:break open_idx = text.find("{", m.start()) close_idx = TLSParser.find_matching_brace(text, open_idx) results.append(text[open_idx + 1:close_idx]) idx = close_idx + 1return results @staticmethoddef_parse_points(pts_text: str) -> List[Tuple[float, float]]: pts = []for m in re.finditer(r"\(\s*([+-]?\d+(?:\.\d+)?)\s*,\s*([+-]?\d+(?:\.\d+)?)\s*\)", pts_text): pts.append((float(m.group(1)), float(m.group(2))))return pts @staticmethoddefparse_sor_block(sor_inner: str) -> SORModel: segments: List[SORSegment] = [] idx = 0while idx < len(sor_inner): m_pts = re.search(r"\bPTS\s*\{", sor_inner[idx:]) m_arc = re.search(r"\bARC\s*\(", sor_inner[idx:]) candidates = []if m_pts: candidates.append(("PTS", idx + m_pts.start()))if m_arc: candidates.append(("ARC", idx + m_arc.start()))ifnot candidates:break kind, pos = min(candidates, key=lambda x: x[1])if kind == "PTS": open_idx = sor_inner.find("{", pos) close_idx = TLSParser.find_matching_brace(sor_inner, open_idx) content = sor_inner[open_idx + 1:close_idx] points = TLSParser._parse_points(content) segments.append(SORSegment("PTS", points)) idx = close_idx + 1else: open_idx = sor_inner.find("(", pos) close_idx = TLSParser.find_matching_paren(sor_inner, open_idx) content = sor_inner[open_idx + 1:close_idx] vals = TLSParser._parse_floats_csv(content, expected=3) segments.append(SORSegment("ARC", vals)) idx = close_idx + 1return SORModel(segments=segments) @staticmethoddefparse_sors(text: str) -> List[SORModel]: sors = []for sor_inner in TLSParser._find_named_blocks(text, "SOR"): sors.append(TLSParser.parse_sor_block(sor_inner))return sors @staticmethoddefparse_tool_block(tool_id: str, block_inner: str) -> ToolModel: units = TLSParser._search_field(r"\bUNITS\s+([A-Z_]+)", block_inner, "MILLIMETER") description = TLSParser._search_field(r'\bDESCRIPTION\s+"([^"]*)"', block_inner, "") gage_raw = TLSParser._search_field(r"\bGAGEPOINT_OFFSET\s*\(\s*([^)]+)\)", block_inner, "0,0,0") gage = TLSParser._parse_floats_csv(gage_raw, expected=3) tooltype = TLSParser._search_field(r"\bTOOLTYPE\s+([A-Z_]+)", block_inner, "MILLING") color_raw = TLSParser._search_field(r"\bCUTTERCOLOR\s*\(\s*([^)]+)\)", block_inner, "255 0 0") cuttercolor = TLSParser._parse_ints_space_or_csv(color_raw, expected=3) maxremovalrate_raw = TLSParser._search_field(r"\bMAXREMOVALRATE\s*\(\s*([^)]+)\)", block_inner, "0") max_qw_raw = TLSParser._search_field(r"\bMAX-QW\s*\(\s*([^)]+)\)", block_inner, "0") stack = TLSParser._search_field(r"\bSTACK\s+([A-Z_]+)", block_inner, "NO") cutter_block = TLSParser._extract_named_block_content(block_inner, "CUTTER") holder_block = TLSParser._extract_named_block_content(block_inner, "HOLDER") cutter_sors = TLSParser.parse_sors(cutter_block) holder_sors = TLSParser.parse_sors(holder_block)return ToolModel( tool_id=tool_id, units=units, description=description, gagepoint_offset=gage, tooltype=tooltype, cuttercolor=cuttercolor, maxremovalrate=float(maxremovalrate_raw), max_qw=float(max_qw_raw), stack=stack, cutter_sors=cutter_sors, holder_sors=holder_sors ) @staticmethoddefparse(text: str) -> Tuple[str, List[ToolModel]]: tools: List[ToolModel] = [] pattern = re.compile(r'TOOLID\s+"([^"]+)"\s*\{') matches = list(pattern.finditer(text))ifnot matches:return text, [] prefix = text[:matches[0].start()]for m in matches: tool_id = m.group(1) open_idx = text.find("{", m.start()) close_idx = TLSParser.find_matching_brace(text, open_idx) block_inner = text[open_idx + 1:close_idx] tools.append(TLSParser.parse_tool_block(tool_id, block_inner))return prefix, tools @staticmethoddefnum_to_str(v: float) -> str:if abs(v - int(v)) < 1e-12:return str(int(v)) s = f"{v:.10f}".rstrip("0").rstrip(".")if s == "-0": s = "0"return s @staticmethoddefsor_to_text(sor: SORModel, indent: str = "\t\t\t") -> str: lines = [f"{indent}SOR {{"]for seg in sor.segments:if seg.kind == "PTS": pts_text = " ".join(f"({TLSParser.num_to_str(x)},{TLSParser.num_to_str(y)})"for x, y in seg.data) lines.append(f"{indent}\tPTS {{ {pts_text} }}")elif seg.kind == "ARC": x, y, r = seg.data lines.append(f"{indent}\tARC ({TLSParser.num_to_str(x)},{TLSParser.num_to_str(y)},{TLSParser.num_to_str(r)})" ) lines.append(f"{indent}}}")return"\n".join(lines) @staticmethoddefsors_to_text(sors: List[SORModel], indent: str = "\t\t\t") -> str:return"\n\n".join(TLSParser.sor_to_text(s, indent=indent) for s in sors) @staticmethoddeftool_to_text(tool: ToolModel) -> str: r, g, b = tool.cuttercolor gx, gy, gz = tool.gagepoint_offset cutter_sors_text = TLSParser.sors_to_text(tool.cutter_sors, indent="\t\t\t") holder_sors_text = TLSParser.sors_to_text(tool.holder_sors, indent="\t\t\t")return (f'TOOLID "{tool.tool_id}" {{\n'f'\tUNITS {tool.units}\n'f'\tDESCRIPTION "{tool.description}"\n'f'\tGAGEPOINT_OFFSET ({TLSParser.num_to_str(gx)},{TLSParser.num_to_str(gy)},{TLSParser.num_to_str(gz)})\n'f'\tTOOLTYPE {tool.tooltype}\n'f'\tCUTTERCOLOR ({r}{g}{b})\n'f'\tMAXREMOVALRATE ({TLSParser.num_to_str(tool.maxremovalrate)})\n'f'\tMAX-QW ({TLSParser.num_to_str(tool.max_qw)})\n'f'\tSTACK {tool.stack}\n'f'\tCUTTER {{\n'f'\t\tASSEMBLY {{\n'f'{cutter_sors_text}\n'f'\t\t}}\n'f'\t}}\n'f'\tHOLDER {{\n'f'\t\tASSEMBLY {{\n'f'{holder_sors_text}\n'f'\t\t}}\n'f'\t}}\n'f'}}' ) @staticmethoddefserialize(prefix: str, tools: List[ToolModel]) -> str: tool_texts = [TLSParser.tool_to_text(t) for t in tools] prefix_clean = prefix.rstrip()if prefix_clean:return prefix_clean + "\n\n\n" + "\n\n\n".join(tool_texts) + "\n"return"\n\n".join(tool_texts) + "\n"defsors_to_edit_text(sors: List[SORModel]) -> str:return TLSParser.sors_to_text(sors, indent="")defedit_text_to_sors(text: str) -> List[SORModel]: stripped = text.strip()ifnot stripped:return []return TLSParser.parse_sors(stripped)deftool_item_text(tool: ToolModel) -> str: desc = f" - {tool.description}"if tool.description else""returnf'TOOLID {tool.tool_id}{desc}'classProfilePreviewWidget(QWidget):def__init__(self, parent=None): super().__init__(parent) self.cutter_sors: List[SORModel] = [] self.holder_sors: List[SORModel] = [] self.cutter_color = QColor(255, 0, 0) self.setMinimumHeight(420) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)defset_data(self, cutter_sors: List[SORModel], holder_sors: List[SORModel], cutter_rgb: Tuple[int, int, int]): self.cutter_sors = cutter_sors self.holder_sors = holder_sors self.cutter_color = QColor(*cutter_rgb) self.update()def_collect_world_points(self) -> List[Tuple[float, float]]: pts: List[Tuple[float, float]] = []for sor in (self.cutter_sors + self.holder_sors): pts.extend(sor.flatten_points())ifnot pts: pts = [(0, 0), (1, 1)]return ptsdef_world_to_screen(self, x: float, y: float, bounds, rect: QRectF) -> QPointF: min_x, max_x, min_y, max_y = bounds w = rect.width() h = rect.height() sx = rect.left() + (x - min_x) / max(max_x - min_x, 1e-9) * w sy = rect.bottom() - (y - min_y) / max(max_y - min_y, 1e-9) * hreturn QPointF(sx, sy)defpaintEvent(self, event): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing, True) p.fillRect(self.rect(), QColor(252, 252, 252)) margin = 28 draw_rect = QRectF( margin, margin, max(10, self.width() - 2 * margin), max(10, self.height() - 2 * margin) ) pts = self._collect_world_points() max_r = max(abs(x) for x, _ in pts) if pts else1.0 min_y = min(y for _, y in pts) if pts else0.0 max_y = max(y for _, y in pts) if pts else1.0 pad_x = max(5.0, max_r * 0.15) pad_y = max(5.0, (max_y - min_y) * 0.10if max_y != min_y else5.0) bounds = (-max_r - pad_x, max_r + pad_x, min_y - pad_y, max_y + pad_y) p.setPen(QPen(QColor(225, 225, 225), 1))for i in range(6): yv = bounds[2] + (bounds[3] - bounds[2]) * i / 5.0 a = self._world_to_screen(bounds[0], yv, bounds, draw_rect) b = self._world_to_screen(bounds[1], yv, bounds, draw_rect) p.drawLine(a, b) center_a = self._world_to_screen(0, bounds[2], bounds, draw_rect) center_b = self._world_to_screen(0, bounds[3], bounds, draw_rect) p.setPen(QPen(QColor(120, 120, 120), 1, Qt.DashLine)) p.drawLine(center_a, center_b)defdraw_sor_list(sors: List[SORModel], color: QColor, width: int): pen = QPen(color, width) p.setPen(pen)for sor in sors: poly = sor.flatten_points()if len(poly) < 2:continuefor i in range(len(poly) - 1): a = self._world_to_screen(poly[i][0], poly[i][1], bounds, draw_rect) b = self._world_to_screen(poly[i + 1][0], poly[i + 1][1], bounds, draw_rect) p.drawLine(a, b)for i in range(len(poly) - 1): a = self._world_to_screen(-poly[i][0], poly[i][1], bounds, draw_rect) b = self._world_to_screen(-poly[i + 1][0], poly[i + 1][1], bounds, draw_rect) p.drawLine(a, b) draw_sor_list(self.holder_sors, QColor(70, 70, 70), 2) draw_sor_list(self.cutter_sors, self.cutter_color, 3) p.setPen(QPen(QColor(40, 40, 40), 1)) font = QFont() font.setPointSize(10) p.setFont(font) p.drawText(12, 18, "2D 轴对称预览(ARC 以示意折线显示)")classMainWindow(QMainWindow):def__init__(self): super().__init__() self.setWindowTitle("NUM TLS 刀具文件编辑器") self.resize(1400, 860) self.prefix_text: str = "" self.tools: List[ToolModel] = [] self.current_file_path: str = DEFAULT_PATH self.current_index: int = -1 self.loading_ui = False self.init_ui()if os.path.exists(DEFAULT_PATH): self.load_tls_file(DEFAULT_PATH)definit_ui(self): central = QWidget() self.setCentralWidget(central) root = QVBoxLayout(central) root.setContentsMargins(8, 8, 8, 8) root.setSpacing(8) top_box = QGroupBox("文件") top_layout = QHBoxLayout(top_box) self.path_edit = QLineEdit(DEFAULT_PATH) self.path_edit.setPlaceholderText("选择 .tls 文件路径") btn_browse = QPushButton("浏览...") btn_open = QPushButton("打开") btn_save = QPushButton("保存") btn_save_as = QPushButton("另存为") btn_browse.clicked.connect(self.browse_file) btn_open.clicked.connect(self.open_from_path) btn_save.clicked.connect(self.save_current_file) btn_save_as.clicked.connect(self.save_as_file) top_layout.addWidget(QLabel("文件路径:")) top_layout.addWidget(self.path_edit, 1) top_layout.addWidget(btn_browse) top_layout.addWidget(btn_open) top_layout.addWidget(btn_save) top_layout.addWidget(btn_save_as) root.addWidget(top_box) splitter = QSplitter() splitter.setOrientation(Qt.Horizontal) root.addWidget(splitter, 1) left_box = QGroupBox("刀具列表") left_layout = QVBoxLayout(left_box) self.tool_list = QListWidget() self.tool_list.currentRowChanged.connect(self.on_tool_changed) left_layout.addWidget(self.tool_list) right_widget = QWidget() right_layout = QVBoxLayout(right_widget) right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setSpacing(8) param_group = QGroupBox("基础参数") form = QGridLayout(param_group) self.edit_toolid = QLineEdit() self.edit_toolid.setReadOnly(True) self.combo_units = QComboBox() self.combo_units.setEditable(True) self.combo_units.addItems(["MILLIMETER", "INCH"]) self.edit_description = QLineEdit() self.spin_gx = self._make_dspin() self.spin_gy = self._make_dspin() self.spin_gz = self._make_dspin() self.combo_tooltype = QComboBox() self.combo_tooltype.setEditable(True) self.combo_tooltype.addItems(["MILLING", "DRILLING", "TURNING"]) self.spin_r = self._make_spin(0, 255) self.spin_g = self._make_spin(0, 255) self.spin_b = self._make_spin(0, 255) self.color_patch = QLabel() self.color_patch.setFixedSize(46, 24) self.color_patch.setFrameShape(QFrame.Box) self.spin_maxremoval = self._make_dspin() self.spin_maxremoval.setMaximum(1e9) self.spin_maxqw = self._make_dspin() self.spin_maxqw.setMaximum(1e6) self.combo_stack = QComboBox() self.combo_stack.setEditable(True) self.combo_stack.addItems(["NO", "YES"]) form.addWidget(QLabel("TOOLID"), 0, 0) form.addWidget(self.edit_toolid, 0, 1) form.addWidget(QLabel("UNITS"), 0, 2) form.addWidget(self.combo_units, 0, 3) form.addWidget(QLabel("DESCRIPTION"), 1, 0) form.addWidget(self.edit_description, 1, 1, 1, 3) form.addWidget(QLabel("GAGEPOINT X"), 2, 0) form.addWidget(self.spin_gx, 2, 1) form.addWidget(QLabel("GAGEPOINT Y"), 2, 2) form.addWidget(self.spin_gy, 2, 3) form.addWidget(QLabel("GAGEPOINT Z"), 3, 0) form.addWidget(self.spin_gz, 3, 1) form.addWidget(QLabel("TOOLTYPE"), 3, 2) form.addWidget(self.combo_tooltype, 3, 3) form.addWidget(QLabel("CUTTERCOLOR R"), 4, 0) form.addWidget(self.spin_r, 4, 1) form.addWidget(QLabel("CUTTERCOLOR G"), 4, 2) form.addWidget(self.spin_g, 4, 3) form.addWidget(QLabel("CUTTERCOLOR B"), 5, 0) form.addWidget(self.spin_b, 5, 1) form.addWidget(QLabel("颜色预览"), 5, 2) form.addWidget(self.color_patch, 5, 3) form.addWidget(QLabel("MAXREMOVALRATE"), 6, 0) form.addWidget(self.spin_maxremoval, 6, 1) form.addWidget(QLabel("MAX-QW"), 6, 2) form.addWidget(self.spin_maxqw, 6, 3) form.addWidget(QLabel("STACK"), 7, 0) form.addWidget(self.combo_stack, 7, 1) self.spin_r.valueChanged.connect(self.update_color_patch) self.spin_g.valueChanged.connect(self.update_color_patch) self.spin_b.valueChanged.connect(self.update_color_patch) right_layout.addWidget(param_group) self.tabs = QTabWidget() geom_tab = QWidget() geom_layout = QVBoxLayout(geom_tab) text_splitter = QSplitter(Qt.Vertical) cutter_group = QGroupBox("CUTTER / ASSEMBLY / SOR") cutter_layout = QVBoxLayout(cutter_group) self.cutter_text = QPlainTextEdit() self.cutter_text.setPlaceholderText("CUTTER 的 SOR 内容") cutter_layout.addWidget(self.cutter_text) holder_group = QGroupBox("HOLDER / ASSEMBLY / SOR") holder_layout = QVBoxLayout(holder_group) self.holder_text = QPlainTextEdit() self.holder_text.setPlaceholderText("HOLDER 的 SOR 内容") holder_layout.addWidget(self.holder_text) text_splitter.addWidget(cutter_group) text_splitter.addWidget(holder_group) text_splitter.setSizes([280, 280]) geom_layout.addWidget(text_splitter) btn_row = QHBoxLayout() btn_apply_preview = QPushButton("刷新预览") btn_apply_preview.clicked.connect(self.refresh_preview_from_ui) btn_sync_model = QPushButton("应用到当前刀具") btn_sync_model.clicked.connect(self.apply_ui_to_current_tool) btn_row.addStretch(1) btn_row.addWidget(btn_apply_preview) btn_row.addWidget(btn_sync_model) geom_layout.addLayout(btn_row) preview_tab = QWidget() preview_layout = QVBoxLayout(preview_tab) self.preview_widget = ProfilePreviewWidget() preview_layout.addWidget(self.preview_widget) self.tabs.addTab(geom_tab, "几何文本") self.tabs.addTab(preview_tab, "轮廓预览") right_layout.addWidget(self.tabs, 1) splitter.addWidget(left_box) splitter.addWidget(right_widget) splitter.setSizes([260, 1080]) self.statusBar().showMessage("就绪")def_make_spin(self, mn: int, mx: int) -> QSpinBox: w = QSpinBox() w.setRange(mn, mx)return wdef_make_dspin(self) -> QDoubleSpinBox: w = QDoubleSpinBox() w.setDecimals(6) w.setRange(-1e9, 1e9) w.setSingleStep(0.1)return wdefbrowse_file(self): current = self.path_edit.text().strip() or DEFAULT_PATH path, _ = QFileDialog.getOpenFileName(self, "选择 TLS 文件", current, "TLS Files (*.tls);;All Files (*)")if path: self.path_edit.setText(path)defopen_from_path(self): path = self.path_edit.text().strip()ifnot path: QMessageBox.warning(self, "提示", "请先输入文件路径。")return self.load_tls_file(path)defload_tls_file(self, path: str):ifnot os.path.exists(path): QMessageBox.warning(self, "文件不存在", f"未找到文件:\n{path}")returntry:with open(path, "r", encoding="utf-8", errors="ignore") as f: text = f.read() prefix, tools = TLSParser.parse(text) self.prefix_text = prefix self.tools = tools self.current_file_path = path self.path_edit.setText(path) self.tool_list.blockSignals(True) self.tool_list.clear()for tool in self.tools: QListWidgetItem(tool_item_text(tool), self.tool_list) self.tool_list.blockSignals(False)if self.tools: self.tool_list.setCurrentRow(0)else: self.clear_ui() self.statusBar().showMessage(f"已打开:{path},共 {len(self.tools)} 把刀具")except Exception as e: QMessageBox.critical(self, "打开失败", f"解析文件失败:\n{e}")defsave_current_file(self):ifnot self.current_file_path: QMessageBox.warning(self, "提示", "当前没有文件路径。")returnifnot self.apply_ui_to_current_tool():returntry: text = TLSParser.serialize(self.prefix_text, self.tools)with open(self.current_file_path, "w", encoding="utf-8", errors="ignore") as f: f.write(text) self.refresh_list_text() self.statusBar().showMessage(f"已保存:{self.current_file_path}") QMessageBox.information(self, "保存成功", f"文件已保存到:\n{self.current_file_path}")except Exception as e: QMessageBox.critical(self, "保存失败", f"写入文件失败:\n{e}")defsave_as_file(self):ifnot self.apply_ui_to_current_tool():return init_path = self.current_file_path or DEFAULT_PATH path, _ = QFileDialog.getSaveFileName(self, "另存为", init_path, "TLS Files (*.tls);;All Files (*)")ifnot path:returntry: text = TLSParser.serialize(self.prefix_text, self.tools)with open(path, "w", encoding="utf-8", errors="ignore") as f: f.write(text) self.current_file_path = path self.path_edit.setText(path) self.statusBar().showMessage(f"已另存为:{path}") QMessageBox.information(self, "保存成功", f"文件已保存到:\n{path}")except Exception as e: QMessageBox.critical(self, "保存失败", f"写入文件失败:\n{e}")defclear_ui(self): self.loading_ui = True self.edit_toolid.clear() self.combo_units.setCurrentText("MILLIMETER") self.edit_description.clear() self.spin_gx.setValue(0) self.spin_gy.setValue(0) self.spin_gz.setValue(0) self.combo_tooltype.setCurrentText("MILLING") self.spin_r.setValue(255) self.spin_g.setValue(0) self.spin_b.setValue(0) self.spin_maxremoval.setValue(0) self.spin_maxqw.setValue(0) self.combo_stack.setCurrentText("NO") self.cutter_text.clear() self.holder_text.clear() self.loading_ui = False self.update_color_patch() self.preview_widget.set_data([], [], (255, 0, 0))defrefresh_list_text(self): self.tool_list.blockSignals(True)for i, tool in enumerate(self.tools): item = self.tool_list.item(i)if item: item.setText(tool_item_text(tool)) self.tool_list.blockSignals(False)defon_tool_changed(self, row: int):if self.loading_ui:returnif row < 0or row >= len(self.tools): self.current_index = -1 self.clear_ui()returnif self.current_index != -1and self.current_index < len(self.tools): ok = self.apply_ui_to_current_tool(silent=True)ifnot ok: self.tool_list.blockSignals(True) self.tool_list.setCurrentRow(self.current_index) self.tool_list.blockSignals(False)return self.current_index = row self.load_tool_to_ui(self.tools[row])defload_tool_to_ui(self, tool: ToolModel): self.loading_ui = True self.edit_toolid.setText(tool.tool_id) self.combo_units.setCurrentText(tool.units) self.edit_description.setText(tool.description) gx, gy, gz = tool.gagepoint_offset self.spin_gx.setValue(gx) self.spin_gy.setValue(gy) self.spin_gz.setValue(gz) self.combo_tooltype.setCurrentText(tool.tooltype) r, g, b = tool.cuttercolor self.spin_r.setValue(r) self.spin_g.setValue(g) self.spin_b.setValue(b) self.spin_maxremoval.setValue(tool.maxremovalrate) self.spin_maxqw.setValue(tool.max_qw) self.combo_stack.setCurrentText(tool.stack) self.cutter_text.setPlainText(sors_to_edit_text(tool.cutter_sors)) self.holder_text.setPlainText(sors_to_edit_text(tool.holder_sors)) self.loading_ui = False self.update_color_patch() self.refresh_preview_from_ui(silent=True)defupdate_color_patch(self): color = QColor(self.spin_r.value(), self.spin_g.value(), self.spin_b.value()) self.color_patch.setStyleSheet(f"background-color: rgb({color.red()}, {color.green()}, {color.blue()});" )defui_to_tool(self) -> ToolModel:if self.current_index < 0or self.current_index >= len(self.tools):raise TLSParseError("当前没有选中的刀具。") base = self.tools[self.current_index] cutter_sors = edit_text_to_sors(self.cutter_text.toPlainText()) holder_sors = edit_text_to_sors(self.holder_text.toPlainText()) new_tool = ToolModel( tool_id=base.tool_id, units=self.combo_units.currentText().strip(), description=self.edit_description.text().strip(), gagepoint_offset=(self.spin_gx.value(), self.spin_gy.value(), self.spin_gz.value()), tooltype=self.combo_tooltype.currentText().strip(), cuttercolor=(self.spin_r.value(), self.spin_g.value(), self.spin_b.value()), maxremovalrate=self.spin_maxremoval.value(), max_qw=self.spin_maxqw.value(), stack=self.combo_stack.currentText().strip(), cutter_sors=cutter_sors, holder_sors=holder_sors )return new_tooldefapply_ui_to_current_tool(self, silent: bool = False) -> bool:if self.loading_ui:returnTrueif self.current_index < 0or self.current_index >= len(self.tools):returnTruetry: tool = self.ui_to_tool() self.tools[self.current_index] = tool self.refresh_list_text() self.refresh_preview_from_ui(silent=True)ifnot silent: self.statusBar().showMessage(f"已应用到 TOOLID {tool.tool_id}")returnTrueexcept Exception as e:ifnot silent: QMessageBox.warning(self, "参数错误", f"当前界面数据无法应用:\n{e}")returnFalsedefrefresh_preview_from_ui(self, silent: bool = False):try: cutter_sors = edit_text_to_sors(self.cutter_text.toPlainText()) holder_sors = edit_text_to_sors(self.holder_text.toPlainText()) cutter_rgb = (self.spin_r.value(), self.spin_g.value(), self.spin_b.value()) self.preview_widget.set_data(cutter_sors, holder_sors, cutter_rgb)ifnot silent: self.statusBar().showMessage("预览已刷新")except Exception as e: self.preview_widget.set_data([], [], (self.spin_r.value(), self.spin_g.value(), self.spin_b.value()))ifnot silent: QMessageBox.warning(self, "预览失败", f"SOR 几何文本解析失败:\n{e}")defmain(): app = QApplication(sys.argv) app.setApplicationName("NUM TLS 刀具文件编辑器") win = MainWindow() win.show() sys.exit(app.exec_())if __name__ == "__main__": main()
通过以上代码,你可以获得一个完整的、可直接运行的NUM刀具编辑器。它不仅能解析和保存标准的.tls文件,还提供了直观的2D轮廓预览,大幅降低刀具参数配置的出错率。对于经常需要定制非标刀具或调试旋转体轮廓的工程师而言,这款工具无疑是一个高效可靠的助手。


陪伴是最长情的告白
为你推送最实用的资讯

识别二维码 关注我们
夜雨聆风