乐于分享
好东西不私藏

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

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

点击上方蓝字订阅!

在数控加工与CAM编程中,刀具文件的正确管理直接影响加工效率与安全性。NUM控制系统使用的.tls刀具文件是一种结构化的文本格式,其中包含了刀具标识、切削刃几何、刀柄轮廓、GAGEPOINT偏移、颜色等关键参数。然而直接手工编辑这类文件不仅容易出错,而且无法直观预览刀具的2D轴对称轮廓。为了解决这一痛点,本文实现了一款功能完备的桌面工具——NUM TLS刀具文件编辑器,它基于PyQt5开发,能够解析、编辑、保存.tls文件,并提供实时几何预览。

整个程序的核心思路是将.tls文本映射为内存中的ToolModel对象,通过正则表达式与递归括号匹配解析出TOOLIDCUTTERHOLDERSORPTSARC等区块。其中SOR(Segment of Revolution)是描述旋转体轮廓的关键结构,它可以包含多个PTS(点列表)和ARC(圆弧指令)。程序支持在左侧列表切换多把刀具,右侧使用控件编辑基础参数(如单位、描述、偏移、颜色、最大去除率等),下方则用纯文本控件直接编辑CUTTERHOLDERSOR几何文本。编辑完成后可以保存回原文件或另存为新文件。

为了降低用户的学习成本,编辑器默认打开路径设为D:\num4.0T\Temp\numroto3d\test_ball.tls,并提供了文件浏览按钮。在几何文本区域,用户可以自由修改PTS的坐标对和ARC的圆心坐标与半径,程序会在保存时自动将其格式化为符合NUM规范的文本。更值得一提的是2D轮廓预览功能:程序将当前刀具的CUTTERHOLDER的所有SOR段展开为点序列,基于轴对称原则绘制出刀具的半剖面图。刀具轮廓用红色粗线绘制,刀柄用灰色线绘制,并显示轴线与背景网格,方便用户快速验证几何形状的正确性。由于ARC在预览时被转换为折线示意(不影响文件存储),这使得预览算法保持简单且高效。

从技术实现角度看,TLSParser类承担了所有解析与序列化工作。它利用find_matching_bracefind_matching_paren两个递归方法处理嵌套的花括号和圆括号,确保即便文件格式存在微小变动也能正确提取内容。对于PTS块,使用正则\(\s*([+-]?\d+(?:\.\d+)?)\s*,\s*([+-]?\d+(?:\.\d+)?)\s*\)捕获每一个点坐标;对于ARC,则从圆括号中解析三个浮点数。SORModel内部维护一个segments列表,分别存储PTSARC数据,并提供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.00.00.0)    tooltype: str = "MILLING"    cuttercolor: Tuple[int, int, int] = (25500)    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(25500)        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 = [(00), (11)]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(252252252))        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(225225225), 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(120120120), 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(707070), 2)        draw_sor_list(self.cutter_sors, self.cutter_color, 3)        p.setPen(QPen(QColor(404040), 1))        font = QFont()        font.setPointSize(10)        p.setFont(font)        p.drawText(1218"2D 轴对称预览(ARC 以示意折线显示)")classMainWindow(QMainWindow):def__init__(self):        super().__init__()        self.setWindowTitle("NUM TLS 刀具文件编辑器")        self.resize(1400860)        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(8888)        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(0000)        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(0255)        self.spin_g = self._make_spin(0255)        self.spin_b = self._make_spin(0255)        self.color_patch = QLabel()        self.color_patch.setFixedSize(4624)        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"), 00)        form.addWidget(self.edit_toolid, 01)        form.addWidget(QLabel("UNITS"), 02)        form.addWidget(self.combo_units, 03)        form.addWidget(QLabel("DESCRIPTION"), 10)        form.addWidget(self.edit_description, 1113)        form.addWidget(QLabel("GAGEPOINT X"), 20)        form.addWidget(self.spin_gx, 21)        form.addWidget(QLabel("GAGEPOINT Y"), 22)        form.addWidget(self.spin_gy, 23)        form.addWidget(QLabel("GAGEPOINT Z"), 30)        form.addWidget(self.spin_gz, 31)        form.addWidget(QLabel("TOOLTYPE"), 32)        form.addWidget(self.combo_tooltype, 33)        form.addWidget(QLabel("CUTTERCOLOR R"), 40)        form.addWidget(self.spin_r, 41)        form.addWidget(QLabel("CUTTERCOLOR G"), 42)        form.addWidget(self.spin_g, 43)        form.addWidget(QLabel("CUTTERCOLOR B"), 50)        form.addWidget(self.spin_b, 51)        form.addWidget(QLabel("颜色预览"), 52)        form.addWidget(self.color_patch, 53)        form.addWidget(QLabel("MAXREMOVALRATE"), 60)        form.addWidget(self.spin_maxremoval, 61)        form.addWidget(QLabel("MAX-QW"), 62)        form.addWidget(self.spin_maxqw, 63)        form.addWidget(QLabel("STACK"), 70)        form.addWidget(self.combo_stack, 71)        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([280280])        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([2601080])        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(-1e91e9)        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([], [], (25500))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轮廓预览,大幅降低刀具参数配置的出错率。对于经常需要定制非标刀具或调试旋转体轮廓的工程师而言,这款工具无疑是一个高效可靠的助手。

 • end • 

陪伴是最长情的告白

 为你推送最实用的资讯 

识别二维码 关注我们