From 3a58685ea4a345b1454ef4d6c733f6b258c77141 Mon Sep 17 00:00:00 2001 From: JayH Date: Sun, 18 Oct 2026 23:21:45 +0800 Subject: [PATCH 1/3] =?UTF-8?q?app:=20ProgramPanel=20=E7=B1=BB=E5=86=85?= =?UTF-8?q?=E6=8B=86=E8=A7=A3=E4=B8=BA=208=20=E4=B8=AA=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=EF=BC=882202=E2=86=92534=20=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUILD_QUEUE Step 12 B1 落地:全仓最大源码单体按职责拆出 program_state/_ui/_robot/_playback/_grasp/_export/_editor/_validate, ProgramPanel 保留全部公开方法薄 stub 转发,调用面与测试零改动。 新建 6 条守门测试;LEGACY_DEBT 摘除 panel、CJK 基线按棘轮守恒迁移。 --- app/views/program_editor.py | 510 +++++++ app/views/program_export.py | 240 +++ app/views/program_grasp.py | 322 ++++ app/views/program_panel.py | 1942 ++----------------------- app/views/program_playback.py | 166 +++ app/views/program_robot.py | 126 ++ app/views/program_state.py | 48 + app/views/program_ui.py | 381 +++++ app/views/program_validate.py | 336 +++++ docs/develop/BUILD_QUEUE.md | 8 +- tests/test_app_views_program_panel.py | 143 ++ tools/check_arch_rules.py | 14 +- 12 files changed, 2427 insertions(+), 1809 deletions(-) create mode 100644 app/views/program_editor.py create mode 100644 app/views/program_export.py create mode 100644 app/views/program_grasp.py create mode 100644 app/views/program_playback.py create mode 100644 app/views/program_robot.py create mode 100644 app/views/program_state.py create mode 100644 app/views/program_ui.py create mode 100644 app/views/program_validate.py create mode 100644 tests/test_app_views_program_panel.py diff --git a/app/views/program_editor.py b/app/views/program_editor.py new file mode 100644 index 0000000..25fa933 --- /dev/null +++ b/app/views/program_editor.py @@ -0,0 +1,510 @@ +"""离线编程面板的轨迹编辑器:路点增删移改、逐点运动类型、列表渲染与着色。 + +圆弧(v1.1.5)口径:**第 N 行的运动类型描述「到达第 N 点」的运动**—— +``C`` 表示"以上一点为中间点、再上一点为起点,圆弧到达本点"。中间点被并进 +上一条指令、不再单独出运动指令,因此路点行号与指令序号不再一一对应, +节拍明细与问题着色都要经 ``instruction_of_point`` 的映射取值。 +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple + +from PySide6 import QtGui, QtWidgets + +from service.pickplace import gripper_action_for +from support.i18n import tr + +if TYPE_CHECKING: # 仅类型标注 + from service.pickplace import PointType + from service.planner import ProgramReport + + from app.views.program_panel import ProgramPanel + + +def set_taught_points( + panel, + positions: Sequence[Dict[str, float]], + types: Optional[Sequence["PointType"]] = None, + motions: Optional[Sequence[str]] = None, + robot: Optional[str] = None, +) -> None: + """导入示教点(``{关节名: 弧度}``)。 + + Args: + positions: 示教点序列 + types: 与示教点对齐的工艺语义(``PointType.PICK/PLACE…``); + 给出后 ``build_program`` 会在抓取/放置点自动插入 + ``gripper()`` 指令,程序与动画共用同一份抓取定义 + motions: 与示教点对齐的运动类型(``"J"``/``"L"``/``"C"``)。 + 为 ``None`` 且数量不变时沿用当前逐点运动类型(轨迹编辑器 + 的改动不被外部同步覆盖),否则重置为全 moveJ + robot: 写到哪台机器人的名下;``None`` 表示当前这台。给非当前机器人 + 喂数据时界面不会跟着跳(主窗口批量同步多台时要这个语义) + """ + # 喂给非当前那台时临时切过去写,写完切换回来——界面不能跟着跳 + hidden = robot is not None and robot in panel._states and robot != panel._active + prev = panel._active + if hidden: + panel._active = robot + try: + panel._taught = [dict(p) for p in positions] + panel._point_types = list(types) if types is not None else None + # 长度必须对齐:语义少了会漏动作,多了会错位 + if panel._point_types is not None and len(panel._point_types) != len(panel._taught): + panel._point_types = None + if motions is not None and len(motions) == len(panel._taught): + panel._point_motions = [ + k if k in ("L", "C") else "J" for k in (str(m).upper() for m in motions) + ] + elif len(panel._point_motions) != len(panel._taught): + panel._point_motions = ["J"] * len(panel._taught) + n = len(panel._taught) + # 点数变了,旧报告的节拍明细/着色不再对得上号——直接作废 + if getattr(panel, "_last_point_count", None) != n: + panel._report = None + panel._point_issue_level = {} + panel._last_point_count = n + finally: + panel._active = prev + + if hidden: + return + panel.statusMessage.emit(tr("导入 {n} 个示教点").format(n=n)) + panel._refresh_point_list() + # 有了示教点才有东西可导出:「导出全部」的可用性随点表一起变, + # 只靠登记机器人时点一次会一直灰着 + panel._refresh_export_all_enabled() + + +def get_taught_points(panel, robot: Optional[str] = None) -> tuple: + """读取示教点数据(供外部同步与测试)。 + + Args: + robot: 读哪台的;``None`` 表示当前这台。抓取向导写完点后主窗口要 + 按名字把点位回灌视口,故支持指定机器人 + + Returns: + ``(positions, types, motions)``——positions 为 + ``{关节名: 弧度}`` 字典列表的拷贝;types 可为 ``None``; + motions 为 ``"J"/"L"/"C"`` 列表 + """ + with panel._using_robot(robot) as state: + positions = [dict(p) for p in state.taught] + types = list(state.point_types) if state.point_types is not None else None + return positions, types, list(state.point_motions) + + +def _selected_index(panel) -> Optional[int]: + """当前选中行号;未选中返回 ``None``。""" + row = panel.point_list.currentRow() + return row if 0 <= row < len(panel._taught) else None + + +def _motion_kind(panel, index: int) -> str: + """第 ``index`` 行声明的运动类型(``"J"`` / ``"L"`` / ``"C"``)。""" + if index < len(panel._point_motions): + kind = str(panel._point_motions[index]).upper() + if kind in ("J", "L", "C"): + return kind + return "J" + + +def _gripper_action_at(panel, index: int) -> Optional[str]: + """第 ``index`` 行是否带夹爪动作(抓取/放置语义)。""" + if panel._point_types is not None and index < len(panel._point_types): + return gripper_action_for(panel._point_types[index]) + return None + + +def arc_rejection(panel, index: int) -> Optional[str]: + """把第 ``index`` 行设为 ``moveC`` 是否可行;不可行时返回原因(可直接展示)。 + + 圆弧的约定(轨迹编辑器里的口径):**第 N 行的运动类型描述"到达第 N 点" + 的运动**——``C`` 表示"以上一点为中间点、再上一点为起点,圆弧到达本点"。 + 因此: + + * 第 1、2 行不能是圆弧(没有两个前驱点); + * 相邻两行不能同为圆弧(中间点会同时成为上一段的终点,几何上矛盾); + * 带工艺语义的点(抓取/放置)不能当中间点——中间点不停机,夹爪动作 + 会失去落点。 + + Returns: + 拒绝原因(中文,可直接显示);可行时 ``None`` + """ + if not (0 <= index < len(panel._taught)): + return tr("路点不存在") + if index < 2: + return tr("P{n} 缺少两个前驱点——圆弧需要起点与中间点,P1/P2 不能设为 moveC").format( + n=index + 1 + ) + if panel._motion_kind(index - 1) == "C": + return tr("P{n} 已是圆弧终点,不能再作为本段的中间点").format(n=index) + if index + 1 < len(panel._taught) and panel._motion_kind(index + 1) == "C": + return tr("P{n} 已把 P{k} 当中间点——本行不能同时作为圆弧终点").format( + n=index + 2, k=index + 1 + ) + if panel._gripper_action_at(index - 1) is not None: + return tr("P{n} 是抓取/放置点,不能作为圆弧中间点(中间点不停机)").format(n=index) + return None + + +def motion_plan(panel) -> Tuple[List[Tuple[str, int, Optional[int]]], List[str]]: + """把逐点运动类型解析成可执行的运动序列。 + + Returns: + ``(plan, notes)``:plan 每项为 ``(kind, point_index, via_index)``, + ``kind`` ∈ ``{"J","L","C"}``,``via_index`` 仅圆弧有值; + notes 为降级说明(圆弧条件不满足时退化为关节运动,这里如实记下)。 + """ + n = len(panel._taught) + declared = [panel._motion_kind(i) for i in range(n)] + accepted: Dict[int, int] = {} # 圆弧终点行 → 中间点行 + notes: List[str] = [] + for i in range(n): + if declared[i] != "C": + continue + reason = panel.arc_rejection(i) + if reason is not None: + notes.append(tr("{reason}——已按关节运动(moveJ)处理").format(reason=reason)) + continue + accepted[i] = i - 1 + vias = set(accepted.values()) + plan: List[Tuple[str, int, Optional[int]]] = [] + for i in range(n): + if i in vias: # 中间点不再单独出运动指令 + continue + if i in accepted: + plan.append(("C", i, accepted[i])) + else: + kind = declared[i] + plan.append((("J" if kind == "C" else kind), i, None)) + return plan, notes + + +def arc_via_indices(panel) -> set: + """当前被当作圆弧中间点(不再单独出指令)的路点行号集合。""" + return {via for _kind, _i, via in panel.motion_plan()[0] if via is not None} + + +def instruction_of_point(panel) -> Dict[int, int]: + """路点行号 → 运动指令序号(圆弧把中间点并进上一条指令,两者不再一一对应)。""" + plan, _notes = panel.motion_plan() + return {index: order for order, (_k, index, _v) in enumerate(plan)} + + +def last_build_notes(panel) -> List[str]: + """最近一次 ``build_program`` 的降级说明(空表表示没有降级)。""" + return list(panel._build_notes) + + +def _emit_taught_changed(panel) -> None: + """示教点被编辑后广播,供主窗口回写路径代码编辑器。""" + positions, types, motions = panel.get_taught_points() + panel.taughtPointsChanged.emit(positions, types or [], motions) + + +def _invalidate_results(panel) -> None: + """示教点变了,旧轨迹/报告/回放全部作废。""" + panel.stop_playback() + panel._trajectory = None + panel._report = None + panel._planner = None + panel._gripper_animator = None + # 「全部回放」跟着失效:轨迹没了就不该再能点——否则按下去播的是 + # 内存里已经被判废的旧轨迹 + if getattr(panel, "btn_play_all", None) is not None: + panel.btn_play_all.setEnabled(bool(panel._playable())) + + +def _keep_row_selected(panel, row: int) -> None: + """把第 ``row`` 行设为选中并滚进视野。 + + 增删改之后列表是**整体重建**的:重建会把选中清掉,长点表里就再也找不 + 到刚动过的那一行。移动类操作一律走这里。注意 ``setCurrentRow`` 只设 + **当前行**、不设**选中**——重建后的行没有选中态,深色主题下只显示 + 交替行的淡色而不是蓝色高亮,用户看到的就是"选中的颜色变淡了" + (2026-09-21 用户反馈)。所以这里显式 ``setSelected(True)``。 + """ + if not (0 <= row < panel.point_list.count()): + return + panel.point_list.setCurrentRow(row) + item = panel.point_list.item(row) + if item is not None: + item.setSelected(True) + panel.point_list.scrollToItem(item, QtWidgets.QAbstractItemView.EnsureVisible) + + +def insert_point(panel, index: Optional[int] = None) -> Optional[int]: + """在 ``index`` 之后插入路点,复制其关节值与运动类型。 + + Args: + index: 插入位置(新点落在该序号后一位);``None`` 取当前 + 选中行,无选中时追加到末尾。首点为空时填零位 + + Returns: + 新点序号;示教点未初始化时返回 ``None`` + """ + if isinstance(index, bool): # clicked(bool) 之类把 False 顶了进来 + index = None + if not panel._taught: + if panel._joint_names: + zero = {name: 0.0 for name in panel._joint_names} + panel._taught = [zero] + panel._point_types = None + panel._point_motions = ["J"] + else: + panel.statusMessage.emit(tr("请先载入机器人再添加路点")) + return None + else: + if index is None: + index = panel._selected_index() + if index is None or not (0 <= index < len(panel._taught)): + index = len(panel._taught) - 1 + src = dict(panel._taught[index]) + panel._taught.insert(index + 1, src) + if panel._point_types is not None: + panel._point_types.insert(index + 1, panel._point_types[index]) + # 复制运动类型:同一位置附近通常保持同一种走法 + panel._point_motions.insert(index + 1, panel._point_motions[index]) + index = index + 1 + panel._invalidate_results() + panel._refresh_point_list() + panel._keep_row_selected(index) + panel._emit_taught_changed() + panel._keep_row_selected(index) # 回灌可能重建列表,选中再兜一次 + panel.statusMessage.emit(tr("已插入路点 {n},请重新校验").format(n=index + 1)) + return index + + +def delete_point(panel, index: Optional[int] = None) -> bool: + """删除 ``index`` 处的路点(``None`` 取当前选中行)。 + + 删完把选中落在**顶上来的那一行**:后续继续删相邻点时不必重新点选。 + """ + if isinstance(index, bool): # clicked(bool) 之类把 False 顶了进来 + index = None + if not panel._taught: + return False + if index is None: + index = panel._selected_index() + if index is None or not (0 <= index < len(panel._taught)): + panel.statusMessage.emit(tr("请先在列表中选中要删除的路点")) + return False + del panel._taught[index] + if panel._point_types is not None and index < len(panel._point_types): + del panel._point_types[index] + if index < len(panel._point_motions): + del panel._point_motions[index] + panel._invalidate_results() + panel._refresh_point_list() + follow = min(index, panel.point_list.count() - 1) + panel.point_list.setCurrentRow(follow) + panel._emit_taught_changed() + panel._keep_row_selected(follow) + panel.statusMessage.emit(tr("已删除路点 {n},请重新校验").format(n=index + 1)) + return True + + +def move_point(panel, src: int, dst: int) -> bool: + """把路点从 ``src`` 移动到 ``dst``(``dst`` 为移动后的新序号)。 + + 移动的**是那一个点本身**:关节值、工艺语义、运动类型一起走,列表里的 + ``P`` 序号按行序重排(第 N 行就是 P{N+1}),因此"被移动的点"到了新位置 + 就顶新的号——不会留下"坐标走了、序号还挂旧行"的两张皮。选中也跟着它走。 + """ + n = len(panel._taught) + if not (0 <= src < n) or not (0 <= dst < n) or src == dst: + return False + for seq in (panel._taught, panel._point_motions): + seq.insert(dst, seq.pop(src)) + if panel._point_types is not None and len(panel._point_types) == n: + panel._point_types.insert(dst, panel._point_types.pop(src)) + panel._invalidate_results() + panel._refresh_point_list() + panel._keep_row_selected(dst) + panel._emit_taught_changed() + # 回灌链路(主窗口按机器人回写点表)若顺带重建了列表,选中会被抹掉—— + # 再兜一次,保证"原来选中的还是选中的" + panel._keep_row_selected(dst) + panel.statusMessage.emit(tr("P{a} 已移到 P{b},请重新校验").format(a=src + 1, b=dst + 1)) + return True + + +def shift_selected_point(panel, delta: int) -> bool: + """把当前选中路点上移(``delta=-1``)或下移(``delta=1``)。""" + index = panel._selected_index() + if index is None: + panel.statusMessage.emit(tr("请先在列表中选中要移动的路点")) + return False + return panel.move_point(index, index + delta) + + +def set_point_motion(panel, index: int, motion: str) -> bool: + """设置单个路点的运动类型(``"J"``=moveJ / ``"L"``=moveL / ``"C"``=moveC)。 + + ``"C"`` 不满足圆弧条件时**拒绝设置并说明原因**(见 :meth:`arc_rejection`)—— + 悄悄改成别的运动类型会让"我以为这是圆弧"变成现场查不动的差异。 + """ + if not (0 <= index < len(panel._taught)): + return False + kind = str(motion).upper() + if kind == "C": + reason = panel.arc_rejection(index) + if reason is not None: + panel.statusMessage.emit(reason) + return False + motion = kind if kind in ("L", "C") else "J" + if 0 <= index < len(panel._point_motions) and panel._point_motions[index] == motion: + return False + while index >= len(panel._point_motions): + panel._point_motions.append("J") + panel._point_motions[index] = motion + panel._invalidate_results() + panel._refresh_point_list() + panel._emit_taught_changed() + return True + + +def update_point( + panel, + index: int, + joints_deg: Sequence[float], + motion: Optional[str] = None, +) -> bool: + """写入路点的关节角(**度**)与可选的运动类型。 + + Args: + index: 路点序号 + joints_deg: 各关节角度值,顺序与 ``_joint_names`` 对齐 + motion: ``"J"/"L"/"C"``;``None`` 保持不变 + """ + if not (0 <= index < len(panel._taught)): + return False + pos = panel._taught[index] + for i, name in enumerate(panel._joint_names): + if i < len(joints_deg): + pos[name] = math.radians(float(joints_deg[i])) + if motion is not None: + panel.set_point_motion(index, motion) + panel._invalidate_results() + panel._refresh_point_list() + panel._emit_taught_changed() + return True + + +def edit_selected_point(panel) -> None: + """弹出对话框编辑当前选中路点的关节角与运动类型。""" + index = panel._selected_index() + if index is None or not panel._taught: + panel.statusMessage.emit(tr("请先在列表中选中要编辑的路点")) + return + joints = [math.degrees(float(panel._taught[index].get(n, 0.0))) for n in panel._joint_names] + motion = panel._point_motions[index] if index < len(panel._point_motions) else "J" + + dialog = QtWidgets.QDialog(panel) + dialog.setWindowTitle(tr("编辑路点 {n}").format(n=index + 1)) + form = QtWidgets.QFormLayout(dialog) + spins: List[QtWidgets.QDoubleSpinBox] = [] + for i, name in enumerate(panel._joint_names): + spin = QtWidgets.QDoubleSpinBox() + spin.setRange(-360.0, 360.0) + spin.setDecimals(2) + spin.setSuffix(" °") + if i < len(joints): + spin.setValue(joints[i]) + spins.append(spin) + form.addRow(name, spin) + combo = QtWidgets.QComboBox() + combo.addItem(tr("moveJ(关节插值)"), "J") + combo.addItem(tr("moveL(直线插值)"), "L") + combo.addItem(tr("moveC(圆弧,以上一点为中间点)"), "C") + combo.setCurrentIndex({"L": 1, "C": 2}.get(str(motion).upper(), 0)) + form.addRow(tr("运动类型"), combo) + if motion != "C": + hint = panel.arc_rejection(index) + hint_label = QtWidgets.QLabel( + tr("可设为 moveC(圆弧)") + if hint is None + else tr("不可设为 moveC:{reason}").format(reason=hint) + ) + hint_label.setWordWrap(True) + hint_label.setStyleSheet("color:#90caf9; font-size:11px;") + form.addRow(hint_label) + buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + buttons.accepted.connect(dialog.accept) + buttons.rejected.connect(dialog.reject) + form.addRow(buttons) + if dialog.exec() != QtWidgets.QDialog.Accepted: + return + panel.update_point( + index, + [spin.value() for spin in spins], + combo.currentData(), + ) + panel.statusMessage.emit(tr("{n} 已更新,请重新校验").format(n=index + 1)) + + +def _refresh_point_list(panel) -> None: + """按当前示教点数据重建列表,并套用最近一次校验的着色。 + + 圆弧(v1.1.5)会打破"每个路点恰好一条运动指令"的旧前提——圆弧的中间点 + 被并进上一条指令。因此节拍明细必须经路点→指令序号映射取值,**不能按下标 + 硬取**(否则圆弧之后的每一行节拍都会错位一行)。 + """ + report = panel._report + motion_times = report.motion_times if report is not None else None + labels = report.motion_labels if report is not None else None + levels = getattr(panel, "_point_issue_level", {}) if report is not None else {} + point_to_instruction = panel.instruction_of_point() if report is not None else {} + vias = panel.arc_via_indices() + + panel.point_list.clear() + for i, pos in enumerate(panel._taught): + mtype = panel._motion_kind(i) + if i in vias: + label = tr("moveC 中间点") + elif mtype == "C": + label = "moveC" + elif mtype == "L": + label = "moveL" + else: + label = "moveJ" + joints = ", ".join( + f"{math.degrees(float(pos.get(n, 0.0))):.1f}" for n in panel._joint_names + ) + text = f"P{i + 1} · {label} · [{joints}]" + instruction = point_to_instruction.get(i) + if ( + motion_times is not None + and instruction is not None + and instruction < len(motion_times) + ): + kind = labels[instruction] if labels and instruction < len(labels) else label + text += f" · {kind} {motion_times[instruction]:.2f}s" + item = QtWidgets.QListWidgetItem(text) + level = levels.get(i) + if level == "error": + item.setForeground(QtGui.QColor("#ff8080")) + item.setToolTip(tr("该校验错误涉及此路点(见校验结果)")) + elif level == "warning": + item.setForeground(QtGui.QColor("#ffc866")) + item.setToolTip(tr("该校验警告涉及此路点(见校验结果)")) + elif report is not None: + item.setForeground(QtGui.QColor("#6fdc6f")) + panel.point_list.addItem(item) + + +def _apply_point_colors(panel, report: "ProgramReport") -> None: + """从校验报告提取每个路点的最严重问题级别并刷新列表着色。 + + ``Issue.motion_index`` 是**运动指令**序号,而列表行是**路点**序号。无圆弧时 + 两者一一对应,有圆弧时中间点被并进上一条指令(指令数少于路点数),因此这里 + 反向映射指令→路点;指向中间点的指令把着色落在**圆弧终点**那一行——问题实际 + 出在圆弧段上,落在被并入的那一行最容易被看见。 + """ + panel._point_issue_level = panel._point_levels(report) + panel._refresh_point_list() diff --git a/app/views/program_export.py b/app/views/program_export.py new file mode 100644 index 0000000..f156562 --- /dev/null +++ b/app/views/program_export.py @@ -0,0 +1,240 @@ +"""离线编程面板的代码生成与导出:后处理出码、逐台导出、工程存盘快照。 + +「生成代码」与「导出全部…」共用同一套选中口径(视口里选中了哪台设备); +导出走 ``FileLink`` 离线部署通道,每台机器人各落一份**独立程序文件** +(各自的品牌扩展名、各自的机器人名)外加 ``.manifest.json`` 部署清单。 +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Dict, Optional + +from PySide6 import QtWidgets + +from domain.link import FileLink +from service.pickplace import PointType +from support.i18n import tr + +if TYPE_CHECKING: # 仅类型标注 + from service.program import Program + + from app.views.program_panel import ProgramPanel + + +def generate_code(panel) -> None: + """生成控制器代码并预览——口径与「回放」一致。 + + * 视口里**选中了某台设备**(机器人/其夹爪/被夹工件都算)→ 只出**那台**的代码; + * **没选中** → 一次给出**全部**机器人的代码(每台一段,带名字分隔), + 替代已删除的「导出全部…」。 + + 失败时如实抛错并清空预览——绝不留下"看起来成功"的废代码。 + """ + selected = None + provider = getattr(panel, "selection_robot_provider", None) + if callable(provider): + try: + selected = provider() + except Exception: + selected = None + if selected and selected in panel._states: + with panel._using_robot(selected): + panel._generate_active_code() + return + + codes = panel.generate_all_code() + if not codes: + return + # 没选中 = 用户要看"整条产线":逐台拼接,段与段之间用名字隔开 + blocks = [f"# ===== {name} =====\n{code}" for name, code in codes.items()] + panel.code_preview.setPlainText("\n\n".join(blocks)) + panel._last_code = codes.get(panel._active) or next(iter(codes.values())) + panel._refresh_export_all_enabled() + panel.statusMessage.emit(tr("未选中设备——已生成全部 {n} 台的代码").format(n=len(codes))) + + +def _generate_active_code(panel) -> None: + """按选择的后处理器生成**当前那台**的控制器代码并预览。""" + program = panel.build_program() + if program is None: + panel.statusMessage.emit(tr("请先加载机器人并示教至少一个点")) + return + choice = panel.combo_post.currentData() # None = 自动(按机器人品牌推断) + # 程序名用**场景里的机器人名**而不是 URDF 文件名:多机同型号时后者会 + # 重名,落出 second.src / second.src 互相覆盖 + prog_name = panel._active or (panel._robot.name if panel._robot else "program") + try: + code = program.to_post(choice, metadata={"program_name": prog_name}) + except Exception as exc: + panel._last_code = "" + panel.code_preview.setPlainText(tr("生成失败:\n{exc}").format(exc=exc)) + panel.statusMessage.emit(tr("生成失败: {exc}").format(exc=exc)) + return + panel._last_code = code + panel.code_preview.setPlainText(code) + panel.statusMessage.emit( + tr("已生成 {choice} 代码,共 {lines} 行").format( + choice=choice or tr("自动"), lines=len(code.splitlines()) + ) + ) + + +def export_all_programs(panel) -> None: + """把**每台**机器人各导出一份**独立程序文件**到同一个目录。 + + 走 ``FileLink`` 离线部署通道,**不要求**先点「生成代码」:内部逐台 + 构建自己的程序再后处理,各按自己的品牌落正确扩展名(UR ``.script`` + / KUKA ``.src`` / ABB ``.mod`` / FANUC ``.tp``…),文件名与程序名用 + 场景里的**机器人名**(多机同型号也不会互相覆盖),并各写 + ``.manifest.json`` 部署清单。 + + 选中了设备时只导那一台(与「生成代码」同一套选中口径);没选就逐台导。 + """ + selected = None + provider = getattr(panel, "selection_robot_provider", None) + if callable(provider): + try: + selected = provider() + except Exception: + selected = None + targets = [selected] if selected and selected in panel._states else list(panel._states) + if not targets: + panel.statusMessage.emit(tr("请先加载机器人并示教至少一个点")) + return + dest = QtWidgets.QFileDialog.getExistingDirectory(panel, tr("选择导出目录"), str(Path.cwd())) + if not dest: + return + ok = 0 + for name in targets: + program = panel.build_program(name) + if program is None: + panel.statusMessage.emit(tr("{name}:无示教点,已跳过").format(name=name)) + continue + if panel.export_program_to(program, dest, robot=name): + ok += 1 + if ok == 0: + return + panel.statusMessage.emit( + tr("已导出 {n} 台机器人的独立程序到 {dest}").format(n=ok, dest=dest) + ) + + +def export_program_to(panel, program: "Program", dest: str, robot: Optional[str] = None) -> bool: + """把 ``program`` 导出到 ``dest`` 目录(无 UI 逻辑,供测试复用)。 + + Args: + program: 已构建好的程序 + dest: 目标目录 + robot: 这台机器人的名字(决定文件名与其槽位写入);``None`` 取当前 + + Returns: + 成功 ``True``;后处理/落盘失败 ``False``(状态栏带原因) + """ + with panel._using_robot(robot): + return panel._export_impl(program, dest) + + +def _export_impl(panel, program: "Program", dest: str) -> bool: + name = panel._active or (panel._robot.name if panel._robot else "program") + try: + link = FileLink(dest) + link.connect() + deployment = program.deploy( + link, + panel.combo_post.currentData(), + name=name, + metadata={"program_name": name}, + ) + except Exception as exc: + panel.statusMessage.emit(tr("导出失败: {exc}").format(exc=exc)) + return False + panel._last_code = deployment.code + panel._refresh_export_all_enabled() + note = tr("({note})").format(note=deployment.notes) if deployment.notes else "" + panel.statusMessage.emit( + tr("已导出:{handle}{note}").format(handle=deployment.handle, note=note) + ) + return True + + +def robot_snapshot(panel) -> Dict[str, dict]: + """各台机器人的示教点与抓取语义快照,供工程存盘。 + + 工程里原先只有一份 ``program`` 段(来自只显示当前那台的路径编辑器), + 多机工程存盘再打开会丢掉其余机器人教好的抓取循环——这段就是为此加的。 + + Returns: + ``{机器人名: {"joint_names": [...], "positions": [{关节名: 弧度}], + "point_types": [...]}}`` + """ + out: Dict[str, dict] = {} + for name, st in panel._states.items(): + if st.robot is None: + continue + out[name] = { + "joint_names": list(st.joint_names), + "positions": [{k: float(v) for k, v in p.items()} for p in st.taught], + "point_types": [getattr(t, "value", None) for t in (st.point_types or [])], + } + return out + + +def restore_robots(panel, data: Optional[dict]) -> None: + """把 :meth:`robot_snapshot` 的快照灌回各台机器人。 + + 场景里已经没有的机器人直接跳过——存档比场景多是正常的(用户删过机器 + 人),不该为它凭空造一个条目出来。语义数量对不上时按默认语义重建, + 绝不猜哪个是抓取点。 + """ + for name, payload in (data or {}).items(): + if name not in panel._states: + continue + positions = payload.get("positions") or [] + if not positions: + continue + raw = [ + t + for t in (payload.get("point_types") or []) + if t not in (None, "", "None", "null") + ] + types = None + if raw and len(raw) == len(positions): + try: + types = [PointType(t) for t in raw] + except ValueError: + types = None + panel.set_taught_points(positions, types, robot=name) + + +def generate_all_code(panel) -> Dict[str, str]: + """给**每一台**机器人各生成一份目标控制器代码。 + + 后处理器统一用下拉里选的那个(``None`` = 各自按品牌推断),这样一次按钮 + 就能拿到"整条产线"的程序,而不是逐台点一遍。 + + Returns: + ``{机器人名: 代码}``;某台生成失败时不进结果,原因已广播到状态栏 + """ + codes: Dict[str, str] = {} + if not panel._states: + panel.statusMessage.emit(tr("请先加载机器人并示教至少一个点")) + return codes + choice = panel.combo_post.currentData() + for name in list(panel._states): + program = panel.build_program(name) + if program is None: + panel.statusMessage.emit(tr("{name}:无示教点,已跳过").format(name=name)) + continue + try: + codes[name] = program.to_post(choice, metadata={"program_name": name}) + except Exception as exc: + panel.statusMessage.emit(tr("{name} 生成失败:{exc}").format(name=name, exc=exc)) + # 预览仍保持当前这台:点了"导出全部"就把预览换成别人家的代码,很容易 + # 让人以为当前编辑的那份丢了 + if panel._active in codes: + panel._last_code = codes[panel._active] + panel.code_preview.setPlainText(codes[panel._active]) + if codes: + panel.statusMessage.emit(tr("已生成 {n} 台机器人的代码").format(n=len(codes))) + return codes diff --git a/app/views/program_grasp.py b/app/views/program_grasp.py new file mode 100644 index 0000000..e8ccfa2 --- /dev/null +++ b/app/views/program_grasp.py @@ -0,0 +1,322 @@ +"""离线编程面板的抓取向导:由抓取点/放置点生成完整抓取循环。 + +用户只需把机器人摆到"要抓的地方"和"要放的地方"各示教一次,接近 / 提升 / +撤离这些安全高度点由向导按竖直偏移(世界 +Z,毫米)算出来——这是示教里 +最费时间、也最容易教歪的一步。逆解失败时**不返回残缺序列**:宁可让用户 +调小高度重试,也不要给一串看着完整、实则缺动作的示教点。 +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple + +from PySide6 import QtWidgets + +from domain.kinematics import KinematicsError +from domain.pose import Pose +from service.pickplace import PointType, default_types_for_count +from support.i18n import tr + +if TYPE_CHECKING: # 仅类型标注 + from app.views.program_panel import ProgramPanel + + +def grasp_cycle_points( + panel, + pick_index: int, + place_index: Optional[int] = None, + approach_mm: float = 50.0, + lift_mm: float = 50.0, + retract_mm: float = 50.0, + robot: Optional[str] = None, +) -> Tuple[List[Dict[str, float]], List[PointType], List[str]]: + """由一个**抓取点**(加可选放置点)生成完整的抓取循环点位。 + + 用户只需把机器人摆到"要抓的地方"和"要放的地方"各示教一次,接近 / 提升 / + 撤离这些安全高度点由向导按竖直偏移算出来——这是示教里最费时间、也最容 + 易教歪的一步(手抖一下,接近点就可能离工件太远或撞上去)。 + + 偏移沿**世界竖直方向(+Z)**、单位毫米:接近点在抓取点上方 + ``approach_mm``,提升点在抓取点上方 ``lift_mm``,撤离点在放置点上方 + ``retract_mm``。 + + Args: + pick_index: 抓取点在当前示教点里的下标(0 起) + place_index: 放置点下标;``None`` 表示只生成抓取侧(接近/抓取/提升) + approach_mm / lift_mm / retract_mm: 竖直偏移,毫米 + robot: 算哪台机器人的;``None`` 表示当前这台 + + Returns: + ``(positions, types, motions)``——positions 为 ``{关节名: 弧度}``, + types 为对应的 :class:`PointType`,motions 为运动类型 + (竖直升降段用 moveL,转运段用 moveJ) + + Raises: + KinematicsError: 条件不足,或某个偏移点逆解失败(超出工作空间 / + 奇异位形)。**不返回残缺序列**——少一个点就不是完整的抓取循环, + 宁可让用户把高度调小重试,也不要给一串看着完整、实则缺动作的 + 示教点 + """ + with panel._using_robot(robot) as state: + if state.robot is None or not state.joint_names: + raise KinematicsError(tr("还没有载入机器人,无法计算抓取点位")) + taught = state.taught + names = state.joint_names + if not taught: + raise KinematicsError(tr("还没有示教点——请先把机器人摆到抓取位并记录一个点")) + if not (0 <= pick_index < len(taught)): + raise KinematicsError( + tr("抓取点下标 {i} 超出示教点范围(共 {n} 个)").format( + i=pick_index, n=len(taught) + ) + ) + if place_index is not None and not (0 <= place_index < len(taught)): + raise KinematicsError( + tr("放置点下标 {i} 超出示教点范围(共 {n} 个)").format( + i=place_index, n=len(taught) + ) + ) + + def joints_at(index: int) -> List[float]: + """第 ``index`` 个示教点的关节值(度)。""" + return [math.degrees(float(taught[index].get(n, 0.0))) for n in names] + + def lifted(index: int, dz: float, guess: List[float], label: str) -> List[float]: + """把第 ``index`` 个示教点的 TCP 沿世界 +Z 抬高 ``dz`` 后逆解。 + + 以 ``guess`` 为初值(这里传抓取/放置位形本身):目标就在正上方, + 解出来自然与示教位形同构,不会翻腕翻到另一个姿态去。 + """ + pose = state.robot.forward_kinematics(joints_at(index)) + target = pose.matrix.copy() + target[2, 3] += float(dz) + try: + return [ + float(v) for v in state.robot.inverse_kinematics(Pose(target), guess=guess) + ] + except KinematicsError as exc: + raise KinematicsError( + tr("{label}(抬高 {dz:.0f} mm)逆解失败:{msg}").format( + label=label, dz=dz, msg=exc + ) + ) from exc + + pick_q = joints_at(pick_index) + + def merged(source_index: int, q: List[float]) -> Dict[str, float]: + """把解出的可动轴覆盖到来源示教点上,其余关节沿用示教值。 + + 末端工具轴(``tcp_joint``)这类非驱动关节不在 ``names`` 里,只按 + 可动轴重建字典就会把它们整条丢掉——生成的点与用户示教的点结构 + 对不上,写回场景时工具的关节值被悄悄抹成 0。 + """ + out = {n: float(v) for n, v in taught[source_index].items()} + out.update({n: math.radians(float(q[i])) for i, n in enumerate(names)}) + return out + + sequence = [ + ( + pick_index, + lifted(pick_index, approach_mm, pick_q, tr("接近点")), + PointType.APPROACH, + "J", + ), + (pick_index, pick_q, PointType.PICK, "L"), + ( + pick_index, + lifted(pick_index, lift_mm, pick_q, tr("提升点")), + PointType.LIFT, + "L", + ), + ] + if place_index is not None: + place_q = joints_at(place_index) + sequence += [ + (place_index, place_q, PointType.PLACE, "J"), + ( + place_index, + lifted(place_index, retract_mm, place_q, tr("撤离点")), + PointType.RETRACT, + "L", + ), + ] + + positions = [merged(src, q) for src, q, _t, _m in sequence] + return ( + positions, + [t for _src, _q, t, _m in sequence], + [m for _src, _q, _t, m in sequence], + ) + + +def apply_grasp_cycle( + panel, + pick_index: int, + place_index: Optional[int] = None, + approach_mm: float = 50.0, + lift_mm: float = 50.0, + retract_mm: float = 50.0, + replace: bool = False, + robot: Optional[str] = None, +) -> int: + """生成抓取循环并写入 ``robot`` 的示教点。 + + Args: + replace: ``True`` 用生成的序列**替换**原有示教点;``False``(默认) + **追加**到末尾。默认追加:原有示教点是用户一个个摆出来的,凭 + 一次向导就抹掉太可惜——真要替换得由用户明确勾选 + robot: 写到哪台;``None`` 表示当前这台 + + Returns: + 写入后的示教点总数 + + Raises: + KinematicsError: 点位算不出来(见 :meth:`grasp_cycle_points`)。 + 此时**不改动**任何示教点 + """ + new_pos, new_types, new_motions = panel.grasp_cycle_points( + pick_index, place_index, approach_mm, lift_mm, retract_mm, robot + ) + with panel._using_robot(robot) as state: + cur = [dict(p) for p in state.taught] + if replace or not cur: + positions, types, motions = new_pos, new_types, new_motions + else: + cur_types = state.point_types + if cur_types is None or len(cur_types) != len(cur): + # 语义缺失或长度对不上时按默认补齐——宁可给用户一份能跑的 + # 猜测,也不要让追加后整串语义错位 + cur_types = default_types_for_count(len(cur)) + cur_motions = ( + list(state.point_motions) + if len(state.point_motions) == len(cur) + else ["J"] * len(cur) + ) + positions = cur + new_pos + types = list(cur_types) + new_types + motions = cur_motions + new_motions + panel.set_taught_points(positions, types, motions, robot=robot) + name = robot or panel._active + # 面板内部的编辑广播不带机器人名(多机会串台),故走专用信号;当前这台 + # 额外走一次既有广播,让路径代码编辑器同步(与轨迹编辑器改点同一条路) + panel.graspCycleApplied.emit(name or "") + if name == panel._active: + panel._emit_taught_changed() + panel.statusMessage.emit( + tr("抓取向导已生成 {n} 个点({seq})").format( + n=len(new_pos), + seq=" → ".join(t.label for t in new_types), + ) + ) + return len(positions) + + +def open_grasp_wizard(panel, robot: Optional[str] = None) -> bool: + """弹出抓取向导对话框,按用户给的两个示教点生成完整抓取循环。 + + Args: + robot: 对哪台机器人用向导;``None`` 表示当前这台 + + Returns: + 确实写入了点位 ``True``;用户取消或算不出来 ``False`` + """ + with panel._using_robot(robot) as state: + taught = state.taught + name = robot or panel._active + if not taught: + QtWidgets.QMessageBox.information( + panel, + tr("抓取向导"), + tr("请先记录至少一个示教点——把机器人摆到要抓的位置,点「记录」"), + ) + return False + if state.robot is None: + QtWidgets.QMessageBox.information( + panel, tr("抓取向导"), tr("还没有载入机器人,无法计算抓取点位") + ) + return False + + dlg = QtWidgets.QDialog(panel) + dlg.setWindowTitle(tr("抓取向导")) + form = QtWidgets.QFormLayout(dlg) + + def point_label(i: int) -> str: + return tr("P{n}").format(n=i + 1) + + combo_pick = QtWidgets.QComboBox() + combo_place = QtWidgets.QComboBox() + for i in range(len(taught)): + combo_pick.addItem(point_label(i), i) + combo_place.addItem(point_label(i), i) + combo_place.addItem(tr("(不生成放置侧)"), -1) + # 默认:抓取点取当前选中行,放置点默认不选(多数人先只想把抓取侧做出来) + selected = panel._selected_index() + combo_pick.setCurrentIndex(selected if selected is not None else len(taught) - 1) + combo_place.setCurrentIndex(len(taught)) # 最后一项 = 不生成 + + spin_approach = QtWidgets.QDoubleSpinBox() + spin_lift = QtWidgets.QDoubleSpinBox() + spin_retract = QtWidgets.QDoubleSpinBox() + for spin, value in ((spin_approach, 50.0), (spin_lift, 50.0), (spin_retract, 50.0)): + spin.setRange(0.0, 2000.0) + spin.setDecimals(1) + spin.setSuffix(" mm") + spin.setValue(value) + spin_retract.setEnabled(False) + + check_replace = QtWidgets.QCheckBox(tr("替换原有示教点(默认追加到末尾)")) + + preview = QtWidgets.QLabel(tr("接近点 → 抓取点 → 提升点")) + preview.setWordWrap(True) + + def refresh_preview() -> None: + spin_retract.setEnabled(combo_place.currentData() != -1) + seq = [tr("接近点"), tr("抓取点"), tr("提升点")] + if combo_place.currentData() != -1: + seq += [tr("放置点"), tr("撤离点")] + preview.setText(tr("将生成 {n} 个点:{seq}").format(n=len(seq), seq=" → ".join(seq))) + + combo_place.currentIndexChanged.connect(lambda _i: refresh_preview()) + refresh_preview() + + form.addRow(tr("抓取点"), combo_pick) + form.addRow(tr("放置点"), combo_place) + form.addRow(tr("接近高度(抓取点上方)"), spin_approach) + form.addRow(tr("提升高度(抓取点上方)"), spin_lift) + form.addRow(tr("撤离高度(放置点上方)"), spin_retract) + form.addRow(check_replace) + form.addRow(preview) + + buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + buttons.button(QtWidgets.QDialogButtonBox.Ok).setText(tr("生成")) + buttons.button(QtWidgets.QDialogButtonBox.Cancel).setText(tr("取消")) + buttons.accepted.connect(dlg.accept) + buttons.rejected.connect(dlg.reject) + form.addRow(buttons) + + if not dlg.exec(): + return False + + place = combo_place.currentData() + try: + panel.apply_grasp_cycle( + pick_index=int(combo_pick.currentData()), + place_index=None if place == -1 else int(place), + approach_mm=spin_approach.value(), + lift_mm=spin_lift.value(), + retract_mm=spin_retract.value(), + replace=check_replace.isChecked(), + robot=name, + ) + except KinematicsError as exc: + # 逆解失败不能悄无声息:告诉用户是哪个点、抬了多少,让他调小高度重试 + QtWidgets.QMessageBox.warning( + panel, + tr("抓取向导"), + tr("点位算不出来,示教点未改动:\n{msg}").format(msg=exc), + ) + return False + return True diff --git a/app/views/program_panel.py b/app/views/program_panel.py index 080aff1..d4129ac 100644 --- a/app/views/program_panel.py +++ b/app/views/program_panel.py @@ -10,124 +10,42 @@ 单位:面板内部一律 **度**(与 :class:`~service.planner` 一致);向视口发送 回放帧时换算为 **弧度**(``GLWidget.renderer.joint_values`` 用弧度)。 -""" -# TECH_DEBT: 存量 GUI 债务(ADR-002)——迁移到 app/views 之前冻结新功能; -# 清单见 tools/check_arch_rules.py 的 GUI_DEBT,口径见 docs/develop/CODING_RULES.md §3.2。 +**结构(v2 类内拆解)**:本类只留信号、构造、多机状态槽代理与各方法的薄 stub; +实现按职责拆到同目录 ``program_state`` / ``program_ui`` / ``program_robot`` / +``program_playback`` / ``program_grasp`` / ``program_export`` / ``program_editor`` / +``program_validate`` 八个模块,每个被拆出的方法是目标模块里以 ``panel`` 为 +第一参数的模块级函数。 +""" from __future__ import annotations -import math from contextlib import contextmanager -from dataclasses import dataclass, field -from pathlib import Path from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple -from PySide6 import QtCore, QtGui, QtWidgets - -from service.planner import ProgramReport, SEVERITY_ERROR, TrajectoryPlanner -from service.pickplace import ( - PointType, - default_types_for_count, - gripper_action_for, -) -from service.program import Program, ProgramBuilder -from domain.pose import Pose -from domain.kinematics import KinematicsError -from adapter.post import get_post_processor, list_post_processors -from domain.world.animation import GraspAnimator -from domain.link import FileLink -from app.contracts.program import PlaybackFrame -from app.features import enabled as _feature_enabled -from app.logging import logger -from support.i18n import tr +from PySide6 import QtCore, QtWidgets + +from app.features import enabled as _feature_enabled # noqa: F401 —— 测试补丁打在本模块此名上 +from app.views import program_editor as _program_editor +from app.views import program_export as _program_export +from app.views import program_grasp as _program_grasp +from app.views import program_playback as _program_playback +from app.views import program_robot as _program_robot +from app.views import program_ui as _program_ui +from app.views import program_validate as _program_validate +from app.views.program_state import PLAYBACK_INTERVAL_MS, RobotProgram +from app.views.program_ui import PANEL_STYLE if TYPE_CHECKING: # 仅类型标注 from domain.robot import Robot - from service.simulation import Trajectory from domain.world.scene import Scene + from service.pickplace import PointType + from service.planner import ProgramReport + from service.program import Program + from service.simulation import Trajectory __all__ = ["ProgramPanel", "PANEL_STYLE"] -#: 回放帧率对应的定时器间隔 (ms) -PLAYBACK_INTERVAL_MS = 40 - -PANEL_STYLE = """ -QWidget { background-color: #2b2b2b; color: #e0e0e0; font-size: 12px; } -QGroupBox { - border: 1px solid #555; border-radius: 4px; - /* margin-top 必须容纳一行标题文字(约16px),否则标题上半截会被部件顶边裁掉 */ - margin-top: 16px; padding-top: 8px; font-weight: bold; -} -QGroupBox::title { - subcontrol-origin: margin; subcontrol-position: top left; - left: 8px; padding: 0 4px; -} -QPushButton { - background-color: #3c3c3c; border: 1px solid #666; - border-radius: 3px; padding: 5px 10px; -} -QPushButton:hover { background-color: #4a4a4a; } -QPushButton:pressed { background-color: #555; } -QPushButton:disabled { color: #777; background-color: #333; } -QListWidget { background-color: #1e1e1e; border: 1px solid #555; border-radius: 3px; } -QLabel#metric { color: #7fd3ff; font-weight: bold; } -QLabel#ok { color: #6fdc6f; font-weight: bold; } -QLabel#bad { color: #ff8080; font-weight: bold; } -QSpinBox { background-color: #1e1e1e; border: 1px solid #555; padding: 3px; } -""" - - -def _button_grid(buttons, columns=2): - """把按钮排成**等宽网格**——侧栏里的按钮组一律用它,不要再用单行 QHBoxLayout。 - - 这个面板在右侧栏里只有 260–360px。一行摆 4–5 个按钮时 ``QHBoxLayout`` 不会 - 换行,只会把每个按钮压到 minimumSize 以下(文字被截成几个字甚至只剩色块), - 并且——更糟的——它会把内容**撑到比可视区更宽**(实测最小内容宽约 400px), - 于是右侧一整列被裁到看不见,用户看到的就是"控件重叠/显示不全"。 - 网格让它按列数自己排,窄面板里也保持可读。 - - :param columns: 每行几个;末行只剩一个时自动跨满整行,不留半边空白。 - """ - grid = QtWidgets.QGridLayout() - grid.setHorizontalSpacing(4) - grid.setVerticalSpacing(4) - span_all = len(buttons) % columns == 1 - for i, btn in enumerate(buttons): - btn.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) - row, col = divmod(i, columns) - # 末行落单的那个跨满整行 - last = i == len(buttons) - 1 - grid.addWidget(btn, row, col, 1, columns if (last and span_all) else 1) - return grid - - -@dataclass -class RobotProgram: - """一台机器人的**私有**离线编程状态。 - - 面板的所有既有方法(``build_program`` / ``validate_program`` / - ``start_playback`` …)写的都是"当前机器人的数据"。以前这些数据直接是 - 面板的字段,多机时第二台会把第一台顶掉——现在每台各占一个本类的实例, - 面板按 :attr:`ProgramPanel._active` 取用。 - """ - - robot: Optional["Robot"] = None - joint_names: List[str] = field(default_factory=list) - taught: List[Dict[str, float]] = field(default_factory=list) - point_types: Optional[List[PointType]] = None - point_motions: List[str] = field(default_factory=list) - build_notes: List[str] = field(default_factory=list) - trajectory: Optional["Trajectory"] = None - report: Optional[ProgramReport] = None - planner: Optional[TrajectoryPlanner] = None - gripper_animator: Optional[GraspAnimator] = None - last_gripper_state: str = "" - point_issue_level: Dict[int, str] = field(default_factory=dict) - last_point_count: Optional[int] = None - last_code: str = "" - playhead: float = 0.0 - class ProgramPanel(QtWidgets.QWidget): """离线编程面板。 @@ -356,207 +274,6 @@ def _playhead(self): def _playhead(self, v): self._state().playhead = v - # ---------------------------- 界面 ---------------------------- # - - def _build_ui(self) -> None: - # 内容自然高度约 798px,而标签页可视高度随窗口变化(1280×800 时仅 725px)。 - # 不套滚动容器会把底部的「后处理与导出」挤出可视区——与右侧属性面板 - # 曾经踩过的坑同源。 - outer = QtWidgets.QVBoxLayout(self) - outer.setContentsMargins(0, 0, 0, 0) - scroll = QtWidgets.QScrollArea() - scroll.setWidgetResizable(True) - scroll.setFrameStyle(QtWidgets.QFrame.NoFrame) - inner = QtWidgets.QWidget() - scroll.setWidget(inner) - outer.addWidget(scroll) - - root = QtWidgets.QVBoxLayout(inner) - root.setContentsMargins(6, 6, 6, 6) - - # --- 多机器人选择:出码/校验/回放全部按这里选中的那台走 --- - robot_row = QtWidgets.QHBoxLayout() - robot_row.addWidget(QtWidgets.QLabel(tr("机器人"))) - self.combo_robot = QtWidgets.QComboBox() - self.combo_robot.setSizeAdjustPolicy(QtWidgets.QComboBox.SizeAdjustPolicy.AdjustToContents) - self.combo_robot.currentIndexChanged.connect(self._on_robot_combo_changed) - robot_row.addWidget(self.combo_robot, 1) - root.addLayout(robot_row) - - # --- 节拍与规模 --- - box = QtWidgets.QGroupBox(tr("程序概览")) - form = QtWidgets.QFormLayout(box) - self.lbl_status = QtWidgets.QLabel(tr("未加载机器人")) - self.lbl_status.setObjectName("bad") - form.addRow(tr("状态"), self.lbl_status) - self.lbl_cycle = QtWidgets.QLabel("—") - self.lbl_cycle.setObjectName("metric") - form.addRow(tr("估算节拍"), self.lbl_cycle) - self.lbl_length = QtWidgets.QLabel("—") - self.lbl_length.setObjectName("metric") - form.addRow(tr("TCP 路径"), self.lbl_length) - self.lbl_points = QtWidgets.QLabel("—") - form.addRow(tr("路径点"), self.lbl_points) - root.addWidget(box) - - # --- 操作 --- - ops = QtWidgets.QGroupBox(tr("操作")) - ops_layout = QtWidgets.QVBoxLayout(ops) - - speed_row = QtWidgets.QHBoxLayout() - speed_row.addWidget(QtWidgets.QLabel(tr("进给速度"))) - self.spin_speed = QtWidgets.QSpinBox() - self.spin_speed.setRange(1, 100) - self.spin_speed.setValue(100) - self.spin_speed.setSuffix(" %") - speed_row.addWidget(self.spin_speed, 1) - ops_layout.addLayout(speed_row) - - # 「校验程序 / 回放」这组**单机**按钮删掉了:和「校验全部 / 全部回放」 - # 完全重复——只有一台时"全部"就是那一台,两排按钮只会让人疑惑该点哪个 - # (2026-09-21 用户反馈)。F7 / Shift+F7 快捷键保留,走主窗口菜单。 - # 顺序按用户要求:校验全部 → 全部回放 → 停止 → 抓取向导。 - self.btn_validate_all = QtWidgets.QPushButton(tr("校验全部")) - self.btn_validate_all.setToolTip( - tr("逐台校验所有机器人并汇总结果——不必一台一台切过去按 F7") - ) - self.btn_validate_all.clicked.connect(lambda: self.validate_all()) - self.btn_play_all = QtWidgets.QPushButton(tr("全部回放")) - self.btn_play_all.setToolTip(tr("所有已校验的机器人同时回放——检查多机协同是否撞车")) - # ``clicked`` 带一个 bool 参数:**不能**直接连到带可选参数的槽—— - # ``start_playback_all()`` 这类槽一律无参,或用 lambda 吃掉 checked。 - self.btn_play_all.clicked.connect(self.start_playback_all) - self.btn_play_all.setEnabled(False) - self.btn_stop = QtWidgets.QPushButton(tr("停止")) - self.btn_stop.clicked.connect(self.stop_playback) - self.btn_stop.setEnabled(False) - # 单机也要有:示教是逐台做的活,跟有几台机器人无关 - self.btn_grasp_wizard = QtWidgets.QPushButton(tr("抓取向导…")) - self.btn_grasp_wizard.setToolTip( - tr("由抓取点/放置点自动生成接近、提升、撤离点——不必逐个手动示教") - ) - self.btn_grasp_wizard.clicked.connect(lambda: self.open_grasp_wizard()) - ops_layout.addLayout( - _button_grid( - [ - self.btn_validate_all, - self.btn_play_all, - self.btn_stop, - self.btn_grasp_wizard, - ] - ) - ) - root.addWidget(ops) - - # --- 轨迹编辑器(v0.2.4):路点增删移改 + 运动类型切换 --- - editor = QtWidgets.QGroupBox(tr("轨迹编辑")) - editor_layout = QtWidgets.QVBoxLayout(editor) - - self.point_list = QtWidgets.QListWidget() - self.point_list.setAlternatingRowColors(True) - self.point_list.setWordWrap(True) - self.point_list.itemDoubleClicked.connect(lambda _item: self.edit_selected_point()) - editor_layout.addWidget(self.point_list, 1) - - self.btn_insert_point = QtWidgets.QPushButton(tr("插入")) - self.btn_insert_point.setToolTip(tr("在选中点之后插入一个路点(复制其关节值)")) - # 同 btn_play:把 ``clicked`` 的 bool 吃掉,否则 False 会被当成第 0 行 - self.btn_insert_point.clicked.connect(lambda _checked=False: self.insert_point()) - self.btn_delete_point = QtWidgets.QPushButton(tr("删除")) - self.btn_delete_point.clicked.connect(lambda _checked=False: self.delete_point()) - self.btn_edit_point = QtWidgets.QPushButton(tr("编辑位姿…")) - self.btn_edit_point.setToolTip(tr("修改选中路点的关节角(度)与运动类型(双击同效)")) - self.btn_edit_point.clicked.connect(self.edit_selected_point) - editor_layout.addLayout( - _button_grid([self.btn_insert_point, self.btn_delete_point, self.btn_edit_point]) - ) - - self.btn_move_up = QtWidgets.QPushButton(tr("上移")) - self.btn_move_up.clicked.connect(lambda: self.shift_selected_point(-1)) - self.btn_move_down = QtWidgets.QPushButton(tr("下移")) - self.btn_move_down.clicked.connect(lambda: self.shift_selected_point(1)) - # 运动类型(moveJ/moveL/moveC)**不在这里改**——面板只留位置调整。 - # 要改指令类型去「编辑位姿…」对话或直接在路径代码里改:放在列表旁一排 - # 按钮反而容易误点,且与代码里的 ``movej`` 行两处可改、互不一致。 - editor_layout.addLayout(_button_grid([self.btn_move_up, self.btn_move_down])) - - hint = QtWidgets.QLabel( - tr( - "改动后需重新校验:着色与节拍会随校验结果刷新" - "(红=错误 / 橙=警告 / 绿=正常)\n" - "圆弧口径:第 N 行的运动类型描述「到达第 N 点」的运动——" - "moveC 以上一点为中间点、再上一点为起点;中间点行显示为「moveC 中间点」" - ) - ) - hint.setWordWrap(True) - editor_layout.addWidget(hint) - root.addWidget(editor) - - # --- 问题清单 --- - issues = QtWidgets.QGroupBox(tr("校验结果")) - issues_layout = QtWidgets.QVBoxLayout(issues) - self.issue_list = QtWidgets.QListWidget() - self.issue_list.setAlternatingRowColors(True) - self.issue_list.setWordWrap(True) - issues_layout.addWidget(self.issue_list) - root.addWidget(issues, 1) - - # --- 设备联调:后处理 / 导出 / 真机(预留) --- - comm = QtWidgets.QGroupBox(tr("后处理与导出")) - comm_layout = QtWidgets.QVBoxLayout(comm) - - pp_row = QtWidgets.QHBoxLayout() - pp_row.addWidget(QtWidgets.QLabel(tr("后处理器"))) - self.combo_post = QtWidgets.QComboBox() - self.combo_post.addItem(tr("自动(按机器人品牌)"), None) - for name in list_post_processors(): - pp = get_post_processor(name) - self.combo_post.addItem(f"{pp.brand.upper()} {pp.controller.upper()}", name) - pp_row.addWidget(self.combo_post, 1) - comm_layout.addLayout(pp_row) - - self.btn_generate = QtWidgets.QPushButton(tr("生成代码")) - self.btn_generate.clicked.connect(self.generate_code) - self.btn_generate.setEnabled(False) - # 「一键导出…」(只导当前那台)去掉了:实际产线是多台的,逐个点太笨 - # (2026-09-21 用户反馈)。导出入口统一为「导出全部…」:每台机器人 - # 各落一份**独立程序文件**——各自的品牌扩展名、各自的机器人名, - # 不是拼成一个大文件。 - self.btn_export_all = QtWidgets.QPushButton(tr("导出全部…")) - self.btn_export_all.setToolTip( - tr( - "为每一台机器人各落一份独立程序文件到同一目录" - "(UR .script / KUKA .src / ABB .mod / FANUC .tp)" - ) - ) - self.btn_export_all.clicked.connect(self.export_all_programs) - self.btn_export_all.setEnabled(False) - comm_layout.addLayout(_button_grid([self.btn_generate, self.btn_export_all])) - - self.code_preview = QtWidgets.QTextEdit() - self.code_preview.setReadOnly(True) - self.code_preview.setLineWrapMode(QtWidgets.QTextEdit.NoWrap) - mono = QtGui.QFont("Consolas", 9) - mono.setStyleHint(QtGui.QFont.Monospace) - self.code_preview.setFont(mono) - self.code_preview.setFixedHeight(150) - self.code_preview.setPlaceholderText(tr("生成的控制器代码将显示在这里")) - comm_layout.addWidget(self.code_preview) - - # 「上传真机 / 真机验证」:从未上过真机验证,MVP 档**藏掉**而不是置灰 - # ——灰色按钮同样会被当成产品能力(2026-09-21 用户反馈:等 MVP 做完 - # 再开启)。CORE_FEATURES=full 或放开清单即恢复。 - if _feature_enabled("real_machine_upload"): - self.btn_upload = QtWidgets.QPushButton(tr("上传真机")) - self.btn_upload.setEnabled(False) - self.btn_upload.setToolTip(tr("预留:接入 ControllerLink 后支持直连控制器上传")) - self.btn_verify = QtWidgets.QPushButton(tr("真机验证")) - self.btn_verify.setEnabled(False) - self.btn_verify.setToolTip(tr("预留:接入 ControllerLink.read_joints 后做真机回读比对")) - comm_layout.addLayout(_button_grid([self.btn_upload, self.btn_verify])) - - root.addWidget(comm) - # ---------------------------- 外部注入 ---------------------------- # def set_robot( @@ -565,802 +282,91 @@ def set_robot( joint_names: Optional[Sequence[str]] = None, name: Optional[str] = None, ) -> None: - """登记一台机器人:**按名字各自留存**,多机不再互相顶掉。 - - Args: - robot: 机器人实例;``None`` 表示卸载 ``name``(或当前这台) - joint_names: 示教点字典里用的关节名(视口渲染器的命名,可能比 - 可驱动关节多——URDF 里的 ``fixed`` 关节,如 ``tcp_joint``, - 也会被渲染器登记)。为 ``None`` 时取 ``robot.joint_names`` - name: 场景里的机器人名。缺省取 ``robot.name``。登记后自动切到它, - 这样示教/校验/出码/回放都作用在刚加进来的那台上 - - Note: - 这里必须把关节名过滤到**可驱动**的那些。渲染器会把 fixed 关节 - 一并登记,照单全收的话 ``moveJ`` 会收到多出来的分量,直接被 - 自由度校验拒掉——表现为"每条指令都失败",极难排查。 - """ - if robot is None: - self.remove_robot(name or self._active) - return - - key = name or robot.name - # 必须先把「当前」指过去:下面的赋值都经由代理属性落到这一台的槽里 - self._active = key - state = self._ensure_state(key) - - self.stop_playback() - state.trajectory = None - state.report = None - state.point_issue_level = {} - state.build_notes = [] - self.issue_list.clear() - - source = list(joint_names or robot.joint_names) - actuated = list(robot.joint_names) - known = set(actuated) - filtered = [n for n in source if n in known] - # 命名对不上时宁可退回权威列表,也不要留下长度不匹配的隐患 - state.joint_names = filtered if len(filtered) == len(actuated) else actuated - self._robot = robot - self.btn_generate.setEnabled(True) - # 「导出全部」的门槛与「生成代码」完全相同——它内部自己构建程序 - # (见 export_all_programs 的文档),不该逼用户先点一次「生成代码」才亮。 - self._refresh_export_all_enabled() - self.lbl_status.setText(tr("{name} · 就绪").format(name=key)) - self.lbl_status.setObjectName("ok") - # 重新套用样式表,objectName 改变后需手动刷新 - self.lbl_status.style().polish(self.lbl_status) - self._refresh_robot_combo(key) + return _program_robot.set_robot(self, robot, joint_names, name) # ---------------------------- 多机器人 ---------------------------- # @property def active_robot(self) -> Optional[str]: - """当前正在编辑/回放的机器人名。""" - return self._active + return _program_robot.active_robot(self) @property def robot_names(self) -> List[str]: - """已登记的机器人名(插入顺序)。""" - return list(self._states) + return _program_robot.robot_names(self) def set_active_robot(self, name: Optional[str]) -> bool: - """切换当前编辑的机器人,界面随之回填**它自己**的数据。 - - Returns: - 切换成功 ``True``;名字未登记返回 ``False`` - """ - if not name or name not in self._states: - return False - if name != self._active: - self.stop_playback() - self._active = name - self._refresh_robot_combo(name) - self._display_active() - return True + return _program_robot.set_active_robot(self, name) def remove_robot(self, name: Optional[str]) -> None: - """注销一台机器人(场景里删掉它时调用),遗留状态一并清理。""" - if not name or name not in self._states: - return - if name == self._active: - self.stop_playback() - self._states.pop(name, None) - self._refresh_robot_combo() - if name == self._active: - self._active = next(iter(self._states), None) - self._display_active() - - def _display_active(self) -> None: - """按当前机器人的状态回填整个界面(点表 / 报告 / 代码 / 按钮可用性)。""" - state = self._state() - self._refresh_point_list() - self.code_preview.setPlainText(state.last_code) - - if state.report is None: - self.issue_list.clear() - self._clear_metrics() - else: - self._show_report(state.report) - self._apply_point_colors(state.report) - - # 「校验全部」只要登记过机器人就能点(没示教的那台会被跳过并如实说明) - if getattr(self, "btn_validate_all", None) is not None: - self.btn_validate_all.setEnabled(bool(self._states)) - # 「全部回放」只要有一台能播就该能点 - if getattr(self, "btn_play_all", None) is not None: - self.btn_play_all.setEnabled(bool(self._playable())) - self.btn_generate.setEnabled(state.robot is not None) - # 「导出全部」看的是全局:任何一台有程序就该能点(它会跳过没示教的) - self._refresh_export_all_enabled() - - if state.robot is None: - self.lbl_status.setText(tr("未加载机器人")) - self.lbl_status.setObjectName("bad") - else: - self.lbl_status.setText(tr("{name} · 就绪").format(name=self._active)) - self.lbl_status.setObjectName("ok") - self.lbl_status.style().polish(self.lbl_status) - - def _refresh_robot_combo(self, selected: Optional[str] = None) -> None: - """把已登记的机器人名填进下拉,并选中 ``selected``(或当前这台)。 - - 重建时必须屏蔽信号:填项过程会让 ``currentIndex`` 跳来跳去,触发 - ``_on_robot_combo_changed`` 会在回填途中反复切换机器人。 - """ - if getattr(self, "combo_robot", None) is None: - return - target = selected or self._active - self.combo_robot.blockSignals(True) - self.combo_robot.clear() - for name in self._states: - self.combo_robot.addItem(name, name) - if target is not None: - idx = self.combo_robot.findData(target) - if idx >= 0: - self.combo_robot.setCurrentIndex(idx) - self.combo_robot.blockSignals(False) - # 只有一台时下拉没什么可挑,藏起来保持单机界面的样子。 - # 「校验全部 / 全部回放」**单机也显示**:它们就是校验/回放入口本身, - # 只有依据"有几台"决定藏不藏会让单机用户找不到按钮 - multi = len(self._states) > 1 - self.combo_robot.setVisible(multi) - if getattr(self, "btn_validate_all", None) is not None: - self.btn_validate_all.setEnabled(bool(self._states)) - if getattr(self, "btn_play_all", None) is not None: - self.btn_play_all.setEnabled(bool(self._playable())) - if getattr(self, "btn_grasp_wizard", None) is not None: - self.btn_grasp_wizard.setEnabled(bool(self._states)) + return _program_robot.remove_robot(self, name) def _on_robot_combo_changed(self, _index: int) -> None: - """下拉换人:切到对应机器人,并广播给主窗口同步示教点/视口。""" - name = self.combo_robot.currentData() - if not name or name == self._active: - return - self.set_active_robot(name) - self.activeRobotChanged.emit(name) + return _program_robot._on_robot_combo_changed(self, _index) def set_scene(self, scene: Optional["Scene"]) -> None: - """绑定工作单元场景;给出后校验会包含沿路径干涉检查。""" - self._scene = scene + return _program_robot.set_scene(self, scene) + + # ---------------------------- 轨迹编辑 ---------------------------- # def set_taught_points( self, positions: Sequence[Dict[str, float]], - types: Optional[Sequence[PointType]] = None, + types: Optional[Sequence["PointType"]] = None, motions: Optional[Sequence[str]] = None, robot: Optional[str] = None, ) -> None: - """导入示教点(``{关节名: 弧度}``)。 - - Args: - positions: 示教点序列 - types: 与示教点对齐的工艺语义(``PointType.PICK/PLACE…``); - 给出后 ``build_program`` 会在抓取/放置点自动插入 - ``gripper()`` 指令,程序与动画共用同一份抓取定义 - motions: 与示教点对齐的运动类型(``"J"``/``"L"``/``"C"``)。 - 为 ``None`` 且数量不变时沿用当前逐点运动类型(轨迹编辑器 - 的改动不被外部同步覆盖),否则重置为全 moveJ - robot: 写到哪台机器人的名下;``None`` 表示当前这台。给非当前机器人 - 喂数据时界面不会跟着跳(主窗口批量同步多台时要这个语义) - """ - # 喂给非当前那台时临时切过去写,写完切换回来——界面不能跟着跳 - hidden = robot is not None and robot in self._states and robot != self._active - prev = self._active - if hidden: - self._active = robot - try: - self._taught = [dict(p) for p in positions] - self._point_types = list(types) if types is not None else None - # 长度必须对齐:语义少了会漏动作,多了会错位 - if self._point_types is not None and len(self._point_types) != len(self._taught): - self._point_types = None - if motions is not None and len(motions) == len(self._taught): - self._point_motions = [ - k if k in ("L", "C") else "J" for k in (str(m).upper() for m in motions) - ] - elif len(self._point_motions) != len(self._taught): - self._point_motions = ["J"] * len(self._taught) - n = len(self._taught) - # 点数变了,旧报告的节拍明细/着色不再对得上号——直接作废 - if getattr(self, "_last_point_count", None) != n: - self._report = None - self._point_issue_level = {} - self._last_point_count = n - finally: - self._active = prev - - if hidden: - return - self.statusMessage.emit(tr("导入 {n} 个示教点").format(n=n)) - self._refresh_point_list() - # 有了示教点才有东西可导出:「导出全部」的可用性随点表一起变, - # 只靠登记机器人时点一次会一直灰着 - self._refresh_export_all_enabled() - - # ---------------------------- 抓取向导 ---------------------------- # - - def grasp_cycle_points( - self, - pick_index: int, - place_index: Optional[int] = None, - approach_mm: float = 50.0, - lift_mm: float = 50.0, - retract_mm: float = 50.0, - robot: Optional[str] = None, - ) -> Tuple[List[Dict[str, float]], List[PointType], List[str]]: - """由一个**抓取点**(加可选放置点)生成完整的抓取循环点位。 - - 用户只需把机器人摆到"要抓的地方"和"要放的地方"各示教一次,接近 / 提升 / - 撤离这些安全高度点由向导按竖直偏移算出来——这是示教里最费时间、也最容 - 易教歪的一步(手抖一下,接近点就可能离工件太远或撞上去)。 - - 偏移沿**世界竖直方向(+Z)**、单位毫米:接近点在抓取点上方 - ``approach_mm``,提升点在抓取点上方 ``lift_mm``,撤离点在放置点上方 - ``retract_mm``。 - - Args: - pick_index: 抓取点在当前示教点里的下标(0 起) - place_index: 放置点下标;``None`` 表示只生成抓取侧(接近/抓取/提升) - approach_mm / lift_mm / retract_mm: 竖直偏移,毫米 - robot: 算哪台机器人的;``None`` 表示当前这台 - - Returns: - ``(positions, types, motions)``——positions 为 ``{关节名: 弧度}``, - types 为对应的 :class:`PointType`,motions 为运动类型 - (竖直升降段用 moveL,转运段用 moveJ) - - Raises: - KinematicsError: 条件不足,或某个偏移点逆解失败(超出工作空间 / - 奇异位形)。**不返回残缺序列**——少一个点就不是完整的抓取循环, - 宁可让用户把高度调小重试,也不要给一串看着完整、实则缺动作的 - 示教点 - """ - with self._using_robot(robot) as state: - if state.robot is None or not state.joint_names: - raise KinematicsError(tr("还没有载入机器人,无法计算抓取点位")) - taught = state.taught - names = state.joint_names - if not taught: - raise KinematicsError(tr("还没有示教点——请先把机器人摆到抓取位并记录一个点")) - if not (0 <= pick_index < len(taught)): - raise KinematicsError( - tr("抓取点下标 {i} 超出示教点范围(共 {n} 个)").format( - i=pick_index, n=len(taught) - ) - ) - if place_index is not None and not (0 <= place_index < len(taught)): - raise KinematicsError( - tr("放置点下标 {i} 超出示教点范围(共 {n} 个)").format( - i=place_index, n=len(taught) - ) - ) - - def joints_at(index: int) -> List[float]: - """第 ``index`` 个示教点的关节值(度)。""" - return [math.degrees(float(taught[index].get(n, 0.0))) for n in names] - - def lifted(index: int, dz: float, guess: List[float], label: str) -> List[float]: - """把第 ``index`` 个示教点的 TCP 沿世界 +Z 抬高 ``dz`` 后逆解。 - - 以 ``guess`` 为初值(这里传抓取/放置位形本身):目标就在正上方, - 解出来自然与示教位形同构,不会翻腕翻到另一个姿态去。 - """ - pose = state.robot.forward_kinematics(joints_at(index)) - target = pose.matrix.copy() - target[2, 3] += float(dz) - try: - return [ - float(v) for v in state.robot.inverse_kinematics(Pose(target), guess=guess) - ] - except KinematicsError as exc: - raise KinematicsError( - tr("{label}(抬高 {dz:.0f} mm)逆解失败:{msg}").format( - label=label, dz=dz, msg=exc - ) - ) from exc - - pick_q = joints_at(pick_index) - - def merged(source_index: int, q: List[float]) -> Dict[str, float]: - """把解出的可动轴覆盖到来源示教点上,其余关节沿用示教值。 - - 末端工具轴(``tcp_joint``)这类非驱动关节不在 ``names`` 里,只按 - 可动轴重建字典就会把它们整条丢掉——生成的点与用户示教的点结构 - 对不上,写回场景时工具的关节值被悄悄抹成 0。 - """ - out = {n: float(v) for n, v in taught[source_index].items()} - out.update({n: math.radians(float(q[i])) for i, n in enumerate(names)}) - return out - - sequence = [ - ( - pick_index, - lifted(pick_index, approach_mm, pick_q, tr("接近点")), - PointType.APPROACH, - "J", - ), - (pick_index, pick_q, PointType.PICK, "L"), - ( - pick_index, - lifted(pick_index, lift_mm, pick_q, tr("提升点")), - PointType.LIFT, - "L", - ), - ] - if place_index is not None: - place_q = joints_at(place_index) - sequence += [ - (place_index, place_q, PointType.PLACE, "J"), - ( - place_index, - lifted(place_index, retract_mm, place_q, tr("撤离点")), - PointType.RETRACT, - "L", - ), - ] - - positions = [merged(src, q) for src, q, _t, _m in sequence] - return ( - positions, - [t for _src, _q, t, _m in sequence], - [m for _src, _q, _t, m in sequence], - ) - - def apply_grasp_cycle( - self, - pick_index: int, - place_index: Optional[int] = None, - approach_mm: float = 50.0, - lift_mm: float = 50.0, - retract_mm: float = 50.0, - replace: bool = False, - robot: Optional[str] = None, - ) -> int: - """生成抓取循环并写入 ``robot`` 的示教点。 - - Args: - replace: ``True`` 用生成的序列**替换**原有示教点;``False``(默认) - **追加**到末尾。默认追加:原有示教点是用户一个个摆出来的,凭 - 一次向导就抹掉太可惜——真要替换得由用户明确勾选 - robot: 写到哪台;``None`` 表示当前这台 - - Returns: - 写入后的示教点总数 - - Raises: - KinematicsError: 点位算不出来(见 :meth:`grasp_cycle_points`)。 - 此时**不改动**任何示教点 - """ - new_pos, new_types, new_motions = self.grasp_cycle_points( - pick_index, place_index, approach_mm, lift_mm, retract_mm, robot - ) - with self._using_robot(robot) as state: - cur = [dict(p) for p in state.taught] - if replace or not cur: - positions, types, motions = new_pos, new_types, new_motions - else: - cur_types = state.point_types - if cur_types is None or len(cur_types) != len(cur): - # 语义缺失或长度对不上时按默认补齐——宁可给用户一份能跑的 - # 猜测,也不要让追加后整串语义错位 - cur_types = default_types_for_count(len(cur)) - cur_motions = ( - list(state.point_motions) - if len(state.point_motions) == len(cur) - else ["J"] * len(cur) - ) - positions = cur + new_pos - types = list(cur_types) + new_types - motions = cur_motions + new_motions - self.set_taught_points(positions, types, motions, robot=robot) - name = robot or self._active - # 面板内部的编辑广播不带机器人名(多机会串台),故走专用信号;当前这台 - # 额外走一次既有广播,让路径代码编辑器同步(与轨迹编辑器改点同一条路) - self.graspCycleApplied.emit(name or "") - if name == self._active: - self._emit_taught_changed() - self.statusMessage.emit( - tr("抓取向导已生成 {n} 个点({seq})").format( - n=len(new_pos), - seq=" → ".join(t.label for t in new_types), - ) - ) - return len(positions) - - def open_grasp_wizard(self, robot: Optional[str] = None) -> bool: - """弹出抓取向导对话框,按用户给的两个示教点生成完整抓取循环。 - - Args: - robot: 对哪台机器人用向导;``None`` 表示当前这台 - - Returns: - 确实写入了点位 ``True``;用户取消或算不出来 ``False`` - """ - with self._using_robot(robot) as state: - taught = state.taught - name = robot or self._active - if not taught: - QtWidgets.QMessageBox.information( - self, - tr("抓取向导"), - tr("请先记录至少一个示教点——把机器人摆到要抓的位置,点「记录」"), - ) - return False - if state.robot is None: - QtWidgets.QMessageBox.information( - self, tr("抓取向导"), tr("还没有载入机器人,无法计算抓取点位") - ) - return False - - dlg = QtWidgets.QDialog(self) - dlg.setWindowTitle(tr("抓取向导")) - form = QtWidgets.QFormLayout(dlg) - - def point_label(i: int) -> str: - return tr("P{n}").format(n=i + 1) - - combo_pick = QtWidgets.QComboBox() - combo_place = QtWidgets.QComboBox() - for i in range(len(taught)): - combo_pick.addItem(point_label(i), i) - combo_place.addItem(point_label(i), i) - combo_place.addItem(tr("(不生成放置侧)"), -1) - # 默认:抓取点取当前选中行,放置点默认不选(多数人先只想把抓取侧做出来) - selected = self._selected_index() - combo_pick.setCurrentIndex(selected if selected is not None else len(taught) - 1) - combo_place.setCurrentIndex(len(taught)) # 最后一项 = 不生成 - - spin_approach = QtWidgets.QDoubleSpinBox() - spin_lift = QtWidgets.QDoubleSpinBox() - spin_retract = QtWidgets.QDoubleSpinBox() - for spin, value in ((spin_approach, 50.0), (spin_lift, 50.0), (spin_retract, 50.0)): - spin.setRange(0.0, 2000.0) - spin.setDecimals(1) - spin.setSuffix(" mm") - spin.setValue(value) - spin_retract.setEnabled(False) - - check_replace = QtWidgets.QCheckBox(tr("替换原有示教点(默认追加到末尾)")) - - preview = QtWidgets.QLabel(tr("接近点 → 抓取点 → 提升点")) - preview.setWordWrap(True) - - def refresh_preview() -> None: - spin_retract.setEnabled(combo_place.currentData() != -1) - seq = [tr("接近点"), tr("抓取点"), tr("提升点")] - if combo_place.currentData() != -1: - seq += [tr("放置点"), tr("撤离点")] - preview.setText(tr("将生成 {n} 个点:{seq}").format(n=len(seq), seq=" → ".join(seq))) - - combo_place.currentIndexChanged.connect(lambda _i: refresh_preview()) - refresh_preview() - - form.addRow(tr("抓取点"), combo_pick) - form.addRow(tr("放置点"), combo_place) - form.addRow(tr("接近高度(抓取点上方)"), spin_approach) - form.addRow(tr("提升高度(抓取点上方)"), spin_lift) - form.addRow(tr("撤离高度(放置点上方)"), spin_retract) - form.addRow(check_replace) - form.addRow(preview) - - buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel - ) - buttons.button(QtWidgets.QDialogButtonBox.Ok).setText(tr("生成")) - buttons.button(QtWidgets.QDialogButtonBox.Cancel).setText(tr("取消")) - buttons.accepted.connect(dlg.accept) - buttons.rejected.connect(dlg.reject) - form.addRow(buttons) - - if not dlg.exec(): - return False - - place = combo_place.currentData() - try: - self.apply_grasp_cycle( - pick_index=int(combo_pick.currentData()), - place_index=None if place == -1 else int(place), - approach_mm=spin_approach.value(), - lift_mm=spin_lift.value(), - retract_mm=spin_retract.value(), - replace=check_replace.isChecked(), - robot=name, - ) - except KinematicsError as exc: - # 逆解失败不能悄无声息:告诉用户是哪个点、抬了多少,让他调小高度重试 - QtWidgets.QMessageBox.warning( - self, - tr("抓取向导"), - tr("点位算不出来,示教点未改动:\n{msg}").format(msg=exc), - ) - return False - return True - - # ---------------------------- 轨迹编辑器 ---------------------------- # + return _program_editor.set_taught_points(self, positions, types, motions, robot) def get_taught_points(self, robot: Optional[str] = None) -> tuple: - """读取示教点数据(供外部同步与测试)。 - - Args: - robot: 读哪台的;``None`` 表示当前这台。抓取向导写完点后主窗口要 - 按名字把点位回灌视口,故支持指定机器人 - - Returns: - ``(positions, types, motions)``——positions 为 - ``{关节名: 弧度}`` 字典列表的拷贝;types 可为 ``None``; - motions 为 ``"J"/"L"/"C"`` 列表 - """ - with self._using_robot(robot) as state: - positions = [dict(p) for p in state.taught] - types = list(state.point_types) if state.point_types is not None else None - return positions, types, list(state.point_motions) + return _program_editor.get_taught_points(self, robot) def _selected_index(self) -> Optional[int]: - """当前选中行号;未选中返回 ``None``。""" - row = self.point_list.currentRow() - return row if 0 <= row < len(self._taught) else None - - # ---------------------------- 运动类型解析(v1.1.5 圆弧) ---------------------------- # + return _program_editor._selected_index(self) def _motion_kind(self, index: int) -> str: - """第 ``index`` 行声明的运动类型(``"J"`` / ``"L"`` / ``"C"``)。""" - if index < len(self._point_motions): - kind = str(self._point_motions[index]).upper() - if kind in ("J", "L", "C"): - return kind - return "J" + return _program_editor._motion_kind(self, index) def _gripper_action_at(self, index: int) -> Optional[str]: - """第 ``index`` 行是否带夹爪动作(抓取/放置语义)。""" - if self._point_types is not None and index < len(self._point_types): - return gripper_action_for(self._point_types[index]) - return None + return _program_editor._gripper_action_at(self, index) def arc_rejection(self, index: int) -> Optional[str]: - """把第 ``index`` 行设为 ``moveC`` 是否可行;不可行时返回原因(可直接展示)。 - - 圆弧的约定(轨迹编辑器里的口径):**第 N 行的运动类型描述"到达第 N 点" - 的运动**——``C`` 表示"以上一点为中间点、再上一点为起点,圆弧到达本点"。 - 因此: - - * 第 1、2 行不能是圆弧(没有两个前驱点); - * 相邻两行不能同为圆弧(中间点会同时成为上一段的终点,几何上矛盾); - * 带工艺语义的点(抓取/放置)不能当中间点——中间点不停机,夹爪动作 - 会失去落点。 - - Returns: - 拒绝原因(中文,可直接显示);可行时 ``None`` - """ - if not (0 <= index < len(self._taught)): - return tr("路点不存在") - if index < 2: - return tr("P{n} 缺少两个前驱点——圆弧需要起点与中间点,P1/P2 不能设为 moveC").format( - n=index + 1 - ) - if self._motion_kind(index - 1) == "C": - return tr("P{n} 已是圆弧终点,不能再作为本段的中间点").format(n=index) - if index + 1 < len(self._taught) and self._motion_kind(index + 1) == "C": - return tr("P{n} 已把 P{k} 当中间点——本行不能同时作为圆弧终点").format( - n=index + 2, k=index + 1 - ) - if self._gripper_action_at(index - 1) is not None: - return tr("P{n} 是抓取/放置点,不能作为圆弧中间点(中间点不停机)").format(n=index) - return None + return _program_editor.arc_rejection(self, index) def motion_plan(self) -> Tuple[List[Tuple[str, int, Optional[int]]], List[str]]: - """把逐点运动类型解析成可执行的运动序列。 - - Returns: - ``(plan, notes)``:plan 每项为 ``(kind, point_index, via_index)``, - ``kind`` ∈ ``{"J","L","C"}``,``via_index`` 仅圆弧有值; - notes 为降级说明(圆弧条件不满足时退化为关节运动,这里如实记下)。 - """ - n = len(self._taught) - declared = [self._motion_kind(i) for i in range(n)] - accepted: Dict[int, int] = {} # 圆弧终点行 → 中间点行 - notes: List[str] = [] - for i in range(n): - if declared[i] != "C": - continue - reason = self.arc_rejection(i) - if reason is not None: - notes.append(tr("{reason}——已按关节运动(moveJ)处理").format(reason=reason)) - continue - accepted[i] = i - 1 - vias = set(accepted.values()) - plan: List[Tuple[str, int, Optional[int]]] = [] - for i in range(n): - if i in vias: # 中间点不再单独出运动指令 - continue - if i in accepted: - plan.append(("C", i, accepted[i])) - else: - kind = declared[i] - plan.append((("J" if kind == "C" else kind), i, None)) - return plan, notes + return _program_editor.motion_plan(self) def arc_via_indices(self) -> set: - """当前被当作圆弧中间点(不再单独出指令)的路点行号集合。""" - return {via for _kind, _i, via in self.motion_plan()[0] if via is not None} + return _program_editor.arc_via_indices(self) def instruction_of_point(self) -> Dict[int, int]: - """路点行号 → 运动指令序号(圆弧把中间点并进上一条指令,两者不再一一对应)。""" - plan, _notes = self.motion_plan() - return {index: order for order, (_k, index, _v) in enumerate(plan)} + return _program_editor.instruction_of_point(self) def last_build_notes(self) -> List[str]: - """最近一次 ``build_program`` 的降级说明(空表表示没有降级)。""" - return list(self._build_notes) + return _program_editor.last_build_notes(self) def _emit_taught_changed(self) -> None: - """示教点被编辑后广播,供主窗口回写路径代码编辑器。""" - positions, types, motions = self.get_taught_points() - self.taughtPointsChanged.emit(positions, types or [], motions) - - def _refresh_export_all_enabled(self) -> None: - """「导出全部…」的可用性:任何一台有程序就该能点(它会跳过没示教的)。""" - if getattr(self, "btn_export_all", None) is None: - return - self.btn_export_all.setEnabled( - any(s.robot is not None and s.taught for s in self._states.values()) - ) + return _program_editor._emit_taught_changed(self) def _invalidate_results(self) -> None: - """示教点变了,旧轨迹/报告/回放全部作废。""" - self.stop_playback() - self._trajectory = None - self._report = None - self._planner = None - self._gripper_animator = None - # 「全部回放」跟着失效:轨迹没了就不该再能点——否则按下去播的是 - # 内存里已经被判废的旧轨迹 - if getattr(self, "btn_play_all", None) is not None: - self.btn_play_all.setEnabled(bool(self._playable())) + return _program_editor._invalidate_results(self) def _keep_row_selected(self, row: int) -> None: - """把第 ``row`` 行设为选中并滚进视野。 - - 增删改之后列表是**整体重建**的:重建会把选中清掉,长点表里就再也找不 - 到刚动过的那一行。移动类操作一律走这里。注意 ``setCurrentRow`` 只设 - **当前行**、不设**选中**——重建后的行没有选中态,深色主题下只显示 - 交替行的淡色而不是蓝色高亮,用户看到的就是"选中的颜色变淡了" - (2026-09-21 用户反馈)。所以这里显式 ``setSelected(True)``。 - """ - if not (0 <= row < self.point_list.count()): - return - self.point_list.setCurrentRow(row) - item = self.point_list.item(row) - if item is not None: - item.setSelected(True) - self.point_list.scrollToItem(item, QtWidgets.QAbstractItemView.EnsureVisible) + return _program_editor._keep_row_selected(self, row) def insert_point(self, index: Optional[int] = None) -> Optional[int]: - """在 ``index`` 之后插入路点,复制其关节值与运动类型。 - - Args: - index: 插入位置(新点落在该序号后一位);``None`` 取当前 - 选中行,无选中时追加到末尾。首点为空时填零位 - - Returns: - 新点序号;示教点未初始化时返回 ``None`` - """ - if isinstance(index, bool): # clicked(bool) 之类把 False 顶了进来 - index = None - if not self._taught: - if self._joint_names: - zero = {name: 0.0 for name in self._joint_names} - self._taught = [zero] - self._point_types = None - self._point_motions = ["J"] - else: - self.statusMessage.emit(tr("请先载入机器人再添加路点")) - return None - else: - if index is None: - index = self._selected_index() - if index is None or not (0 <= index < len(self._taught)): - index = len(self._taught) - 1 - src = dict(self._taught[index]) - self._taught.insert(index + 1, src) - if self._point_types is not None: - self._point_types.insert(index + 1, self._point_types[index]) - # 复制运动类型:同一位置附近通常保持同一种走法 - self._point_motions.insert(index + 1, self._point_motions[index]) - index = index + 1 - self._invalidate_results() - self._refresh_point_list() - self._keep_row_selected(index) - self._emit_taught_changed() - self._keep_row_selected(index) # 回灌可能重建列表,选中再兜一次 - self.statusMessage.emit(tr("已插入路点 {n},请重新校验").format(n=index + 1)) - return index + return _program_editor.insert_point(self, index) def delete_point(self, index: Optional[int] = None) -> bool: - """删除 ``index`` 处的路点(``None`` 取当前选中行)。 - - 删完把选中落在**顶上来的那一行**:后续继续删相邻点时不必重新点选。 - """ - if isinstance(index, bool): # clicked(bool) 之类把 False 顶了进来 - index = None - if not self._taught: - return False - if index is None: - index = self._selected_index() - if index is None or not (0 <= index < len(self._taught)): - self.statusMessage.emit(tr("请先在列表中选中要删除的路点")) - return False - del self._taught[index] - if self._point_types is not None and index < len(self._point_types): - del self._point_types[index] - if index < len(self._point_motions): - del self._point_motions[index] - self._invalidate_results() - self._refresh_point_list() - follow = min(index, self.point_list.count() - 1) - self.point_list.setCurrentRow(follow) - self._emit_taught_changed() - self._keep_row_selected(follow) - self.statusMessage.emit(tr("已删除路点 {n},请重新校验").format(n=index + 1)) - return True + return _program_editor.delete_point(self, index) def move_point(self, src: int, dst: int) -> bool: - """把路点从 ``src`` 移动到 ``dst``(``dst`` 为移动后的新序号)。 - - 移动的**是那一个点本身**:关节值、工艺语义、运动类型一起走,列表里的 - ``P`` 序号按行序重排(第 N 行就是 P{N+1}),因此"被移动的点"到了新位置 - 就顶新的号——不会留下"坐标走了、序号还挂旧行"的两张皮。选中也跟着它走。 - """ - n = len(self._taught) - if not (0 <= src < n) or not (0 <= dst < n) or src == dst: - return False - for seq in (self._taught, self._point_motions): - seq.insert(dst, seq.pop(src)) - if self._point_types is not None and len(self._point_types) == n: - self._point_types.insert(dst, self._point_types.pop(src)) - self._invalidate_results() - self._refresh_point_list() - self._keep_row_selected(dst) - self._emit_taught_changed() - # 回灌链路(主窗口按机器人回写点表)若顺带重建了列表,选中会被抹掉—— - # 再兜一次,保证"原来选中的还是选中的" - self._keep_row_selected(dst) - self.statusMessage.emit(tr("P{a} 已移到 P{b},请重新校验").format(a=src + 1, b=dst + 1)) - return True + return _program_editor.move_point(self, src, dst) def shift_selected_point(self, delta: int) -> bool: - """把当前选中路点上移(``delta=-1``)或下移(``delta=1``)。""" - index = self._selected_index() - if index is None: - self.statusMessage.emit(tr("请先在列表中选中要移动的路点")) - return False - return self.move_point(index, index + delta) + return _program_editor.shift_selected_point(self, delta) def set_point_motion(self, index: int, motion: str) -> bool: - """设置单个路点的运动类型(``"J"``=moveJ / ``"L"``=moveL / ``"C"``=moveC)。 - - ``"C"`` 不满足圆弧条件时**拒绝设置并说明原因**(见 :meth:`arc_rejection`)—— - 悄悄改成别的运动类型会让"我以为这是圆弧"变成现场查不动的差异。 - """ - if not (0 <= index < len(self._taught)): - return False - kind = str(motion).upper() - if kind == "C": - reason = self.arc_rejection(index) - if reason is not None: - self.statusMessage.emit(reason) - return False - motion = kind if kind in ("L", "C") else "J" - if 0 <= index < len(self._point_motions) and self._point_motions[index] == motion: - return False - while index >= len(self._point_motions): - self._point_motions.append("J") - self._point_motions[index] = motion - self._invalidate_results() - self._refresh_point_list() - self._emit_taught_changed() - return True + return _program_editor.set_point_motion(self, index, motion) def update_point( self, @@ -1368,154 +374,16 @@ def update_point( joints_deg: Sequence[float], motion: Optional[str] = None, ) -> bool: - """写入路点的关节角(**度**)与可选的运动类型。 - - Args: - index: 路点序号 - joints_deg: 各关节角度值,顺序与 ``_joint_names`` 对齐 - motion: ``"J"/"L"/"C"``;``None`` 保持不变 - """ - if not (0 <= index < len(self._taught)): - return False - pos = self._taught[index] - for i, name in enumerate(self._joint_names): - if i < len(joints_deg): - pos[name] = math.radians(float(joints_deg[i])) - if motion is not None: - self.set_point_motion(index, motion) - self._invalidate_results() - self._refresh_point_list() - self._emit_taught_changed() - return True + return _program_editor.update_point(self, index, joints_deg, motion) def edit_selected_point(self) -> None: - """弹出对话框编辑当前选中路点的关节角与运动类型。""" - index = self._selected_index() - if index is None or not self._taught: - self.statusMessage.emit(tr("请先在列表中选中要编辑的路点")) - return - joints = [math.degrees(float(self._taught[index].get(n, 0.0))) for n in self._joint_names] - motion = self._point_motions[index] if index < len(self._point_motions) else "J" - - dialog = QtWidgets.QDialog(self) - dialog.setWindowTitle(tr("编辑路点 {n}").format(n=index + 1)) - form = QtWidgets.QFormLayout(dialog) - spins: List[QtWidgets.QDoubleSpinBox] = [] - for i, name in enumerate(self._joint_names): - spin = QtWidgets.QDoubleSpinBox() - spin.setRange(-360.0, 360.0) - spin.setDecimals(2) - spin.setSuffix(" °") - if i < len(joints): - spin.setValue(joints[i]) - spins.append(spin) - form.addRow(name, spin) - combo = QtWidgets.QComboBox() - combo.addItem(tr("moveJ(关节插值)"), "J") - combo.addItem(tr("moveL(直线插值)"), "L") - combo.addItem(tr("moveC(圆弧,以上一点为中间点)"), "C") - combo.setCurrentIndex({"L": 1, "C": 2}.get(str(motion).upper(), 0)) - form.addRow(tr("运动类型"), combo) - if motion != "C": - hint = self.arc_rejection(index) - hint_label = QtWidgets.QLabel( - tr("可设为 moveC(圆弧)") - if hint is None - else tr("不可设为 moveC:{reason}").format(reason=hint) - ) - hint_label.setWordWrap(True) - hint_label.setStyleSheet("color:#90caf9; font-size:11px;") - form.addRow(hint_label) - buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel - ) - buttons.accepted.connect(dialog.accept) - buttons.rejected.connect(dialog.reject) - form.addRow(buttons) - if dialog.exec() != QtWidgets.QDialog.Accepted: - return - self.update_point( - index, - [spin.value() for spin in spins], - combo.currentData(), - ) - self.statusMessage.emit(tr("{n} 已更新,请重新校验").format(n=index + 1)) + return _program_editor.edit_selected_point(self) def _refresh_point_list(self) -> None: - """按当前示教点数据重建列表,并套用最近一次校验的着色。 - - 圆弧(v1.1.5)会打破"每个路点恰好一条运动指令"的旧前提——圆弧的中间点 - 被并进上一条指令。因此节拍明细必须经路点→指令序号映射取值,**不能按下标 - 硬取**(否则圆弧之后的每一行节拍都会错位一行)。 - """ - report = self._report - motion_times = report.motion_times if report is not None else None - labels = report.motion_labels if report is not None else None - levels = getattr(self, "_point_issue_level", {}) if report is not None else {} - point_to_instruction = self.instruction_of_point() if report is not None else {} - vias = self.arc_via_indices() - - self.point_list.clear() - for i, pos in enumerate(self._taught): - mtype = self._motion_kind(i) - if i in vias: - label = tr("moveC 中间点") - elif mtype == "C": - label = "moveC" - elif mtype == "L": - label = "moveL" - else: - label = "moveJ" - joints = ", ".join( - f"{math.degrees(float(pos.get(n, 0.0))):.1f}" for n in self._joint_names - ) - text = f"P{i + 1} · {label} · [{joints}]" - instruction = point_to_instruction.get(i) - if ( - motion_times is not None - and instruction is not None - and instruction < len(motion_times) - ): - kind = labels[instruction] if labels and instruction < len(labels) else label - text += f" · {kind} {motion_times[instruction]:.2f}s" - item = QtWidgets.QListWidgetItem(text) - level = levels.get(i) - if level == "error": - item.setForeground(QtGui.QColor("#ff8080")) - item.setToolTip(tr("该校验错误涉及此路点(见校验结果)")) - elif level == "warning": - item.setForeground(QtGui.QColor("#ffc866")) - item.setToolTip(tr("该校验警告涉及此路点(见校验结果)")) - elif report is not None: - item.setForeground(QtGui.QColor("#6fdc6f")) - self.point_list.addItem(item) + return _program_editor._refresh_point_list(self) def _apply_point_colors(self, report: ProgramReport) -> None: - """从校验报告提取每个路点的最严重问题级别并刷新列表着色。 - - ``Issue.motion_index`` 是**运动指令**序号,而列表行是**路点**序号。无圆弧时 - 两者一一对应,有圆弧时中间点被并进上一条指令(指令数少于路点数),因此这里 - 反向映射指令→路点;指向中间点的指令把着色落在**圆弧终点**那一行——问题实际 - 出在圆弧段上,落在被并入的那一行最容易被看见。 - """ - self._point_issue_level = self._point_levels(report) - self._refresh_point_list() - - def _point_levels(self, report: "ProgramReport") -> Dict[int, str]: - """报告里每个路点的最严重问题级别——**只算,不刷界面**。 - - 映射规则同 :meth:`_apply_point_colors`,但不碰列表控件:一键校验全部 - 时要逐台算完存进各自的状态槽,界面从头到尾只显示当前那台。 - """ - levels: Dict[int, str] = {} - plan, _notes = self.motion_plan() - instruction_to_point = {order: index for order, (_kind, index, _via) in enumerate(plan)} - for issue in report.issues: - point_index = instruction_to_point.get(issue.motion_index, issue.motion_index) - level = "error" if issue.severity == SEVERITY_ERROR else "warning" - if levels.get(point_index) != "error": - levels[point_index] = level - return levels + return _program_editor._apply_point_colors(self, report) # ---------------------------- 业务逻辑 ---------------------------- # @@ -1530,673 +398,137 @@ def report(self) -> Optional[ProgramReport]: return self._report def build_program(self, robot: Optional[str] = None) -> Optional[Program]: - """组装**指定机器人**(默认当前这台)的程序。 - - Args: - robot: 机器人名;``None`` 表示当前正在编辑的那台。批量出全部機器人 - 的代码时逐个指名即可,界面不会跟着切走 - """ - with self._using_robot(robot): - return self._build_program_impl() + return _program_validate.build_program(self, robot) def _build_program_impl(self) -> Optional[Program]: - # 实际装配:调用方已把当前槽切到目标机器人 - """把示教点组装成程序。 - - 示教点内部以弧度保存,:class:`Motion` 期望角度。 - 带工艺语义的示教点(抓取/放置)会自动追加 ``gripper()`` 指令组, - 于是回放动画、程序校验与后处理出码共用同一份抓取时序。 - 运动类型逐点取自轨迹编辑器(:meth:`motion_plan`):``"L"`` 的点位先对该 - 关节配置做 FK 得到 TCP 位姿、再以 ``moveL`` 直线插补过去;``"C"`` 的点位 - 以**上一点为中间点**做 ``moveC``(中间点不再单独出指令)。 - - 圆弧正解失败(位形奇异/越限等)时**退化为关节运动并记入** - :meth:`last_build_notes`,绝不静默改成另一条几何路径却让界面以为还是圆弧。 - - Returns: - :class:`Program`;无机器人或无示教点时返回 ``None`` - """ - self._build_notes = [] - if self._robot is None or not self._taught: - return None - speed = float(self.spin_speed.value()) - builder = ProgramBuilder(self._robot) - plan, notes = self.motion_plan() - self._build_notes.extend(notes) - - def _pose_of(index: int): - """路点关节配置 → TCP 位姿(度)。""" - pos = self._taught[index] - joints = [math.degrees(float(pos.get(n, 0.0))) for n in self._joint_names] - return self._robot.forward_kinematics(joints), joints - - def _joint_move(index: int) -> None: - """关节运动到某个示教点——直接下发关节值、不走 FK,任何位形都可用。""" - joints = [ - math.degrees(float(self._taught[index].get(n, 0.0))) for n in self._joint_names - ] - builder.moveJ(joints, speed=speed) - - def _gripper_after(index: int) -> None: - """该点的工艺动作(抓取/放置)——回放与出码共用这份时序。""" - t = ( - self._point_types[index] - if self._point_types and index < len(self._point_types) - else None - ) - action = gripper_action_for(t) - if action: - builder.gripper(action) - - for kind, i, via_index in plan: - if kind == "C": - moved = False - try: - via_pose, _ = _pose_of(via_index) - target_pose, _ = _pose_of(i) - except Exception: - self._build_notes.append( - tr( - "P{n} 的圆弧正解失败——退化为关节运动(moveJ);中间点 P{k} 改为单独经过" - ).format(n=i + 1, k=via_index + 1) - ) - else: - try: - builder.moveC(via_pose, target_pose, speed=speed) - moved = True - except Exception: - self._build_notes.append( - tr( - "P{n} 的圆弧无法加入程序——退化为关节运动(moveJ);中间点 P{k} 改为单独经过" - ).format(n=i + 1, k=via_index + 1) - ) - if not moved: - # 圆弧没了,中间点也不能跟着一起消失:示教了 4 个点却只走 3 个, - # 现场表现就是"这一拐没绕过去"。补一条 moveJ 单独经过中间点—— - # 精度不如圆弧,但经过的示教点一个不少。 - _joint_move(via_index) - _joint_move(i) - elif kind == "L": - moved = False - try: - pose, _ = _pose_of(i) - builder.moveL(pose, speed=speed) - moved = True - except Exception: - # FK 失败(位形奇异/越限等)退回关节运动:宁可走不到 - # 直线,也不能让整个程序构建崩掉 - pass - if not moved: - _joint_move(i) - else: - _joint_move(i) - _gripper_after(i) - return builder.build() + return _program_validate._build_program_impl(self) def _scene_joint_snapshot(self, name: Optional[str]): - """校验前把 ``name`` 的场景关节角拍个快照(恢复用);拍不到返回 ``None``。""" - scene = self._scene - if scene is None or not name: - return None - state = getattr(scene, "robots", {}).get(name) - joints = getattr(state, "joints_deg", None) - if joints is None: - return None - return (name, [float(v) for v in joints]) + return _program_validate._scene_joint_snapshot(self, name) def _restore_scene_joints(self, snapshot) -> None: - """把校验期间被干涉检查写走的场景关节角**原样还回去**。 - - 干涉检查沿轨迹密集采样,且会把采样到的位形写回场景(规划器的 - 历史契约,单点位形检查依赖它)。副作用是:校验一跑完,**场景**里 - 的机器人停在轨迹末端的位形,而**视口渲染器**还在原地——挂载件 - (夹爪/工件)的位姿按场景关节算,于是"夹爪离开法兰跳到别处"。 - 校验是只读的分析,不该留这种副作用,这里统一恢复。 - """ - if not snapshot or self._scene is None: - return - name, joints = snapshot - try: - self._scene.set_robot_joints(joints, name=name) - except Exception as exc: # 恢复失败不该盖过校验结果本身 - logger.warning("restore_scene_joints_failed: %s", exc) + return _program_validate._restore_scene_joints(self, snapshot) def validate_program(self) -> Optional[ProgramReport]: - """校验程序并刷新界面。 + return _program_validate.validate_program(self) - 面板自己的槽位是上次同步时的快照,而点表的最新版本在主窗口那边 - ("教点 → 回灌"是两条通路)。点按钮时先请求回灌一次,避免"校的是 - 旧数据 / 这一槽压根没灌过点点看起来像没反应"。 - - Returns: - :class:`ProgramReport`;条件不足时返回 ``None`` - """ - self.syncRequested.emit(self._active) - if self._robot is None: - self.statusMessage.emit(tr("请先载入机器人型号,再校验程序")) - return None - - program = self.build_program() - if program is None: - self.statusMessage.emit(tr("没有示教点可供校验——请先记录若干关节位形")) - return None - - QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) - snapshot = self._scene_joint_snapshot(self._active) - try: - report = program.validate(scene=self._scene) - planner = TrajectoryPlanner(self._robot, scene=self._scene) - self._trajectory = planner.plan(program) - except Exception as exc: - # 校验本身崩了也要让用户看见——这类异常几乎总是模型数据的问题 - self.statusMessage.emit( - tr("校验异常:{kind}: {msg}").format(kind=type(exc).__name__, msg=exc) - ) - return None - finally: - QtWidgets.QApplication.restoreOverrideCursor() - self._restore_scene_joints(snapshot) - - # 规划器要留着:其内部记录了每条指令的时间区间,抓取动画靠它把 - # gripper() 指令映射到轨迹时刻 - self._planner = planner - self._gripper_animator = GraspAnimator(program, planner) - - self._report = report - self._show_report(report) - # 路点列表着色:错误红/警告橙/正常绿 + 逐指令节拍(v0.2.4) - self._apply_point_colors(report) - self.reportReady.emit(report) - - # 有轨迹就能回放——即使校验没通过,也允许用户看看走到哪儿崩的 - if getattr(self, "btn_play_all", None) is not None: - self.btn_play_all.setEnabled(bool(self._playable())) - message = tr("校验完成:{res}").format( - res=tr("通过") if report.ok else tr("{n} 处错误").format(n=len(report.errors)) - ) - if self._build_notes: - # 圆弧降级这类"几何被换掉了"的事必须说出来,不能只在日志里躺着 - message += tr(";{n} 项运动降级(见日志)").format(n=len(self._build_notes)) - for note in self._build_notes: - self.statusMessage.emit(tr("⚠ {note}").format(note=note)) - self.statusMessage.emit(message) - return report - - def validate_all(self) -> Dict[str, Optional["ProgramReport"]]: - """逐台校验**所有**已登记的机器人(一键校验全部)。 - - 装配与规划和 :meth:`validate_program` 完全一致,区别在于**不逐台刷界面** - ——界面始终显示当前那台。否则问题列表会被一遍遍刷掉,用户只看见最后 - 一台的结论,前面几台校出问题等于没看见。每台的结果(轨迹 / 报告 / - 夹爪时序)各存进自己的状态槽,随后「全部回放」直接复用这些轨迹。 - - Returns: - ``{机器人名: ProgramReport}``;没型号、没示教点或校验本身出错的那台 - 对应 ``None``——"没结果"和"通过"是两回事,不混为一谈 - """ - reports: Dict[str, Optional["ProgramReport"]] = {} - for name in list(self._states): - state = self._states.get(name) - if state is None or state.robot is None: - continue - with self._using_robot(name): - program = self._build_program_impl() - if program is None: - reports[name] = None # 这台还没教过程序 - continue - snapshot = self._scene_joint_snapshot(name) - try: - report = program.validate(scene=self._scene) - planner = TrajectoryPlanner(self._robot, scene=self._scene) - state.trajectory = planner.plan(program) - except Exception as exc: - # 一台崩了不能带崩全部:如实记下来,继续校其余的 - state.trajectory = None - state.report = None - reports[name] = None - self.statusMessage.emit( - tr("{name} 校验异常:{kind}: {msg}").format( - name=name, kind=type(exc).__name__, msg=exc - ) - ) - continue - finally: - self._restore_scene_joints(snapshot) - state.planner = planner - state.gripper_animator = GraspAnimator(program, planner) - state.report = report - state.point_issue_level = self._point_levels(report) - reports[name] = report - - # 当前这台若也校过了,顺手把它的结论刷进界面——用户正看着它 - active = self._active - if active in reports and reports[active] is not None: - self._show_report(reports[active]) - self._apply_point_colors(reports[active]) - if getattr(self, "btn_play_all", None) is not None: - self.btn_play_all.setEnabled(bool(self._playable())) - - passed = [n for n, r in reports.items() if r is not None and r.ok] - failed = [n for n, r in reports.items() if r is not None and not r.ok] - skipped = [n for n, r in reports.items() if r is None] - parts = [] - if passed: - parts.append(tr("{n} 台通过").format(n=len(passed))) - if failed: - parts.append(tr("{n} 台有问题").format(n=len(failed))) - if skipped: - parts.append(tr("{n} 台没程序").format(n=len(skipped))) - self.statusMessage.emit( - tr("校验全部:{res}").format(res="、".join(parts) or tr("没有可校验的机器人")) - ) - self.allValidated.emit(reports) - return reports + def validate_all(self) -> Dict[str, Optional[ProgramReport]]: + return _program_validate.validate_all(self) def focus_report(self, name: Optional[str] = None) -> bool: - """切到 ``name`` 并把它**已存的**校验报告刷进界面。 - - 汇总对话框里点「看这台」走这里:报告已经算过了,不必重校一遍。 + return _program_validate.focus_report(self, name) - Returns: - 有报告并已显示 ``True``;这台还没校过返回 ``False`` - """ - key = name or self._active - state = self._state(key) - if state is None or state.report is None: - self.statusMessage.emit(tr("{name} 还没有校验结果").format(name=key or tr("这台"))) - return False - self.set_active_robot(key) - self._show_report(state.report) - self._apply_point_colors(state.report) - # 视口与示教点显示也要跟过去——否则"看的是 A 的报告,画的是 B" - self.activeRobotChanged.emit(key) - return True + def _point_levels(self, report: ProgramReport) -> Dict[int, str]: + return _program_validate._point_levels(self, report) # ---------------------------- 设备联调 ---------------------------- # def generate_code(self) -> None: - """生成控制器代码并预览——口径与「回放」一致。 - - * 视口里**选中了某台设备**(机器人/其夹爪/被夹工件都算)→ 只出**那台**的代码; - * **没选中** → 一次给出**全部**机器人的代码(每台一段,带名字分隔), - 替代已删除的「导出全部…」。 - - 失败时如实抛错并清空预览——绝不留下"看起来成功"的废代码。 - """ - selected = None - provider = getattr(self, "selection_robot_provider", None) - if callable(provider): - try: - selected = provider() - except Exception: - selected = None - if selected and selected in self._states: - with self._using_robot(selected): - self._generate_active_code() - return - - codes = self.generate_all_code() - if not codes: - return - # 没选中 = 用户要看"整条产线":逐台拼接,段与段之间用名字隔开 - blocks = [f"# ===== {name} =====\n{code}" for name, code in codes.items()] - self.code_preview.setPlainText("\n\n".join(blocks)) - self._last_code = codes.get(self._active) or next(iter(codes.values())) - self._refresh_export_all_enabled() - self.statusMessage.emit(tr("未选中设备——已生成全部 {n} 台的代码").format(n=len(codes))) + return _program_export.generate_code(self) def _generate_active_code(self) -> None: - """按选择的后处理器生成**当前那台**的控制器代码并预览。""" - program = self.build_program() - if program is None: - self.statusMessage.emit(tr("请先加载机器人并示教至少一个点")) - return - choice = self.combo_post.currentData() # None = 自动(按机器人品牌推断) - # 程序名用**场景里的机器人名**而不是 URDF 文件名:多机同型号时后者会 - # 重名,落出 second.src / second.src 互相覆盖 - prog_name = self._active or (self._robot.name if self._robot else "program") - try: - code = program.to_post(choice, metadata={"program_name": prog_name}) - except Exception as exc: - self._last_code = "" - self.code_preview.setPlainText(tr("生成失败:\n{exc}").format(exc=exc)) - self.statusMessage.emit(tr("生成失败: {exc}").format(exc=exc)) - return - self._last_code = code - self.code_preview.setPlainText(code) - self.statusMessage.emit( - tr("已生成 {choice} 代码,共 {lines} 行").format( - choice=choice or tr("自动"), lines=len(code.splitlines()) - ) - ) + return _program_export._generate_active_code(self) def export_all_programs(self) -> None: - """把**每台**机器人各导出一份**独立程序文件**到同一个目录。 - - 走 ``FileLink`` 离线部署通道,**不要求**先点「生成代码」:内部逐台 - 构建自己的程序再后处理,各按自己的品牌落正确扩展名(UR ``.script`` - / KUKA ``.src`` / ABB ``.mod`` / FANUC ``.tp``…),文件名与程序名用 - 场景里的**机器人名**(多机同型号也不会互相覆盖),并各写 - ``.manifest.json`` 部署清单。 + return _program_export.export_all_programs(self) - 选中了设备时只导那一台(与「生成代码」同一套选中口径);没选就逐台导。 - """ - selected = None - provider = getattr(self, "selection_robot_provider", None) - if callable(provider): - try: - selected = provider() - except Exception: - selected = None - targets = [selected] if selected and selected in self._states else list(self._states) - if not targets: - self.statusMessage.emit(tr("请先加载机器人并示教至少一个点")) - return - dest = QtWidgets.QFileDialog.getExistingDirectory(self, tr("选择导出目录"), str(Path.cwd())) - if not dest: - return - ok = 0 - for name in targets: - program = self.build_program(name) - if program is None: - self.statusMessage.emit(tr("{name}:无示教点,已跳过").format(name=name)) - continue - if self.export_program_to(program, dest, robot=name): - ok += 1 - if ok == 0: - return - self.statusMessage.emit( - tr("已导出 {n} 台机器人的独立程序到 {dest}").format(n=ok, dest=dest) - ) - - def export_program_to(self, program: Program, dest: str, robot: Optional[str] = None) -> bool: - """把 ``program`` 导出到 ``dest`` 目录(无 UI 逻辑,供测试复用)。 - - Args: - program: 已构建好的程序 - dest: 目标目录 - robot: 这台机器人的名字(决定文件名与其槽位写入);``None`` 取当前 - - Returns: - 成功 ``True``;后处理/落盘失败 ``False``(状态栏带原因) - """ - with self._using_robot(robot): - return self._export_impl(program, dest) + def export_program_to( + self, program: Program, dest: str, robot: Optional[str] = None + ) -> bool: + return _program_export.export_program_to(self, program, dest, robot) def _export_impl(self, program: Program, dest: str) -> bool: - name = self._active or (self._robot.name if self._robot else "program") - try: - link = FileLink(dest) - link.connect() - deployment = program.deploy( - link, - self.combo_post.currentData(), - name=name, - metadata={"program_name": name}, - ) - except Exception as exc: - self.statusMessage.emit(tr("导出失败: {exc}").format(exc=exc)) - return False - self._last_code = deployment.code - self._refresh_export_all_enabled() - note = tr("({note})").format(note=deployment.notes) if deployment.notes else "" - self.statusMessage.emit( - tr("已导出:{handle}{note}").format(handle=deployment.handle, note=note) - ) - return True - - # ---------------------------- 存盘 / 恢复 ---------------------------- # + return _program_export._export_impl(self, program, dest) def robot_snapshot(self) -> Dict[str, dict]: - """各台机器人的示教点与抓取语义快照,供工程存盘。 - - 工程里原先只有一份 ``program`` 段(来自只显示当前那台的路径编辑器), - 多机工程存盘再打开会丢掉其余机器人教好的抓取循环——这段就是为此加的。 - - Returns: - ``{机器人名: {"joint_names": [...], "positions": [{关节名: 弧度}], - "point_types": [...]}}`` - """ - out: Dict[str, dict] = {} - for name, st in self._states.items(): - if st.robot is None: - continue - out[name] = { - "joint_names": list(st.joint_names), - "positions": [{k: float(v) for k, v in p.items()} for p in st.taught], - "point_types": [getattr(t, "value", None) for t in (st.point_types or [])], - } - return out + return _program_export.robot_snapshot(self) def restore_robots(self, data: Optional[dict]) -> None: - """把 :meth:`robot_snapshot` 的快照灌回各台机器人。 - - 场景里已经没有的机器人直接跳过——存档比场景多是正常的(用户删过机器 - 人),不该为它凭空造一个条目出来。语义数量对不上时按默认语义重建, - 绝不猜哪个是抓取点。 - """ - for name, payload in (data or {}).items(): - if name not in self._states: - continue - positions = payload.get("positions") or [] - if not positions: - continue - raw = [ - t - for t in (payload.get("point_types") or []) - if t not in (None, "", "None", "null") - ] - types = None - if raw and len(raw) == len(positions): - try: - types = [PointType(t) for t in raw] - except ValueError: - types = None - self.set_taught_points(positions, types, robot=name) - - # ---------------------------- 多机出码 ---------------------------- # + return _program_export.restore_robots(self, data) def generate_all_code(self) -> Dict[str, str]: - """给**每一台**机器人各生成一份目标控制器代码。 - - 后处理器统一用下拉里选的那个(``None`` = 各自按品牌推断),这样一次按钮 - 就能拿到"整条产线"的程序,而不是逐台点一遍。 - - Returns: - ``{机器人名: 代码}``;某台生成失败时不进结果,原因已广播到状态栏 - """ - codes: Dict[str, str] = {} - if not self._states: - self.statusMessage.emit(tr("请先加载机器人并示教至少一个点")) - return codes - choice = self.combo_post.currentData() - for name in list(self._states): - program = self.build_program(name) - if program is None: - self.statusMessage.emit(tr("{name}:无示教点,已跳过").format(name=name)) - continue - try: - codes[name] = program.to_post(choice, metadata={"program_name": name}) - except Exception as exc: - self.statusMessage.emit(tr("{name} 生成失败:{exc}").format(name=name, exc=exc)) - # 预览仍保持当前这台:点了"导出全部"就把预览换成别人家的代码,很容易 - # 让人以为当前编辑的那份丢了 - if self._active in codes: - self._last_code = codes[self._active] - self.code_preview.setPlainText(codes[self._active]) - if codes: - self.statusMessage.emit(tr("已生成 {n} 台机器人的代码").format(n=len(codes))) - return codes - - def _show_report(self, report: ProgramReport) -> None: - """把报告填进界面。""" - self.lbl_cycle.setText( - f"{report.cycle_time:.2f} s" if report.cycle_time is not None else "—" - ) - self.lbl_length.setText(f"{report.path_length:.1f} mm") - self.lbl_points.setText( - tr("{w} 个 / {m} 条指令").format(w=report.num_waypoints, m=report.num_motions) - ) - - self.issue_list.clear() - if report.ok and not report.warnings: - item = QtWidgets.QListWidgetItem(tr("✓ 程序校验通过,未发现问题")) - item.setForeground(QtGui.QColor("#6fdc6f")) - self.issue_list.addItem(item) - return - - for issue in report.errors: - item = QtWidgets.QListWidgetItem(str(issue)) - item.setForeground(QtGui.QColor("#ff8080")) - item.setToolTip(issue.message) - self.issue_list.addItem(item) - for issue in report.warnings: - item = QtWidgets.QListWidgetItem(str(issue)) - item.setForeground(QtGui.QColor("#ffc866")) - item.setToolTip(issue.message) - self.issue_list.addItem(item) - - def _clear_metrics(self) -> None: - self.lbl_cycle.setText("—") - self.lbl_length.setText("—") - self.lbl_points.setText("—") + return _program_export.generate_all_code(self) # ---------------------------- 回放 ---------------------------- # def start_playback(self, robots: Optional[Sequence[str]] = None) -> None: - """从轨迹起点开始回放。 - - Args: - robots: 要回放的机器人名序列;``None`` 表示当前这台。 - 多台时**各自按自己的时间轴**推进但共用同一个定时器——这就是 - 多机协同回放:能直观看出两台是否会抢同一个工位 - """ - if isinstance(robots, bool) or isinstance(robots, str): - # ``clicked(bool)`` 会把 False 灌进来(``list(False)`` 直接崩), - # 单个名字的字符串也会被拆成一串字符——都不是"机器人名单" - robots = [robots] if isinstance(robots, str) else None - wanted = list(robots) if robots is not None else self._playable() - names = [n for n in wanted if self._playable(n)] - if not names: - # 不用模态弹窗:无头/自动化场景下会永久阻塞,且"还没校验"只是 - # 普通状态提示,不值得打断用户 - self.statusMessage.emit(tr("请先校验程序以生成轨迹,再回放")) - return - - for name in names: - state = self._states[name] - traj = state.trajectory - state.playhead = float(traj.waypoints[0].timestamp) - # 回放从头开始:夹爪状态复位为未夹持,主窗口会据此放下工件 - if state.gripper_animator is not None: - state.gripper_animator.reset() - self._set_gripper_for(name, "") - - self._playing = names - self.btn_stop.setEnabled(True) - self._timer.start() - if len(names) > 1: - self.statusMessage.emit(tr("多机回放中…({n} 台)").format(n=len(names))) - else: - self.statusMessage.emit(tr("轨迹回放中…")) + return _program_playback.start_playback(self, robots) def start_playback_all(self) -> None: - """所有已校验过的机器人**锁步同播**(多机回放)。""" - if not self._playable(): - self.statusMessage.emit(tr("没有已生成轨迹的机器人——请先校验程序")) - return - self.start_playback(self._playable()) + return _program_playback.start_playback_all(self) def _playable(self, name: Optional[str] = None) -> List[str]: - """可回放的机器人名——必须已校验出多于一个路点的轨迹。 - - Args: - name: 只看这台;``None`` 看全部 - """ - keys = [name] if name is not None else list(self._states) - out = [] - for key in keys: - state = self._states.get(key) - if state is None: - continue - traj = state.trajectory - if traj is not None and len(traj.waypoints) > 1 and state.joint_names: - out.append(key) - return out + return _program_playback._playable(self, name) def stop_playback(self) -> None: - """停止回放。""" - was_running = self._timer.isActive() - self._timer.stop() - self._playing = [] - self.btn_stop.setEnabled(False) - if was_running: - self.playbackFinished.emit() + return _program_playback.stop_playback(self) def _on_tick(self) -> None: - """推进所有在播机器人的播放头并发射关节帧。""" - if not getattr(self, "_playing", None): - self.stop_playback() - return - # 到终点的机器人自己退出;全部到点后整体收尾 - self._playing = [name for name in self._playing if self._tick_one(name)] - if not self._playing: - self.stop_playback() + return _program_playback._on_tick(self) def _tick_one(self, name: str) -> bool: - """推进 ``name`` 一帧并发给它自己的视口;未到终点返回 ``True``。""" - state = self._states.get(name) - traj = state.trajectory if state is not None else None - if traj is None or not state.joint_names: - return False - - # 用定时器标称间隔推进(按速度百分比缩放)。定时器本身有抖动, - # 但对"看一遍路径怎么走"这个目的足够,不值得引入实时时钟补偿的复杂度 - scale = 100.0 / max(self.spin_speed.value(), 1) - state.playhead += (PLAYBACK_INTERVAL_MS / 1000.0) / scale - - t_end = float(traj.waypoints[-1].timestamp) - done = state.playhead >= t_end - if done: - # 终点处的事件(程序以 gripper 收尾)也要应用 - self._emit_one(name, traj.waypoints[-1].joints) - if state.gripper_animator is not None: - self._set_gripper_for(name, state.gripper_animator.state_at(t_end) or "") - return False - - waypoint = traj.interpolate(state.playhead) - self._emit_one(name, waypoint.joints) - # 越过夹爪事件时刻时通知主窗口吸附/释放工件 - if state.gripper_animator is not None: - self._set_gripper_for(name, state.gripper_animator.state_at(state.playhead) or "") - return True + return _program_playback._tick_one(self, name) def _emit_one(self, name: str, joints_deg: Sequence[float]) -> None: - """把 ``name`` 这一帧的关节角(度)换算为弧度发给它自己的机器人。""" - state = self._states[name] - values = {} - for i, joint in enumerate(state.joint_names): - if i < len(joints_deg): - values[joint] = math.radians(float(joints_deg[i])) - frame = PlaybackFrame(robot_name=name, joints_rad=values) - self.playbackFrame.emit(frame.robot_name, dict(frame.joints_rad)) - self.playbackFrameDTO.emit(frame) + return _program_playback._emit_one(self, name, joints_deg) def _emit_frame(self, joints_deg: Sequence[float]) -> None: - """兼容旧调用面:给**当前**机器人发一帧。""" - if self._active is not None and self._active in self._states: - self._emit_one(self._active, joints_deg) + return _program_playback._emit_frame(self, joints_deg) def _set_gripper_for(self, name: str, action: str) -> None: - """广播 ``name`` 这台机器人的夹爪状态(去重,避免每帧刷屏)。""" - state = self._states.get(name) - if state is None: - return - if action != state.last_gripper_state: - state.last_gripper_state = action - self.gripperStateChanged.emit(name, action) + return _program_playback._set_gripper_for(self, name, action) def _set_gripper_state(self, action: str) -> None: - """兼容旧调用面:给**当前**机器人设夹爪状态。""" - if self._active is not None: - self._set_gripper_for(self._active, action) + return _program_playback._set_gripper_state(self, action) + + # ---------------------------- 抓取向导 ---------------------------- # + + def grasp_cycle_points( + self, + pick_index: int, + place_index: Optional[int] = None, + approach_mm: float = 50.0, + lift_mm: float = 50.0, + retract_mm: float = 50.0, + robot: Optional[str] = None, + ) -> Tuple[List[Dict[str, float]], List[PointType], List[str]]: + return _program_grasp.grasp_cycle_points( + self, pick_index, place_index, approach_mm, lift_mm, retract_mm, robot + ) + + def apply_grasp_cycle( + self, + pick_index: int, + place_index: Optional[int] = None, + approach_mm: float = 50.0, + lift_mm: float = 50.0, + retract_mm: float = 50.0, + replace: bool = False, + robot: Optional[str] = None, + ) -> int: + return _program_grasp.apply_grasp_cycle( + self, pick_index, place_index, approach_mm, lift_mm, retract_mm, replace, robot + ) + + def open_grasp_wizard(self, robot: Optional[str] = None) -> bool: + return _program_grasp.open_grasp_wizard(self, robot) + + # ---------------------------- 界面 ---------------------------- # + + def _build_ui(self) -> None: + return _program_ui._build_ui(self) + + def _show_report(self, report: ProgramReport) -> None: + return _program_ui._show_report(self, report) + + def _clear_metrics(self) -> None: + return _program_ui._clear_metrics(self) + + def _display_active(self) -> None: + return _program_ui._display_active(self) + + def _refresh_robot_combo(self, selected: Optional[str] = None) -> None: + return _program_ui._refresh_robot_combo(self, selected) + + def _refresh_export_all_enabled(self) -> None: + return _program_ui._refresh_export_all_enabled(self) diff --git a/app/views/program_playback.py b/app/views/program_playback.py new file mode 100644 index 0000000..51a6929 --- /dev/null +++ b/app/views/program_playback.py @@ -0,0 +1,166 @@ +"""离线编程面板的回放控制:定时器推进、逐机发帧、夹爪状态广播。 + +多台机器人共用同一个 ``QTimer``,各自按自己的时间轴推进(锁步同播)—— +多机协同回放能直观看出两台是否会抢同一个工位。面板内部角度一律为度, +发向视口的回放帧换算为弧度(``GLWidget.renderer.joint_values`` 用弧度)。 +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, List, Optional, Sequence + +from app.contracts.program import PlaybackFrame +from app.views.program_state import PLAYBACK_INTERVAL_MS +from support.i18n import tr + +if TYPE_CHECKING: # 仅类型标注 + from app.views.program_panel import ProgramPanel + + +def start_playback(panel, robots: Optional[Sequence[str]] = None) -> None: + """从轨迹起点开始回放。 + + Args: + robots: 要回放的机器人名序列;``None`` 表示当前这台。 + 多台时**各自按自己的时间轴**推进但共用同一个定时器——这就是 + 多机协同回放:能直观看出两台是否会抢同一个工位 + """ + if isinstance(robots, bool) or isinstance(robots, str): + # ``clicked(bool)`` 会把 False 灌进来(``list(False)`` 直接崩), + # 单个名字的字符串也会被拆成一串字符——都不是"机器人名单" + robots = [robots] if isinstance(robots, str) else None + wanted = list(robots) if robots is not None else panel._playable() + names = [n for n in wanted if panel._playable(n)] + if not names: + # 不用模态弹窗:无头/自动化场景下会永久阻塞,且"还没校验"只是 + # 普通状态提示,不值得打断用户 + panel.statusMessage.emit(tr("请先校验程序以生成轨迹,再回放")) + return + + for name in names: + state = panel._states[name] + traj = state.trajectory + state.playhead = float(traj.waypoints[0].timestamp) + # 回放从头开始:夹爪状态复位为未夹持,主窗口会据此放下工件 + if state.gripper_animator is not None: + state.gripper_animator.reset() + panel._set_gripper_for(name, "") + + panel._playing = names + panel.btn_stop.setEnabled(True) + panel._timer.start() + if len(names) > 1: + panel.statusMessage.emit(tr("多机回放中…({n} 台)").format(n=len(names))) + else: + panel.statusMessage.emit(tr("轨迹回放中…")) + + +def start_playback_all(panel) -> None: + """所有已校验过的机器人**锁步同播**(多机回放)。""" + if not panel._playable(): + panel.statusMessage.emit(tr("没有已生成轨迹的机器人——请先校验程序")) + return + panel.start_playback(panel._playable()) + + +def _playable(panel, name: Optional[str] = None) -> List[str]: + """可回放的机器人名——必须已校验出多于一个路点的轨迹。 + + Args: + name: 只看这台;``None`` 看全部 + """ + keys = [name] if name is not None else list(panel._states) + out = [] + for key in keys: + state = panel._states.get(key) + if state is None: + continue + traj = state.trajectory + if traj is not None and len(traj.waypoints) > 1 and state.joint_names: + out.append(key) + return out + + +def stop_playback(panel) -> None: + """停止回放。""" + was_running = panel._timer.isActive() + panel._timer.stop() + panel._playing = [] + panel.btn_stop.setEnabled(False) + if was_running: + panel.playbackFinished.emit() + + +def _on_tick(panel) -> None: + """推进所有在播机器人的播放头并发射关节帧。""" + if not getattr(panel, "_playing", None): + panel.stop_playback() + return + # 到终点的机器人自己退出;全部到点后整体收尾 + panel._playing = [name for name in panel._playing if panel._tick_one(name)] + if not panel._playing: + panel.stop_playback() + + +def _tick_one(panel, name: str) -> bool: + """推进 ``name`` 一帧并发给它自己的视口;未到终点返回 ``True``。""" + state = panel._states.get(name) + traj = state.trajectory if state is not None else None + if traj is None or not state.joint_names: + return False + + # 用定时器标称间隔推进(按速度百分比缩放)。定时器本身有抖动, + # 但对"看一遍路径怎么走"这个目的足够,不值得引入实时时钟补偿的复杂度 + scale = 100.0 / max(panel.spin_speed.value(), 1) + state.playhead += (PLAYBACK_INTERVAL_MS / 1000.0) / scale + + t_end = float(traj.waypoints[-1].timestamp) + done = state.playhead >= t_end + if done: + # 终点处的事件(程序以 gripper 收尾)也要应用 + panel._emit_one(name, traj.waypoints[-1].joints) + if state.gripper_animator is not None: + panel._set_gripper_for(name, state.gripper_animator.state_at(t_end) or "") + return False + + waypoint = traj.interpolate(state.playhead) + panel._emit_one(name, waypoint.joints) + # 越过夹爪事件时刻时通知主窗口吸附/释放工件 + if state.gripper_animator is not None: + panel._set_gripper_for(name, state.gripper_animator.state_at(state.playhead) or "") + return True + + +def _emit_one(panel, name: str, joints_deg: Sequence[float]) -> None: + """把 ``name`` 这一帧的关节角(度)换算为弧度发给它自己的机器人。""" + state = panel._states[name] + values = {} + for i, joint in enumerate(state.joint_names): + if i < len(joints_deg): + values[joint] = math.radians(float(joints_deg[i])) + frame = PlaybackFrame(robot_name=name, joints_rad=values) + panel.playbackFrame.emit(frame.robot_name, dict(frame.joints_rad)) + panel.playbackFrameDTO.emit(frame) + + +def _emit_frame(panel, joints_deg: Sequence[float]) -> None: + """兼容旧调用面:给**当前**机器人发一帧。""" + if panel._active is not None and panel._active in panel._states: + panel._emit_one(panel._active, joints_deg) + + +def _set_gripper_for(panel, name: str, action: str) -> None: + """广播 ``name`` 这台机器人的夹爪状态(去重,避免每帧刷屏)。""" + state = panel._states.get(name) + if state is None: + return + if action != state.last_gripper_state: + state.last_gripper_state = action + panel.gripperStateChanged.emit(name, action) + + +def _set_gripper_state(panel, action: str) -> None: + """兼容旧调用面:给**当前**机器人设夹爪状态。""" + if panel._active is not None: + panel._set_gripper_for(panel._active, action) diff --git a/app/views/program_robot.py b/app/views/program_robot.py new file mode 100644 index 0000000..70e543b --- /dev/null +++ b/app/views/program_robot.py @@ -0,0 +1,126 @@ +"""离线编程面板的机器人管理:登记、切换、注销、场景绑定。 + +每台机器人按名字各自留存一份 :class:`~app.views.program_state.RobotProgram` +状态槽,多机不再互相顶掉;登记/切换/注销都会同步面板顶部的机器人下拉, +并向主窗口广播 ``activeRobotChanged`` 以同步示教点与视口显示。 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Optional, Sequence + +from support.i18n import tr + +if TYPE_CHECKING: # 仅类型标注 + from domain.robot import Robot + from domain.world.scene import Scene + + from app.views.program_panel import ProgramPanel + + +def set_robot( + panel, + robot: Optional["Robot"], + joint_names: Optional[Sequence[str]] = None, + name: Optional[str] = None, +) -> None: + """登记一台机器人:**按名字各自留存**,多机不再互相顶掉。 + + Args: + robot: 机器人实例;``None`` 表示卸载 ``name``(或当前这台) + joint_names: 示教点字典里用的关节名(视口渲染器的命名,可能比 + 可驱动关节多——URDF 里的 ``fixed`` 关节,如 ``tcp_joint``, + 也会被渲染器登记)。为 ``None`` 时取 ``robot.joint_names`` + name: 场景里的机器人名。缺省取 ``robot.name``。登记后自动切到它, + 这样示教/校验/出码/回放都作用在刚加进来的那台上 + + Note: + 这里必须把关节名过滤到**可驱动**的那些。渲染器会把 fixed 关节 + 一并登记,照单全收的话 ``moveJ`` 会收到多出来的分量,直接被 + 自由度校验拒掉——表现为"每条指令都失败",极难排查。 + """ + if robot is None: + panel.remove_robot(name or panel._active) + return + + key = name or robot.name + # 必须先把「当前」指过去:下面的赋值都经由代理属性落到这一台的槽里 + panel._active = key + state = panel._ensure_state(key) + + panel.stop_playback() + state.trajectory = None + state.report = None + state.point_issue_level = {} + state.build_notes = [] + panel.issue_list.clear() + + source = list(joint_names or robot.joint_names) + actuated = list(robot.joint_names) + known = set(actuated) + filtered = [n for n in source if n in known] + # 命名对不上时宁可退回权威列表,也不要留下长度不匹配的隐患 + state.joint_names = filtered if len(filtered) == len(actuated) else actuated + panel._robot = robot + panel.btn_generate.setEnabled(True) + # 「导出全部」的门槛与「生成代码」完全相同——它内部自己构建程序 + # (见 export_all_programs 的文档),不该逼用户先点一次「生成代码」才亮。 + panel._refresh_export_all_enabled() + panel.lbl_status.setText(tr("{name} · 就绪").format(name=key)) + panel.lbl_status.setObjectName("ok") + # 重新套用样式表,objectName 改变后需手动刷新 + panel.lbl_status.style().polish(panel.lbl_status) + panel._refresh_robot_combo(key) + + +def active_robot(panel) -> Optional[str]: + """当前正在编辑/回放的机器人名。""" + return panel._active + + +def robot_names(panel) -> List[str]: + """已登记的机器人名(插入顺序)。""" + return list(panel._states) + + +def set_active_robot(panel, name: Optional[str]) -> bool: + """切换当前编辑的机器人,界面随之回填**它自己**的数据。 + + Returns: + 切换成功 ``True``;名字未登记返回 ``False`` + """ + if not name or name not in panel._states: + return False + if name != panel._active: + panel.stop_playback() + panel._active = name + panel._refresh_robot_combo(name) + panel._display_active() + return True + + +def remove_robot(panel, name: Optional[str]) -> None: + """注销一台机器人(场景里删掉它时调用),遗留状态一并清理。""" + if not name or name not in panel._states: + return + if name == panel._active: + panel.stop_playback() + panel._states.pop(name, None) + panel._refresh_robot_combo() + if name == panel._active: + panel._active = next(iter(panel._states), None) + panel._display_active() + + +def _on_robot_combo_changed(panel, _index: int) -> None: + """下拉换人:切到对应机器人,并广播给主窗口同步示教点/视口。""" + name = panel.combo_robot.currentData() + if not name or name == panel._active: + return + panel.set_active_robot(name) + panel.activeRobotChanged.emit(name) + + +def set_scene(panel, scene: Optional["Scene"]) -> None: + """绑定工作单元场景;给出后校验会包含沿路径干涉检查。""" + panel._scene = scene diff --git a/app/views/program_state.py b/app/views/program_state.py new file mode 100644 index 0000000..bb737ea --- /dev/null +++ b/app/views/program_state.py @@ -0,0 +1,48 @@ +"""离线编程面板的状态槽:每台机器人一份私有编程状态,外加回放定时器口径。 + +纯数据模块,不依赖 Qt。``RobotProgram`` 让面板按机器人名各自隔离示教点、 +报告、轨迹等状态(多机不再互相顶掉);``PLAYBACK_INTERVAL_MS`` 是回放帧率 +对应的定时器间隔,面板初始化与回放推进(``program_playback``)共用这一处口径。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Dict, List, Optional + +if TYPE_CHECKING: # 仅类型标注 + from domain.robot import Robot + from domain.world.animation import GraspAnimator + from service.pickplace import PointType + from service.planner import ProgramReport, TrajectoryPlanner + from service.simulation import Trajectory + +#: 回放帧率对应的定时器间隔 (ms) +PLAYBACK_INTERVAL_MS = 40 + + +@dataclass +class RobotProgram: + """一台机器人的**私有**离线编程状态。 + + 面板的所有既有方法(``build_program`` / ``validate_program`` / + ``start_playback`` …)写的都是"当前机器人的数据"。以前这些数据直接是 + 面板的字段,多机时第二台会把第一台顶掉——现在每台各占一个本类的实例, + 面板按 :attr:`ProgramPanel._active` 取用。 + """ + + robot: Optional["Robot"] = None + joint_names: List[str] = field(default_factory=list) + taught: List[Dict[str, float]] = field(default_factory=list) + point_types: Optional[List[PointType]] = None + point_motions: List[str] = field(default_factory=list) + build_notes: List[str] = field(default_factory=list) + trajectory: Optional["Trajectory"] = None + report: Optional[ProgramReport] = None + planner: Optional[TrajectoryPlanner] = None + gripper_animator: Optional[GraspAnimator] = None + last_gripper_state: str = "" + point_issue_level: Dict[int, str] = field(default_factory=dict) + last_point_count: Optional[int] = None + last_code: str = "" + playhead: float = 0.0 diff --git a/app/views/program_ui.py b/app/views/program_ui.py new file mode 100644 index 0000000..9a7d938 --- /dev/null +++ b/app/views/program_ui.py @@ -0,0 +1,381 @@ +"""离线编程面板的界面构建与渲染:布局、控件、报告与指标回填。 + +本模块只管"长什么样"——所有控件都是 ``ProgramPanel`` 的实例属性 +(``panel.combo_robot`` / ``panel.btn_*`` / ``panel.point_list`` …), +业务逻辑在 ``program_validate`` / ``program_editor`` 等模块。信号连接的 +目标一律是 ``panel.<方法>`` 薄 stub,由面板转发到对应实现模块。 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from PySide6 import QtGui, QtWidgets + +from adapter.post import get_post_processor, list_post_processors +from support.i18n import tr + +if TYPE_CHECKING: # 仅类型标注 + from service.planner import ProgramReport + + from app.views.program_panel import ProgramPanel + +PANEL_STYLE = """ +QWidget { background-color: #2b2b2b; color: #e0e0e0; font-size: 12px; } +QGroupBox { + border: 1px solid #555; border-radius: 4px; + /* margin-top 必须容纳一行标题文字(约16px),否则标题上半截会被部件顶边裁掉 */ + margin-top: 16px; padding-top: 8px; font-weight: bold; +} +QGroupBox::title { + subcontrol-origin: margin; subcontrol-position: top left; + left: 8px; padding: 0 4px; +} +QPushButton { + background-color: #3c3c3c; border: 1px solid #666; + border-radius: 3px; padding: 5px 10px; +} +QPushButton:hover { background-color: #4a4a4a; } +QPushButton:pressed { background-color: #555; } +QPushButton:disabled { color: #777; background-color: #333; } +QListWidget { background-color: #1e1e1e; border: 1px solid #555; border-radius: 3px; } +QLabel#metric { color: #7fd3ff; font-weight: bold; } +QLabel#ok { color: #6fdc6f; font-weight: bold; } +QLabel#bad { color: #ff8080; font-weight: bold; } +QSpinBox { background-color: #1e1e1e; border: 1px solid #555; padding: 3px; } +""" + + +def _button_grid(buttons, columns=2): + """把按钮排成**等宽网格**——侧栏里的按钮组一律用它,不要再用单行 QHBoxLayout。 + + 这个面板在右侧栏里只有 260–360px。一行摆 4–5 个按钮时 ``QHBoxLayout`` 不会 + 换行,只会把每个按钮压到 minimumSize 以下(文字被截成几个字甚至只剩色块), + 并且——更糟的——它会把内容**撑到比可视区更宽**(实测最小内容宽约 400px), + 于是右侧一整列被裁到看不见,用户看到的就是"控件重叠/显示不全"。 + 网格让它按列数自己排,窄面板里也保持可读。 + + :param columns: 每行几个;末行只剩一个时自动跨满整行,不留半边空白。 + """ + grid = QtWidgets.QGridLayout() + grid.setHorizontalSpacing(4) + grid.setVerticalSpacing(4) + span_all = len(buttons) % columns == 1 + for i, btn in enumerate(buttons): + btn.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + row, col = divmod(i, columns) + # 末行落单的那个跨满整行 + last = i == len(buttons) - 1 + grid.addWidget(btn, row, col, 1, columns if (last and span_all) else 1) + return grid + + +def _build_ui(panel) -> None: + # 内容自然高度约 798px,而标签页可视高度随窗口变化(1280×800 时仅 725px)。 + # 不套滚动容器会把底部的「后处理与导出」挤出可视区——与右侧属性面板 + # 曾经踩过的坑同源。 + outer = QtWidgets.QVBoxLayout(panel) + outer.setContentsMargins(0, 0, 0, 0) + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameStyle(QtWidgets.QFrame.NoFrame) + inner = QtWidgets.QWidget() + scroll.setWidget(inner) + outer.addWidget(scroll) + + root = QtWidgets.QVBoxLayout(inner) + root.setContentsMargins(6, 6, 6, 6) + + # --- 多机器人选择:出码/校验/回放全部按这里选中的那台走 --- + robot_row = QtWidgets.QHBoxLayout() + robot_row.addWidget(QtWidgets.QLabel(tr("机器人"))) + panel.combo_robot = QtWidgets.QComboBox() + panel.combo_robot.setSizeAdjustPolicy(QtWidgets.QComboBox.SizeAdjustPolicy.AdjustToContents) + panel.combo_robot.currentIndexChanged.connect(panel._on_robot_combo_changed) + robot_row.addWidget(panel.combo_robot, 1) + root.addLayout(robot_row) + + # --- 节拍与规模 --- + box = QtWidgets.QGroupBox(tr("程序概览")) + form = QtWidgets.QFormLayout(box) + panel.lbl_status = QtWidgets.QLabel(tr("未加载机器人")) + panel.lbl_status.setObjectName("bad") + form.addRow(tr("状态"), panel.lbl_status) + panel.lbl_cycle = QtWidgets.QLabel("—") + panel.lbl_cycle.setObjectName("metric") + form.addRow(tr("估算节拍"), panel.lbl_cycle) + panel.lbl_length = QtWidgets.QLabel("—") + panel.lbl_length.setObjectName("metric") + form.addRow(tr("TCP 路径"), panel.lbl_length) + panel.lbl_points = QtWidgets.QLabel("—") + form.addRow(tr("路径点"), panel.lbl_points) + root.addWidget(box) + + # --- 操作 --- + ops = QtWidgets.QGroupBox(tr("操作")) + ops_layout = QtWidgets.QVBoxLayout(ops) + + speed_row = QtWidgets.QHBoxLayout() + speed_row.addWidget(QtWidgets.QLabel(tr("进给速度"))) + panel.spin_speed = QtWidgets.QSpinBox() + panel.spin_speed.setRange(1, 100) + panel.spin_speed.setValue(100) + panel.spin_speed.setSuffix(" %") + speed_row.addWidget(panel.spin_speed, 1) + ops_layout.addLayout(speed_row) + + # 「校验程序 / 回放」这组**单机**按钮删掉了:和「校验全部 / 全部回放」 + # 完全重复——只有一台时"全部"就是那一台,两排按钮只会让人疑惑该点哪个 + # (2026-09-21 用户反馈)。F7 / Shift+F7 快捷键保留,走主窗口菜单。 + # 顺序按用户要求:校验全部 → 全部回放 → 停止 → 抓取向导。 + panel.btn_validate_all = QtWidgets.QPushButton(tr("校验全部")) + panel.btn_validate_all.setToolTip( + tr("逐台校验所有机器人并汇总结果——不必一台一台切过去按 F7") + ) + panel.btn_validate_all.clicked.connect(lambda: panel.validate_all()) + panel.btn_play_all = QtWidgets.QPushButton(tr("全部回放")) + panel.btn_play_all.setToolTip(tr("所有已校验的机器人同时回放——检查多机协同是否撞车")) + # ``clicked`` 带一个 bool 参数:**不能**直接连到带可选参数的槽—— + # ``start_playback_all()`` 这类槽一律无参,或用 lambda 吃掉 checked。 + panel.btn_play_all.clicked.connect(panel.start_playback_all) + panel.btn_play_all.setEnabled(False) + panel.btn_stop = QtWidgets.QPushButton(tr("停止")) + panel.btn_stop.clicked.connect(panel.stop_playback) + panel.btn_stop.setEnabled(False) + # 单机也要有:示教是逐台做的活,跟有几台机器人无关 + panel.btn_grasp_wizard = QtWidgets.QPushButton(tr("抓取向导…")) + panel.btn_grasp_wizard.setToolTip( + tr("由抓取点/放置点自动生成接近、提升、撤离点——不必逐个手动示教") + ) + panel.btn_grasp_wizard.clicked.connect(lambda: panel.open_grasp_wizard()) + ops_layout.addLayout( + _button_grid( + [ + panel.btn_validate_all, + panel.btn_play_all, + panel.btn_stop, + panel.btn_grasp_wizard, + ] + ) + ) + root.addWidget(ops) + + # --- 轨迹编辑器(v0.2.4):路点增删移改 + 运动类型切换 --- + editor = QtWidgets.QGroupBox(tr("轨迹编辑")) + editor_layout = QtWidgets.QVBoxLayout(editor) + + panel.point_list = QtWidgets.QListWidget() + panel.point_list.setAlternatingRowColors(True) + panel.point_list.setWordWrap(True) + panel.point_list.itemDoubleClicked.connect(lambda _item: panel.edit_selected_point()) + editor_layout.addWidget(panel.point_list, 1) + + panel.btn_insert_point = QtWidgets.QPushButton(tr("插入")) + panel.btn_insert_point.setToolTip(tr("在选中点之后插入一个路点(复制其关节值)")) + # 同 btn_play:把 ``clicked`` 的 bool 吃掉,否则 False 会被当成第 0 行 + panel.btn_insert_point.clicked.connect(lambda _checked=False: panel.insert_point()) + panel.btn_delete_point = QtWidgets.QPushButton(tr("删除")) + panel.btn_delete_point.clicked.connect(lambda _checked=False: panel.delete_point()) + panel.btn_edit_point = QtWidgets.QPushButton(tr("编辑位姿…")) + panel.btn_edit_point.setToolTip(tr("修改选中路点的关节角(度)与运动类型(双击同效)")) + panel.btn_edit_point.clicked.connect(panel.edit_selected_point) + editor_layout.addLayout( + _button_grid([panel.btn_insert_point, panel.btn_delete_point, panel.btn_edit_point]) + ) + + panel.btn_move_up = QtWidgets.QPushButton(tr("上移")) + panel.btn_move_up.clicked.connect(lambda: panel.shift_selected_point(-1)) + panel.btn_move_down = QtWidgets.QPushButton(tr("下移")) + panel.btn_move_down.clicked.connect(lambda: panel.shift_selected_point(1)) + # 运动类型(moveJ/moveL/moveC)**不在这里改**——面板只留位置调整。 + # 要改指令类型去「编辑位姿…」对话或直接在路径代码里改:放在列表旁一排 + # 按钮反而容易误点,且与代码里的 ``movej`` 行两处可改、互不一致。 + editor_layout.addLayout(_button_grid([panel.btn_move_up, panel.btn_move_down])) + + hint = QtWidgets.QLabel( + tr( + "改动后需重新校验:着色与节拍会随校验结果刷新" + "(红=错误 / 橙=警告 / 绿=正常)\n" + "圆弧口径:第 N 行的运动类型描述「到达第 N 点」的运动——" + "moveC 以上一点为中间点、再上一点为起点;中间点行显示为「moveC 中间点」" + ) + ) + hint.setWordWrap(True) + editor_layout.addWidget(hint) + root.addWidget(editor) + + # --- 问题清单 --- + issues = QtWidgets.QGroupBox(tr("校验结果")) + issues_layout = QtWidgets.QVBoxLayout(issues) + panel.issue_list = QtWidgets.QListWidget() + panel.issue_list.setAlternatingRowColors(True) + panel.issue_list.setWordWrap(True) + issues_layout.addWidget(panel.issue_list) + root.addWidget(issues, 1) + + # --- 设备联调:后处理 / 导出 / 真机(预留) --- + comm = QtWidgets.QGroupBox(tr("后处理与导出")) + comm_layout = QtWidgets.QVBoxLayout(comm) + + pp_row = QtWidgets.QHBoxLayout() + pp_row.addWidget(QtWidgets.QLabel(tr("后处理器"))) + panel.combo_post = QtWidgets.QComboBox() + panel.combo_post.addItem(tr("自动(按机器人品牌)"), None) + for name in list_post_processors(): + pp = get_post_processor(name) + panel.combo_post.addItem(f"{pp.brand.upper()} {pp.controller.upper()}", name) + pp_row.addWidget(panel.combo_post, 1) + comm_layout.addLayout(pp_row) + + panel.btn_generate = QtWidgets.QPushButton(tr("生成代码")) + panel.btn_generate.clicked.connect(panel.generate_code) + panel.btn_generate.setEnabled(False) + # 「一键导出…」(只导当前那台)去掉了:实际产线是多台的,逐个点太笨 + # (2026-09-21 用户反馈)。导出入口统一为「导出全部…」:每台机器人 + # 各落一份**独立程序文件**——各自的品牌扩展名、各自的机器人名, + # 不是拼成一个大文件。 + panel.btn_export_all = QtWidgets.QPushButton(tr("导出全部…")) + panel.btn_export_all.setToolTip( + tr( + "为每一台机器人各落一份独立程序文件到同一目录" + "(UR .script / KUKA .src / ABB .mod / FANUC .tp)" + ) + ) + panel.btn_export_all.clicked.connect(panel.export_all_programs) + panel.btn_export_all.setEnabled(False) + comm_layout.addLayout(_button_grid([panel.btn_generate, panel.btn_export_all])) + + panel.code_preview = QtWidgets.QTextEdit() + panel.code_preview.setReadOnly(True) + panel.code_preview.setLineWrapMode(QtWidgets.QTextEdit.NoWrap) + mono = QtGui.QFont("Consolas", 9) + mono.setStyleHint(QtGui.QFont.Monospace) + panel.code_preview.setFont(mono) + panel.code_preview.setFixedHeight(150) + panel.code_preview.setPlaceholderText(tr("生成的控制器代码将显示在这里")) + comm_layout.addWidget(panel.code_preview) + + # 「上传真机 / 真机验证」:从未上过真机验证,MVP 档**藏掉**而不是置灰 + # ——灰色按钮同样会被当成产品能力(2026-09-21 用户反馈:等 MVP 做完 + # 再开启)。CORE_FEATURES=full 或放开清单即恢复。 + # 门控必须查 program_panel 的模块字典(迟绑定):既有测试把补丁打在 + # pp_mod._feature_enabled 上,查本模块自己的全局会让补丁静默失效。 + from app.views import program_panel as _pp + + if _pp._feature_enabled("real_machine_upload"): + panel.btn_upload = QtWidgets.QPushButton(tr("上传真机")) + panel.btn_upload.setEnabled(False) + panel.btn_upload.setToolTip(tr("预留:接入 ControllerLink 后支持直连控制器上传")) + panel.btn_verify = QtWidgets.QPushButton(tr("真机验证")) + panel.btn_verify.setEnabled(False) + panel.btn_verify.setToolTip(tr("预留:接入 ControllerLink.read_joints 后做真机回读比对")) + comm_layout.addLayout(_button_grid([panel.btn_upload, panel.btn_verify])) + + root.addWidget(comm) + + +def _show_report(panel, report: "ProgramReport") -> None: + """把报告填进界面。""" + panel.lbl_cycle.setText( + f"{report.cycle_time:.2f} s" if report.cycle_time is not None else "—" + ) + panel.lbl_length.setText(f"{report.path_length:.1f} mm") + panel.lbl_points.setText( + tr("{w} 个 / {m} 条指令").format(w=report.num_waypoints, m=report.num_motions) + ) + + panel.issue_list.clear() + if report.ok and not report.warnings: + item = QtWidgets.QListWidgetItem(tr("✓ 程序校验通过,未发现问题")) + item.setForeground(QtGui.QColor("#6fdc6f")) + panel.issue_list.addItem(item) + return + + for issue in report.errors: + item = QtWidgets.QListWidgetItem(str(issue)) + item.setForeground(QtGui.QColor("#ff8080")) + item.setToolTip(issue.message) + panel.issue_list.addItem(item) + for issue in report.warnings: + item = QtWidgets.QListWidgetItem(str(issue)) + item.setForeground(QtGui.QColor("#ffc866")) + item.setToolTip(issue.message) + panel.issue_list.addItem(item) + + +def _clear_metrics(panel) -> None: + panel.lbl_cycle.setText("—") + panel.lbl_length.setText("—") + panel.lbl_points.setText("—") + + +def _display_active(panel) -> None: + """按当前机器人的状态回填整个界面(点表 / 报告 / 代码 / 按钮可用性)。""" + state = panel._state() + panel._refresh_point_list() + panel.code_preview.setPlainText(state.last_code) + + if state.report is None: + panel.issue_list.clear() + panel._clear_metrics() + else: + panel._show_report(state.report) + panel._apply_point_colors(state.report) + + # 「校验全部」只要登记过机器人就能点(没示教的那台会被跳过并如实说明) + if getattr(panel, "btn_validate_all", None) is not None: + panel.btn_validate_all.setEnabled(bool(panel._states)) + # 「全部回放」只要有一台能播就该能点 + if getattr(panel, "btn_play_all", None) is not None: + panel.btn_play_all.setEnabled(bool(panel._playable())) + panel.btn_generate.setEnabled(state.robot is not None) + # 「导出全部」看的是全局:任何一台有程序就该能点(它会跳过没示教的) + panel._refresh_export_all_enabled() + + if state.robot is None: + panel.lbl_status.setText(tr("未加载机器人")) + panel.lbl_status.setObjectName("bad") + else: + panel.lbl_status.setText(tr("{name} · 就绪").format(name=panel._active)) + panel.lbl_status.setObjectName("ok") + panel.lbl_status.style().polish(panel.lbl_status) + + +def _refresh_robot_combo(panel, selected: Optional[str] = None) -> None: + """把已登记的机器人名填进下拉,并选中 ``selected``(或当前这台)。 + + 重建时必须屏蔽信号:填项过程会让 ``currentIndex`` 跳来跳去,触发 + ``_on_robot_combo_changed`` 会在回填途中反复切换机器人。 + """ + if getattr(panel, "combo_robot", None) is None: + return + target = selected or panel._active + panel.combo_robot.blockSignals(True) + panel.combo_robot.clear() + for name in panel._states: + panel.combo_robot.addItem(name, name) + if target is not None: + idx = panel.combo_robot.findData(target) + if idx >= 0: + panel.combo_robot.setCurrentIndex(idx) + panel.combo_robot.blockSignals(False) + # 只有一台时下拉没什么可挑,藏起来保持单机界面的样子。 + # 「校验全部 / 全部回放」**单机也显示**:它们就是校验/回放入口本身, + # 只有依据"有几台"决定藏不藏会让单机用户找不到按钮 + multi = len(panel._states) > 1 + panel.combo_robot.setVisible(multi) + if getattr(panel, "btn_validate_all", None) is not None: + panel.btn_validate_all.setEnabled(bool(panel._states)) + if getattr(panel, "btn_play_all", None) is not None: + panel.btn_play_all.setEnabled(bool(panel._playable())) + if getattr(panel, "btn_grasp_wizard", None) is not None: + panel.btn_grasp_wizard.setEnabled(bool(panel._states)) + + +def _refresh_export_all_enabled(panel) -> None: + """「导出全部…」的可用性:任何一台有程序就该能点(它会跳过没示教的)。""" + if getattr(panel, "btn_export_all", None) is None: + return + panel.btn_export_all.setEnabled( + any(s.robot is not None and s.taught for s in panel._states.values()) + ) diff --git a/app/views/program_validate.py b/app/views/program_validate.py new file mode 100644 index 0000000..076d638 --- /dev/null +++ b/app/views/program_validate.py @@ -0,0 +1,336 @@ +"""离线编程面板的程序装配与校验:示教点 → Program → 校验报告/轨迹。 + +``_build_program_impl`` 按 :meth:`motion_plan` 的逐点运动类型装配程序 +(moveJ 直接下发关节值;moveL 先 FK 再直线插补;moveC 以上一点为中间点), +抓取/放置语义点自动追加 ``gripper()`` 指令——回放动画、程序校验与后处理 +出码共用同一份抓取时序。校验是只读分析:干涉检查写走的场景关节角在 +``_restore_scene_joints`` 里原样还回,不留副作用。 +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Dict, List, Optional + +from PySide6 import QtCore, QtWidgets + +from domain.world.animation import GraspAnimator +from service.pickplace import gripper_action_for +from service.planner import SEVERITY_ERROR, TrajectoryPlanner +from service.program import ProgramBuilder +from support.i18n import tr + +from app.logging import logger + +if TYPE_CHECKING: # 仅类型标注 + from service.planner import ProgramReport + from service.program import Program + + from app.views.program_panel import ProgramPanel + + +def build_program(panel, robot: Optional[str] = None) -> Optional["Program"]: + """组装**指定机器人**(默认当前这台)的程序。 + + Args: + robot: 机器人名;``None`` 表示当前正在编辑的那台。批量出全部機器人 + 的代码时逐个指名即可,界面不会跟着切走 + """ + with panel._using_robot(robot): + return panel._build_program_impl() + + +def _build_program_impl(panel) -> Optional["Program"]: + # 实际装配:调用方已把当前槽切到目标机器人 + """把示教点组装成程序。 + + 示教点内部以弧度保存,:class:`Motion` 期望角度。 + 带工艺语义的示教点(抓取/放置)会自动追加 ``gripper()`` 指令组, + 于是回放动画、程序校验与后处理出码共用同一份抓取时序。 + 运动类型逐点取自轨迹编辑器(:meth:`motion_plan`):``"L"`` 的点位先对该 + 关节配置做 FK 得到 TCP 位姿、再以 ``moveL`` 直线插补过去;``"C"`` 的点位 + 以**上一点为中间点**做 ``moveC``(中间点不再单独出指令)。 + + 圆弧正解失败(位形奇异/越限等)时**退化为关节运动并记入** + :meth:`last_build_notes`,绝不静默改成另一条几何路径却让界面以为还是圆弧。 + + Returns: + :class:`Program`;无机器人或无示教点时返回 ``None`` + """ + panel._build_notes = [] + if panel._robot is None or not panel._taught: + return None + speed = float(panel.spin_speed.value()) + builder = ProgramBuilder(panel._robot) + plan, notes = panel.motion_plan() + panel._build_notes.extend(notes) + + def _pose_of(index: int): + """路点关节配置 → TCP 位姿(度)。""" + pos = panel._taught[index] + joints = [math.degrees(float(pos.get(n, 0.0))) for n in panel._joint_names] + return panel._robot.forward_kinematics(joints), joints + + def _joint_move(index: int) -> None: + """关节运动到某个示教点——直接下发关节值、不走 FK,任何位形都可用。""" + joints = [ + math.degrees(float(panel._taught[index].get(n, 0.0))) for n in panel._joint_names + ] + builder.moveJ(joints, speed=speed) + + def _gripper_after(index: int) -> None: + """该点的工艺动作(抓取/放置)——回放与出码共用这份时序。""" + t = ( + panel._point_types[index] + if panel._point_types and index < len(panel._point_types) + else None + ) + action = gripper_action_for(t) + if action: + builder.gripper(action) + + for kind, i, via_index in plan: + if kind == "C": + moved = False + try: + via_pose, _ = _pose_of(via_index) + target_pose, _ = _pose_of(i) + except Exception: + panel._build_notes.append( + tr( + "P{n} 的圆弧正解失败——退化为关节运动(moveJ);中间点 P{k} 改为单独经过" + ).format(n=i + 1, k=via_index + 1) + ) + else: + try: + builder.moveC(via_pose, target_pose, speed=speed) + moved = True + except Exception: + panel._build_notes.append( + tr( + "P{n} 的圆弧无法加入程序——退化为关节运动(moveJ);中间点 P{k} 改为单独经过" + ).format(n=i + 1, k=via_index + 1) + ) + if not moved: + # 圆弧没了,中间点也不能跟着一起消失:示教了 4 个点却只走 3 个, + # 现场表现就是"这一拐没绕过去"。补一条 moveJ 单独经过中间点—— + # 精度不如圆弧,但经过的示教点一个不少。 + _joint_move(via_index) + _joint_move(i) + elif kind == "L": + moved = False + try: + pose, _ = _pose_of(i) + builder.moveL(pose, speed=speed) + moved = True + except Exception: + # FK 失败(位形奇异/越限等)退回关节运动:宁可走不到 + # 直线,也不能让整个程序构建崩掉 + pass + if not moved: + _joint_move(i) + else: + _joint_move(i) + _gripper_after(i) + return builder.build() + + +def _scene_joint_snapshot(panel, name: Optional[str]): + """校验前把 ``name`` 的场景关节角拍个快照(恢复用);拍不到返回 ``None``。""" + scene = panel._scene + if scene is None or not name: + return None + state = getattr(scene, "robots", {}).get(name) + joints = getattr(state, "joints_deg", None) + if joints is None: + return None + return (name, [float(v) for v in joints]) + + +def _restore_scene_joints(panel, snapshot) -> None: + """把校验期间被干涉检查写走的场景关节角**原样还回去**。 + + 干涉检查沿轨迹密集采样,且会把采样到的位形写回场景(规划器的 + 历史契约,单点位形检查依赖它)。副作用是:校验一跑完,**场景**里 + 的机器人停在轨迹末端的位形,而**视口渲染器**还在原地——挂载件 + (夹爪/工件)的位姿按场景关节算,于是"夹爪离开法兰跳到别处"。 + 校验是只读的分析,不该留这种副作用,这里统一恢复。 + """ + if not snapshot or panel._scene is None: + return + name, joints = snapshot + try: + panel._scene.set_robot_joints(joints, name=name) + except Exception as exc: # 恢复失败不该盖过校验结果本身 + logger.warning("restore_scene_joints_failed: %s", exc) + + +def validate_program(panel) -> Optional["ProgramReport"]: + """校验程序并刷新界面。 + + 面板自己的槽位是上次同步时的快照,而点表的最新版本在主窗口那边 + ("教点 → 回灌"是两条通路)。点按钮时先请求回灌一次,避免"校的是 + 旧数据 / 这一槽压根没灌过点点看起来像没反应"。 + + Returns: + :class:`ProgramReport`;条件不足时返回 ``None`` + """ + panel.syncRequested.emit(panel._active) + if panel._robot is None: + panel.statusMessage.emit(tr("请先载入机器人型号,再校验程序")) + return None + + program = panel.build_program() + if program is None: + panel.statusMessage.emit(tr("没有示教点可供校验——请先记录若干关节位形")) + return None + + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) + snapshot = panel._scene_joint_snapshot(panel._active) + try: + report = program.validate(scene=panel._scene) + planner = TrajectoryPlanner(panel._robot, scene=panel._scene) + panel._trajectory = planner.plan(program) + except Exception as exc: + # 校验本身崩了也要让用户看见——这类异常几乎总是模型数据的问题 + panel.statusMessage.emit( + tr("校验异常:{kind}: {msg}").format(kind=type(exc).__name__, msg=exc) + ) + return None + finally: + QtWidgets.QApplication.restoreOverrideCursor() + panel._restore_scene_joints(snapshot) + + # 规划器要留着:其内部记录了每条指令的时间区间,抓取动画靠它把 + # gripper() 指令映射到轨迹时刻 + panel._planner = planner + panel._gripper_animator = GraspAnimator(program, planner) + + panel._report = report + panel._show_report(report) + # 路点列表着色:错误红/警告橙/正常绿 + 逐指令节拍(v0.2.4) + panel._apply_point_colors(report) + panel.reportReady.emit(report) + + # 有轨迹就能回放——即使校验没通过,也允许用户看看走到哪儿崩的 + if getattr(panel, "btn_play_all", None) is not None: + panel.btn_play_all.setEnabled(bool(panel._playable())) + message = tr("校验完成:{res}").format( + res=tr("通过") if report.ok else tr("{n} 处错误").format(n=len(report.errors)) + ) + if panel._build_notes: + # 圆弧降级这类"几何被换掉了"的事必须说出来,不能只在日志里躺着 + message += tr(";{n} 项运动降级(见日志)").format(n=len(panel._build_notes)) + for note in panel._build_notes: + panel.statusMessage.emit(tr("⚠ {note}").format(note=note)) + panel.statusMessage.emit(message) + return report + + +def validate_all(panel) -> Dict[str, Optional["ProgramReport"]]: + """逐台校验**所有**已登记的机器人(一键校验全部)。 + + 装配与规划和 :meth:`validate_program` 完全一致,区别在于**不逐台刷界面** + ——界面始终显示当前那台。否则问题列表会被一遍遍刷掉,用户只看见最后 + 一台的结论,前面几台校出问题等于没看见。每台的结果(轨迹 / 报告 / + 夹爪时序)各存进自己的状态槽,随后「全部回放」直接复用这些轨迹。 + + Returns: + ``{机器人名: ProgramReport}``;没型号、没示教点或校验本身出错的那台 + 对应 ``None``——"没结果"和"通过"是两回事,不混为一谈 + """ + reports: Dict[str, Optional["ProgramReport"]] = {} + for name in list(panel._states): + state = panel._states.get(name) + if state is None or state.robot is None: + continue + with panel._using_robot(name): + program = panel._build_program_impl() + if program is None: + reports[name] = None # 这台还没教过程序 + continue + snapshot = panel._scene_joint_snapshot(name) + try: + report = program.validate(scene=panel._scene) + planner = TrajectoryPlanner(panel._robot, scene=panel._scene) + state.trajectory = planner.plan(program) + except Exception as exc: + # 一台崩了不能带崩全部:如实记下来,继续校其余的 + state.trajectory = None + state.report = None + reports[name] = None + panel.statusMessage.emit( + tr("{name} 校验异常:{kind}: {msg}").format( + name=name, kind=type(exc).__name__, msg=exc + ) + ) + continue + finally: + panel._restore_scene_joints(snapshot) + state.planner = planner + state.gripper_animator = GraspAnimator(program, planner) + state.report = report + state.point_issue_level = panel._point_levels(report) + reports[name] = report + + # 当前这台若也校过了,顺手把它的结论刷进界面——用户正看着它 + active = panel._active + if active in reports and reports[active] is not None: + panel._show_report(reports[active]) + panel._apply_point_colors(reports[active]) + if getattr(panel, "btn_play_all", None) is not None: + panel.btn_play_all.setEnabled(bool(panel._playable())) + + passed = [n for n, r in reports.items() if r is not None and r.ok] + failed = [n for n, r in reports.items() if r is not None and not r.ok] + skipped = [n for n, r in reports.items() if r is None] + parts = [] + if passed: + parts.append(tr("{n} 台通过").format(n=len(passed))) + if failed: + parts.append(tr("{n} 台有问题").format(n=len(failed))) + if skipped: + parts.append(tr("{n} 台没程序").format(n=len(skipped))) + panel.statusMessage.emit( + tr("校验全部:{res}").format(res="、".join(parts) or tr("没有可校验的机器人")) + ) + panel.allValidated.emit(reports) + return reports + + +def focus_report(panel, name: Optional[str] = None) -> bool: + """切到 ``name`` 并把它**已存的**校验报告刷进界面。 + + 汇总对话框里点「看这台」走这里:报告已经算过了,不必重校一遍。 + + Returns: + 有报告并已显示 ``True``;这台还没校过返回 ``False`` + """ + key = name or panel._active + state = panel._state(key) + if state is None or state.report is None: + panel.statusMessage.emit(tr("{name} 还没有校验结果").format(name=key or tr("这台"))) + return False + panel.set_active_robot(key) + panel._show_report(state.report) + panel._apply_point_colors(state.report) + # 视口与示教点显示也要跟过去——否则"看的是 A 的报告,画的是 B" + panel.activeRobotChanged.emit(key) + return True + + +def _point_levels(panel, report: "ProgramReport") -> Dict[int, str]: + """报告里每个路点的最严重问题级别——**只算,不刷界面**。 + + 映射规则同 :meth:`_apply_point_colors`,但不碰列表控件:一键校验全部 + 时要逐台算完存进各自的状态槽,界面从头到尾只显示当前那台。 + """ + levels: Dict[int, str] = {} + plan, _notes = panel.motion_plan() + instruction_to_point = {order: index for order, (_kind, index, _via) in enumerate(plan)} + for issue in report.issues: + point_index = instruction_to_point.get(issue.motion_index, issue.motion_index) + level = "error" if issue.severity == SEVERITY_ERROR else "warning" + if levels.get(point_index) != "error": + levels[point_index] = level + return levels diff --git a/docs/develop/BUILD_QUEUE.md b/docs/develop/BUILD_QUEUE.md index f8ce93f..33da763 100644 --- a/docs/develop/BUILD_QUEUE.md +++ b/docs/develop/BUILD_QUEUE.md @@ -215,8 +215,12 @@ S2 完成后把 v0.3.3 剩余部分(工艺模板库、waitDI 完整语义) - [ ] **发现的小瑕疵(未修,本轮刻意保持"纯搬运")**:`generate_robot_code` 的 docstring 开头是 `与tr("保存路径代码")不同`——早期一次 i18n 批量替换把 docstring 里的"与保存路径代码不同" 改坏了(docstring 不该含 `tr(...)`)。纯文案瑕疵、无功能影响,下次动这个文件时顺手改。 -- [ ] **Step 12(下一阶段)**:**B 组**——`app/views/program_panel.py`(2193) / `gl_widget.py`(1992) / - `path_editor.py`(1298) / `controller_panel.py`(683) 的类内拆解,方案已出(见本节 B 组小节)。 +- [x] **Step 12(B 组首个落地)**:`app/views/program_panel.py` 类内拆解完成(2026-10-18)—— + 拆出 8 个模块(`program_state`/`program_ui`/`program_robot`/`program_playback`/`program_grasp`/ + `program_export`/`program_editor`/`program_validate`),原文件 2202 → **534 行**(< 600 软限, + 已摘除 TECH_DEBT 并从 LEGACY_DEBT 移除),公开方法签名全保留(stub 转发至模块级函数), + 1852 测试全绿,6 条守门测试已建(`tests/test_app_views_program_panel.py`)。 + `gl_widget.py`(1992) / `path_editor.py`(1298) / `controller_panel.py`(683) 仍待拆。 - ⚠️ **第二个事故——测试的"补丁目标"没跟着走(段错误)**:`tests/test_app_degraded_paths.py` 原本写 `monkeypatch.setattr(main, "gl", _NullGL())` 再调 `main.GLWidget._draw_link_recursive`。 类搬进 `app/views/gl_widget.py` 后,它查的是**自己模块**的 `gl` → **补丁成了空操作** → diff --git a/tests/test_app_views_program_panel.py b/tests/test_app_views_program_panel.py new file mode 100644 index 0000000..f2be27f --- /dev/null +++ b/tests/test_app_views_program_panel.py @@ -0,0 +1,143 @@ +"""`ProgramPanel` 类内拆解(BUILD_QUEUE B1)的守门测试。 + +与 `test_app_views_gl_widget.py` 同一套纪律——拆解本身没有功能行为可测 +(方法体一字未改,只从类方法改成"以 panel 为第一参数的模块级函数 + 薄 stub"), +会退化的是**结构**: + +1. **不断链**:``ProgramPanel`` 仍可从 ``app.views.program_panel`` 导入且是类; +2. **方向**:八个新模块不得 import ``main``; +3. **随行的常量与数据类**:``PANEL_STYLE`` / ``RobotProgram`` / + ``PLAYBACK_INTERVAL_MS`` 必须仍能从 ``app.views.program_panel`` 访问 + (re-export 是有意保留的兼容面); +4. **门禁同步**:含中文字面量的新文件已登记进守护的 CJK_BASELINE; +5. **搬迁完整性**:新模块用到的名字必须能自己解析,不得指望入口脚本提供; +6. **测试不得对入口脚本的模块全局打补丁**——补丁要打在**定义处**。 +""" + +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +import pytest + +pytest.importorskip("PySide6") + +_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_ROOT)) + +_MOD = _ROOT / "app" / "views" / "program_panel.py" +_NEW_MODS = [ + _ROOT / "app" / "views" / f"program_{part}.py" + for part in ( + "state", + "ui", + "robot", + "playback", + "grasp", + "export", + "editor", + "validate", + ) +] + + +def test_class_still_importable_from_same_module(): + """① 拆解完成 + 导入面不变。""" + from app.views import program_panel + + assert isinstance(program_panel.ProgramPanel, type) + assert program_panel.ProgramPanel.__module__ == "app.views.program_panel" + assert program_panel.__all__ == ["ProgramPanel", "PANEL_STYLE"] + + +def test_new_modules_do_not_import_entry_shell(): + """② 依赖方向:八个新模块不得反向 import 入口脚本。""" + for mod in [*_NEW_MODS, _MOD]: + tree = ast.parse(mod.read_text(encoding="utf-8")) + imported: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported |= {a.name for a in node.names} + elif isinstance(node, ast.ImportFrom) and not node.level and node.module: + imported.add(node.module) + bad = {m for m in imported if m == "main" or m.startswith("main.")} + assert not bad, f"{mod.name} 反向依赖了入口脚本:{bad}" + + +def test_constants_and_dataclass_still_reachable_from_panel_module(): + """③ 常量与数据类经 program_panel re-export,旧导入面不断。""" + import app.views.program_panel as pp_mod + + assert isinstance(pp_mod.PANEL_STYLE, str) and "QWidget" in pp_mod.PANEL_STYLE + assert pp_mod.PLAYBACK_INTERVAL_MS == 40 + assert isinstance(pp_mod.RobotProgram, type) + # 数据类实例化路径也不能断(面板 __init__ 的 _scratch 依赖它) + assert pp_mod.RobotProgram().taught == [] + + +def test_cjk_baseline_registered_in_guard(): + """④ 结构门禁同步:含中文字面量的新文件必须在 CJK_BASELINE 有键。""" + guard = (_ROOT / "tools" / "check_arch_rules.py").read_text(encoding="utf-8") + for part in ("editor", "export", "grasp", "playback", "robot", "ui", "validate"): + assert ( + f'"app/views/program_{part}.py"' in guard + ), f"program_{part}.py 未登记进 CJK_BASELINE —— 搬迁必须同步守护脚本" + + +def test_module_needs_no_names_from_entry_shell(): + """⑤ 搬迁完整性:本模块用到的名字必须能自己解析,不得指望入口脚本提供。 + + **真实事故**(ADR-002 Step 2):`_segments_for_radius` 是 main.py 的 + **模块级函数**,搬类时漏了它——而**全部测试仍然全绿**(那条代码路径 + 当时没被覆盖),只会在真实交互里炸成 NameError。这类"漏搬一个名字" + 编译器不会说话、覆盖不到也不会说话,只能靠静态检查钉住。 + """ + import main + + for mod in [*_NEW_MODS, _MOD]: + tree = ast.parse(mod.read_text(encoding="utf-8")) + defined = {"self", "panel"} + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)): + defined |= {a.asname or a.name.split(".")[0] for a in node.names} + elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store): + defined.add(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + defined.add(node.name) + elif isinstance(node, ast.ExceptHandler) and node.name: + defined.add(node.name) + used = { + n.id for n in ast.walk(tree) if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load) + } + missing = sorted((used & set(dir(main))) - defined) + assert not missing, ( + f"{mod.name} 这些名字本模块自己解析不了、只能靠入口脚本提供" + f"(搬迁漏了):{missing}" + ) + + +def test_no_test_patches_globals_on_entry_shell(): + """⑥ 测试不得对**入口脚本**的模块全局打补丁——补丁要打在**定义处**。 + + 拆解后名字的定义处分散在八个新模块;对 ``main`` 打补丁只对还住在它 + 里面的代码有效,这种"看起来在打补丁、其实没打上"的失效方式极难从 + 失败信息里看出来,所以直接禁掉。 + """ + import re + + tests_dir = _ROOT / "tests" + pattern = re.compile(r"setattr\(\s*(?:main|rw)\s*,") + # 跳过本文件与同纪律的 gl_widget 守门测试:它们的文档字符串里正举着这个反例 + skip = {Path(__file__).name, "test_app_views_gl_widget.py"} + offenders = sorted( + f"{p.name}:{i}" + for p in tests_dir.glob("*.py") + if p.name not in skip + for i, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1) + if pattern.search(line) + ) + assert not offenders, ( + "这些地方对入口脚本的模块全局打补丁——改为打在**定义该名字的模块**上:" f"{offenders}" + ) diff --git a/tools/check_arch_rules.py b/tools/check_arch_rules.py index 47d4897..6338f20 100644 --- a/tools/check_arch_rules.py +++ b/tools/check_arch_rules.py @@ -111,7 +111,9 @@ "app/views/controller_panel.py": "已在 app/views:真机连接面板 683 行,类内继续拆", "app/views/gl_widget.py": "已在 app/views:类内继续拆(1992 行,方案见 BUILD_QUEUE B2)", "app/views/path_editor.py": "已在 app/views:类内继续拆(1298 行,方案见 BUILD_QUEUE B3)", - "app/views/program_panel.py": "已在 app/views:类内继续拆(2193 行,全仓最大单体,方案见 BUILD_QUEUE B1)", + # 2026-10-18 移除 ``app/views/program_panel.py``:B1 类内拆解完成, + # 降到 534 行(< 软限 600),实现按职责拆到 program_state/_ui/_robot/ + # _playback/_grasp/_export/_editor/_validate 八个模块,主文件只留薄 stub。 # 2026-09-27 移除 ``app/window/main_window.py``:C2 把导入桥收敛进 # capabilities.py 后它降到 596 行(< 软限 600)——债务还完就下调,别留在清单里 # 误导后人。下面这条仍在软限之上(601 行),保留。 @@ -181,7 +183,15 @@ "app/views/controller_panel.py": 76, "app/views/gl_widget.py": 6, "app/views/path_editor.py": 84, - "app/views/program_panel.py": 138, + # program_panel.py 于 2026-10-18 类内拆解(B1):137 条中文字面量随实现 + # 搬到下列七个模块,panel 本体只剩薄 stub(0 条),总数守恒。 + "app/views/program_editor.py": 25, + "app/views/program_export.py": 16, + "app/views/program_grasp.py": 32, + "app/views/program_playback.py": 4, + "app/views/program_robot.py": 1, + "app/views/program_ui.py": 42, + "app/views/program_validate.py": 17, "app/views/property_controls.py": 5, "app/views/property_panel.py": 7, "app/views/property_panel_ui.py": 31, From c1af38817d7d0e1f2eb734263eecdf0085836870 Mon Sep 17 00:00:00 2001 From: JayH Date: Sun, 18 Oct 2026 23:21:55 +0800 Subject: [PATCH 2/3] =?UTF-8?q?support:=20i18n=20=E8=AF=8D=E6=9D=A1?= =?UTF-8?q?=E8=A1=A8=E5=A4=96=E7=BD=AE=E5=88=B0=20i18n=5Fcatalog.py?= =?UTF-8?q?=EF=BC=881479=E2=86=92118=20=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _CATALOG_EN 968 条字节级原样搬运为独立数据模块,i18n.py 只留语言 检测/持久化/tr() 逻辑并 re-export,守护 H9 与 build_web_site 调用面 不变。I18N_HOME 豁免集纳入数据表;test_catalog_has_no_duplicate_keys 的 AST 解析目标跟随数据真身。LEGACY_DEBT 摘除 i18n.py、如实登记 i18n_catalog.py(数据表行数=词条规模)。 --- docs/develop/BUILD_QUEUE.md | 7 +- support/i18n.py | 1369 +--------------------------------- support/i18n_catalog.py | 1377 +++++++++++++++++++++++++++++++++++ tests/test_i18n.py | 6 +- tools/check_arch_rules.py | 11 +- 5 files changed, 1397 insertions(+), 1373 deletions(-) create mode 100644 support/i18n_catalog.py diff --git a/docs/develop/BUILD_QUEUE.md b/docs/develop/BUILD_QUEUE.md index 33da763..065151c 100644 --- a/docs/develop/BUILD_QUEUE.md +++ b/docs/develop/BUILD_QUEUE.md @@ -279,10 +279,11 @@ S2 完成后把 v0.3.3 剩余部分(工艺模板库、waitDI 完整语义) - [ ] `core/__init__.py` 门面(447 行)让**内核依赖全部三层**(与"内核在最底层"矛盾)—— 它是兼容层,54 个文件在用旧写法 → ADR-006 补**退出条件**(如:新代码禁用 `core.<上层模块>`,用棘轮统计调用点只许减,至 v2.0 移除 re-export)。 -- [ ] **34 个文件超 600 行软限**(其中 **14 个 >1000 行**;6 个 >1500:`main.py` 6219 · - `app/views/program_panel.py` 2198 · - `service/planner.py` 1920 · `service/perf.py` 1830 · `service/twinsync.py` 1828 · `support/i18n.py` 1538)。 +- [ ] **33 个文件超 600 行软限**(其中 **13 个 >1000 行**;4 个 >1500:`main.py` 6219 · + `service/planner.py` 1920 · `service/perf.py` 1830 · `service/twinsync.py` 1828)。 **分层搬迁不解决体积**——搬完还是这么大;按 ARCHITECTURE Phase 1/2 节奏每轮挑 1-2 个拆。 + 已完成:`app/views/program_panel.py` 2202→534(2026-10-18 类内拆 8 模块,见 Step 12 B 组)、 + `support/i18n.py` 1479→118(词条表外置 `support/i18n_catalog.py`,i18n 逻辑零改动)。 ## 新增功能想法(评审区) diff --git a/support/i18n.py b/support/i18n.py index 0967fae..dba16c5 100644 --- a/support/i18n.py +++ b/support/i18n.py @@ -112,1368 +112,7 @@ def tr(text: str) -> str: return _CATALOG_EN.get(text, text) -# --------------------------------------------------------------------------- -# 英文目录(key = 源码中的中文文案原样;模板用命名占位符) -# --------------------------------------------------------------------------- - -_CATALOG_EN: dict[str, str] = { - # --- main.py:菜单 / 全局 --- - "抓取工作站演示(一键)(&D)": "Grasping workstation demo (one-click)(&D)", - "载入预置工作站(&W)...": "Load preset workcell(&W)...", - "预置工作站": "Preset workcells", - "选择工作站:": "Select workcell:", - "抓取工作站演示": "Grasping workstation demo", - "显示&机器组件库": "Show &machine library", - "插件": "Plugins", - "插件状态": "Plugin status", - "插件状态…": "Plugin status…", - "插件覆盖层": "Plugin overlays", - "重新扫描已安装插件": "Rescan installed plugins", - "重新扫描机器组件库": "Rescan machine library", - "当前环境无插件层(adapter.plugins 不可用)。": "Plugin layer unavailable (adapter.plugins is not installed).", - "未找到预置场景({cells} 下没有 .json)": "No preset scenes found (no .json under {cells})", - "已载入 {name}({n} 个对象)": "Loaded {name} ({n} objects)", - "已载入: {name}": "Loaded: {name}", - "关节数: {n}": "Axes: {n}", - "模板构建失败:{kind}: {msg}": "Template build failed: {kind}: {msg}", - "示例程序生成失败:{kind}: {msg}": "Demo program build failed: {kind}: {msg}", - "离线编程机器人初始化失败,无法生成示例程序。": "Offline-programming robot init failed; cannot build demo program.", - "所需模块不可用(模板/环境建模/离线编程/抓取工艺)。": "Required modules unavailable (templates / world modeling / OLP / grasping process).", - "抓取工作站演示已就绪:{n} 个示例示教点{cycle_txt},回放中(可暂停/单步/停止)": "Grasping workstation demo ready: {n} demo taught points{cycle_txt}, playing back (pause/step/stop available)", - ",节拍 {t} s": ", cycle {t} s", - "节拍 {t} s": "Cycle {t} s", - "RoboWorld": "RoboWorld", - "IO —": "IO —", - "节拍 —": "Cycle —", - "干涉 {n}": "Interference {n}", - "干涉 —": "Interference —", - "报错 {n}": "{n} errors", - "无报错": "No errors", - "当前没有示教点。\n请先在 3D 视口中调整关节角度并点击「记录」按钮。": "No taught points yet.\nAdjust joint angles in the 3D viewport and click Record first.", - "重新扫描失败:{exc}": "Rescan failed: {exc}", - "重新扫描完成:发现 {discovered} 个入口点,登记 {registered} 个,跳过 {skipped} 个,失败 {failed} 个。": "Rescan finished: found {discovered} entry point(s), registered {registered}, skipped {skipped}, failed {failed}.", - # 缓存可观测性(v1.2.4) - "缓存状态…": "Cache status…", - "清空缓存与统计": "Clear caches & statistics", - "缓存状态": "Cache status", - "当前环境无缓存层(service.incremental 不可用)。": "Cache layer unavailable in this environment (service.incremental missing).", - '内容寻址缓存不会给出过期结果;这里的数字解释"活少干了多少"。': "Content-addressed caches can never serve stale results; these numbers show how much work they saved.", - "已清空全部缓存并把统计归零——下次校验将全量真算。": "All caches cleared and statistics reset — the next validation will recompute everything.", - "规划与分段缓存(TrajectoryCache)": "Plan & segment cache (TrajectoryCache)", - "干涉检查结果缓存(CollisionResultCache)": "Collision-check result cache (CollisionResultCache)", - "力矩校验结果缓存(DynamicsResultCache)": "Torque-check result cache (DynamicsResultCache)", - "已关闭": "Disabled", - "整程序 {entries}/{max} 份({wp} 路点) · 命中 {hits} / 未命中 {misses}(命中率 {rate:.0%})": "Full-program {entries}/{max} snapshots ({wp} waypoints) · {hits} hits / {misses} misses ({rate:.0%} hit rate)", - "分段 {entries}/{wp_max} 条({wp} 路点) · 命中 {hits} / 未命中 {misses}(命中率 {rate:.0%})": "Per-segment {entries}/{wp_max} entries ({wp} waypoints) · {hits} hits / {misses} misses ({rate:.0%} hit rate)", - "LRU 淘汰 {n} 次": "{n} LRU eviction(s)", - "条目 {entries}/{max} · 命中 {hits} / 未命中 {misses}(命中率 {rate:.0%})· 存储 {stores} · 淘汰 {evictions}": "{entries}/{max} entries · {hits} hits / {misses} misses ({rate:.0%} hit rate) · {stores} stores · {evictions} evictions", - "本轮还没有缓存活动(计数全为 0)。": "No cache activity yet this session (all counters are zero).", - "机器人模型不可用:{urdf}": "Robot model unavailable: {urdf}", - "该型号 URDF 不可用": "URDF unavailable for this model", - "请先从「机器人库」载入一个型号。": "Load a model from the robot library first.", - "未加载机器人模型,无法确定目标品牌与后处理器。\n请先从「机器人库」载入一个型号。": "No robot model loaded; cannot determine target brand and post-processor.\nLoad a model from the robot library first.", - "请先加载机器人": "Load a robot first", - "载入失败": "Load failed", - "保存失败": "Save failed", - "关闭": "Close", - "(无)": "(none)", - "(无可调参数)": "(no adjustable parameters)", - "(该设备无关节)": "(this device has no joints)", - "⚠ 参数未核验 — ": "⚠ Parameters unverified — ", - "载入预置工作站": "Load preset workcell", - "单元模型不可用": "Cell model unavailable", - "请先在「路径示教」里 Record 至少一个示教点": "Record at least one taught point in Path Teaching first", - "\n 最近一次渲染覆盖层的问题:\n": "\n Recent plugin overlay problems:\n", - "\n\n可用后处理器:\n ": "\n\nAvailable post-processors:\n ", - "机器人:{name} 目标语言:{lang} 指令数:{n}": "Robot: {name} Target language: {lang} Motions: {n}", - "绕 X (°)": "X (°)", - "绕 Y (°)": "Y (°)", - "绕 Z (°)": "Z (°)", - "、": ", ", - "({note})": "({note})", - # --- main.py:抓取工作站演示 tooltip(多段整组) --- - "记录时赋予该示教点的工艺语义。\n抓取点:插入夹紧 IO,播放时把工件吸附到法兰;\n放置点:插入松开 IO,播放时把工件释放到世界。": "Process semantics assigned to this taught point.\nPick point: inserts grip IO; on playback the workpiece snaps to the flange;\nPlace point: inserts release IO; on playback the workpiece is left in the world.", - "挂到「机器人」= 安装到法兰,工具将跟随机器人运动;\n挂到其它对象 = 保持当前位置,仅建立随动关系。": 'Attach to "Robot" = tool install: the object follows the robot flange;\nAttach to any other object = keep current position, follow relationship only.', - "一键载入抓取工作站(UR5e + 吸盘 + 输送带 + 托盘),\n自动装上示例抓取程序并开始回放,状态栏显示节拍。": "One-click load of the grasping workstation (UR5e + gripper + conveyor + pallet),\nauto-loads the demo pick program and starts playback; the status bar shows cycle time.", - "暂停 / 继续;暂停保留当前点位,便于逐点核对": "Pause / resume; pausing keeps the current point for step-by-step checks", - "⏭ 单步": "⏭ Step", - "⏸ 暂停": "⏸ Pause", - "▶ 继续": "▶ Resume", - "走一个示教点——核对抓取/放置时机最有用的操作": "Step one taught point — the most useful way to check grasp/release timing", - "被抓取的场景对象;留空表示只演示轨迹": "Scene object to grasp; leave empty to demo trajectory only", - "工件:": "Workpiece:", - # --- main.py:右侧标签页 / 对象属性 --- - "属性": "Properties", - "场景设备库": "Scene Device Library", - "设备树": "Device Tree", - "导出打包": "Export & Package", - "对象属性": "Object properties", - "位置": "Position", - "参数": "Parameters", - "关节": "Joints", - "信息": "Info", - "日志": "Log", - "删除": "Delete", - "从场景同步": "Sync from scene", - "自定义": "Custom", - # --- 组件分类(与设备库分组同源,设备树「类型」列也用它)--- - "基础设施": "Infrastructure", - "安全设施": "Safety facilities", - "工件": "Workpiece", - "夹具": "Fixture", - "变位机": "Positioner", - "输送设备": "Conveyor", - "末端工具": "End effector", - "机器人": "Robot", - "范围 {lo} ~ {hi} {unit}": "Range {lo} ~ {hi} {unit}", - "范围 {lo}° ~ {hi}°": "Range {lo}° ~ {hi}°", - # --- main.py:代码生成 / PLC / 硬件清单 --- - "PLC 程序逻辑": "PLC program logic", - "代码预览": "Code preview", - "生成": "Generate", - "代码生成失败": "Code generation failed", - "保存为文件…": "Save to file…", - "保存路径代码": "Save path code", - "保存 {lang} 代码": "Save {lang} code", - "已保存 {lang} 代码: {name}": "Saved {lang} code: {name}", - "生成的机器人代码 — {lang}": "Generated robot code — {lang}", - "生成的 PLC 代码(西门子 SCL)将显示在这里": "Generated PLC code (Siemens SCL) will appear here", - "生成机器人代码": "Generate robot code", - "机器人子程序": "Robot subroutine", - "机器人子程序代码(需先加载机器人并示教)": "Robot subroutine code (load a robot and teach points first)", - "西门子 SCL": "Siemens SCL", - "自动(按机器人)": "Auto (by robot)", - "硬件列表": "Hardware list", - "添加设备": "Add device", - "传感器": "Sensor", - "名称": "Name", - "类型": "Type", - "厂商": "Vendor", - "型号": "Model", - "通信": "Comm", - "目标": "Target", - "品牌": "Brand", - "名称:": "Name:", - "厂商:": "Vendor:", - "型号:": "Model:", - "控制器:": "Controller:", - "类型:": "Type:", - "点位:": "Points:", - "挂载到:": "Attach to:", - "轴数:": "Axes:", - "重复定位精度:": "Repeatability:", - "额定负载:": "Rated payload:", - "最大臂展:": "Max reach:", - "未加载模型": "No model loaded", - "资源库": "Library", - "工作单元干涉检查": "Cell interference check", - "干涉检查": "Interference check", - "程序校验": "Program validation", - "程序校验与节拍估算": "Program validation & cycle-time estimate", - "路径示教": "Path teaching", - "协作臂": "Collaborative", - "工业臂": "Industrial", - "待机位姿": "Home pose", - "关节值数量 {n} 与自由度 {dof} 不符(关节: {names})": "Joint value count {n} does not match DOF {dof} (joints: {names})", - # --- program_panel.py --- - "程序概览": "Program overview", - "估算节拍": "Estimate cycle time", - "校验程序 (F7)": "Validate (F7)", - "校验结果": "Validation results", - "后处理与导出": "Post-processing & export", - "后处理器": "Post-processor", - "生成代码": "Generate code", - "一键导出…": "One-click export…", - "导出全部…": "Export all…", - "为每一台机器人各落一份独立程序文件到同一目录(UR .script / KUKA .src / ABB .mod / FANUC .tp)": ( - "Write one independent program file per robot into the same folder" - " (UR .script / KUKA .src / ABB .mod / FANUC .tp)" - ), - "已导出 {n} 台机器人的独立程序到 {dest}": ("Exported {n} independent robot programs to {dest}"), - "上传真机": "Upload to controller", - "真机验证": "Controller verification", - "回放": "Playback", - "全部回放": "Play all", - "未选中设备——已生成全部 {n} 台的代码": ( - "No device selected — generated code for all {n} robots" - ), - "停止": "Stop", - # 代码预览(路径示教)的播放条——原先 Record/Play/Stop/Clear 是英文, - # 与同一行的「暂停 / 单步」混着排(2026-09-21 界面体检) - "● 记录": "● Record", - "● 正在记录": "● Recording", - "▶ 播放": "▶ Play", - "■ 停止": "■ Stop", - "示教点:0": "Points: 0", - "示教点:{n}": "Points: {n}", - "示教点:{n}({detail})": "Points: {n} ({detail})", - "就绪": "Ready", - "—": "—", - "X (毫米)": "X (mm)", - "Y (毫米)": "Y (mm)", - "Z (毫米)": "Z (mm)", - # 菜单栏:原先 File / View 与其中若干条目是英文 - "文件(&F)": "File(&F)", - "打开模型(&O)…": "Open Model(&O)…", - "保存路径代码(&S)…": "Save Path Code(&S)…", - "载入路径代码(&L)…": "Load Path Code(&L)…", - "生成机器人代码(&G)…": "Generate Robot Code(&G)…", - "退出(&X)": "Exit(&X)", - "视图(&V)": "View(&V)", - "重置视图(&R)": "Reset View(&R)", - "语言 / Language": "Language", - "从轨迹起点开始回放。": "Play back from the trajectory start.", - "停止回放。": "Stop playback.", - "看一遍路径怎么走": "Preview how the path moves", - "轨迹回放中…": "Playing back trajectory…", - "TCP 路径": "TCP path", - "路径点": "Waypoints", - "运动类型": "Motion type", - "进给速度": "Feed speed", - "直线插补": "Linear interpolation", - "自动": "Auto", - "自动(按机器人品牌)": "Auto (by robot brand)", - "未加载机器人": "No robot loaded", - "还没校验": "Not validated yet", - "通过": "Pass", - "不通过": "Failed", - "结果": "Result", - "错误": "Errors", - "警示": "Warnings", - "节拍 (s)": "Cycle (s)", - "没程序(未示教)": "No program (nothing taught yet)", - "看这台": "Show this one", - "校验全部机器人": "Validate all robots", - "还没有登记任何机器人——请先载入机器人型号。": "No robot registered yet — load a robot model first.", - "当前版本不支持批量校验。": "This version does not support batch validation.", - "每条指令都失败": "Every motion failed", - "{n} 处错误": "{n} error(s)", - "{w} 个 / {m} 条指令": "{w} points / {m} motions", - "{name} · 就绪": "{name} · Ready", - "校验完成:{res}": "Validation finished: {res}", - "校验异常:{kind}: {msg}": "Validation error: {kind}: {msg}", - "{name} 校验异常:{kind}: {msg}": "Validation error on {name}: {kind}: {msg}", - "校验全部": "Validate all", - "逐台校验所有机器人并汇总结果——不必一台一台切过去按 F7": "Validate every robot and summarize — no need to switch one by one and press F7", - "校验全部:{res}": "Validate all: {res}", - "{n} 台通过": "{n} passed", - "{n} 台有问题": "{n} have problems", - "{n} 台没程序": "{n} have no program", - "没有可校验的机器人": "No robot to validate", - "{name} 还没有校验结果": "{name} has no validation result yet", - "这台": "This robot", - "抓取向导": "Grasp wizard", - "抓取向导…": "Grasp wizard…", - "由抓取点/放置点自动生成接近、提升、撤离点——不必逐个手动示教": "Generate approach / lift / retract points from the pick and place poses — no need to teach them one by one", - "当前版本不支持抓取向导。": "This version does not support the grasp wizard.", - "请先记录至少一个示教点——把机器人摆到要抓的位置,点「记录」": "Record at least one taught point first — jog the robot to the grasp pose and press Record", - "还没有载入机器人,无法计算抓取点位": "No robot loaded — cannot compute grasp points", - "还没有示教点——请先把机器人摆到抓取位并记录一个点": "No taught point yet — jog to the grasp pose and record one", - "抓取点下标 {i} 超出示教点范围(共 {n} 个)": "Pick index {i} is out of range ({n} taught points)", - "放置点下标 {i} 超出示教点范围(共 {n} 个)": "Place index {i} is out of range ({n} taught points)", - "接近点": "Approach", - "抓取点": "Pick", - "提升点": "Lift", - "放置点": "Place", - "撤离点": "Retract", - "{label}(抬高 {dz:.0f} mm)逆解失败:{msg}": "Inverse kinematics failed for {label} (raised {dz:.0f} mm): {msg}", - "抓取向导已生成 {n} 个点({seq})": "Grasp wizard generated {n} points ({seq})", - "P{n}": "P{n}", - "(不生成放置侧)": "(no place side)", - "接近高度(抓取点上方)": "Approach height (above pick)", - "提升高度(抓取点上方)": "Lift height (above pick)", - "撤离高度(放置点上方)": "Retract height (above place)", - "替换原有示教点(默认追加到末尾)": "Replace existing taught points (default: append)", - "将生成 {n} 个点:{seq}": "Will generate {n} points: {seq}", - "点位算不出来,示教点未改动:\n{msg}": "Could not compute the points; taught points are unchanged:\n{msg}", - "取消": "Cancel", - "没有示教点可供校验——请先记录若干关节位形": "No taught points to validate — record some joint poses first", - "请先加载机器人并示教至少一个点": "Load a robot and teach at least one point first", - "请先校验程序以生成轨迹,再回放": "Validate the program to generate a trajectory before playback", - "请先载入机器人再添加路点": "Load a robot before adding waypoints", - "请先载入机器人型号,再校验程序": "Load a robot model before validating", - "请先在列表中选中要删除的路点": "Select a waypoint in the list to delete", - "请先在列表中选中要改运动类型的路点": "Select a waypoint in the list to change its motion type", - "请先在列表中选中要编辑的路点": "Select a waypoint in the list to edit", - "插入": "Insert", - "上移": "Move up", - "下移": "Move down", - "编辑位姿…": "Edit pose…", - "编辑路点 {n}": "Edit waypoint {n}", - "设为 moveJ": "Set as moveJ", - "设为 moveL": "Set as moveL", - "moveJ(关节插值)": "moveJ (joint interpolation)", - "moveL(直线插值)": "moveL (linear interpolation)", - "moveJ(关节)": "moveJ (joint)", - "moveL(直线)": "moveL (linear)", - "轨迹编辑": "Trajectory editing", - "操作": "Actions", - "状态": "Status", - "选择导出目录": "Select export directory", - "导入 {n} 个示教点": "Imported {n} taught points", - "导出失败: {exc}": "Export failed: {exc}", - "已导出:{handle}{note}": "Deployed: {handle}{note}", - "已生成 {choice} 代码,共 {lines} 行": "Generated {choice} code, {lines} lines", - "生成失败: {exc}": "Generation failed: {exc}", - "生成失败:\n{exc}": "Generation failed:\n{exc}", - "生成的控制器代码将显示在这里": "Generated controller code will appear here", - "已插入路点 {n},请重新校验": "Inserted waypoint {n}; re-validate", - "已删除路点 {n},请重新校验": "Deleted waypoint {n}; re-validate", - "P{a} 已移到 P{b},请重新校验": "P{a} moved to P{b}; re-validate", - "请先在列表中选中要移动的路点": "Select a waypoint in the list first", - "{n} 已更新,请重新校验": "{n} updated; re-validate", - "{n} 已设为 {label},请重新校验": "{n} set to {label}; re-validate", - "修改选中路点的关节角(度)与运动类型(双击同效)": "Edit the selected waypoint's joint angles (deg) and motion type (double-click works too)", - "在选中点之后插入一个路点(复制其关节值)": "Insert a waypoint after the selection (copies its joint values)", - "一步构建程序并按品牌导出(UR .script / KUKA .src / ABB .mod / FANUC .tp)": "Build the program and export by brand in one step (UR .script / KUKA .src / ABB .mod / FANUC .tp)", - "选中点改为关节运动(走平滑关节插值,不保证直线)": "Switch selection to joint motion (smooth joint interpolation; not a straight line)", - "选中点改为直线运动(TCP 沿直线,走 IK 插补)": "Switch selection to linear motion (TCP follows a straight line via IK interpolation)", - "该校验警告涉及此路点(见校验结果)": "A validation warning concerns this waypoint (see validation results)", - "该校验错误涉及此路点(见校验结果)": "A validation error concerns this waypoint (see validation results)", - "改动后需重新校验:着色与节拍会随校验结果刷新(红=错误 / 橙=警告 / 绿=正常)": "Re-validate after edits: coloring and cycle time refresh with validation results (red=error / orange=warning / green=ok)", - "预留:接入 ControllerLink 后支持直连控制器上传": "Reserved: direct controller upload once ControllerLink is connected", - "预留:接入 ControllerLink.read_joints 后做真机回读比对": "Reserved: read-back comparison once ControllerLink.read_joints is available", - # --- world/panel.py(场景面板) --- - "场景对象": "Scene objects", - "对象": "Object", - "场景为空": "Scene is empty", - "0 个对象": "0 objects", - "{n} 个对象": "{n} objects", - "▢ 机器人": "▢ Robot", - "保存场景": "Save scene", - "保存场景…": "Save scene…", - "加载场景": "Load scene", - "加载场景…": "Load scene…", - "加载失败": "Load failed", - "场景文件 (*.json)": "Scene files (*.json)", - "场景已保存: {name}": "Scene saved: {name}", - "场景已加载: {name}({n} 个对象)": "Scene loaded: {name} ({n} objects)", - "场景已清空": "Scene cleared", - "已删除对象": "Object deleted", - "已添加 {name}": "Added {name}", - "添加失败: {exc}": "Add failed: {exc}", - "清空": "Clear", - "清空场景": "Clear scene", - "确定要移除所有场景对象吗?(机器人不受影响)": "Remove all scene objects? (robots are not affected)", - "工艺时序(泳道图)": "Process timeline (swimlanes)", - "时间 →": "Time →", - "未检查": "Not checked", - "检查干涉 (F6)": "Check interference (F6)", - "✓ 无干涉": "✓ No interference", - "✗ {n} 处干涉:{shown}{more}": "✗ {n} interference(s): {shown}{more}", - "发现 {n} 处干涉": "Found {n} interference(s)", - "干涉检查通过:无干涉": "Interference check passed: no interference", - "检查失败: {exc}": "Check failed: {exc}", - "机器人加载失败: {exc}": "Robot load failed: {exc}", - " …等共 {n} 处": " …{n} in total", - "拖入对象即可生成泳道,按时间轴编排工艺步骤,后续将驱动“工艺 → 机器动作”的自动映射。": 'Drop objects here to create swimlanes and sequence process steps on a timeline; automatic "process → machine action" mapping is planned.', - # --- robot_library/panel.py(机器人库面板) --- - "全部": "All", - "全部品牌": "All brands", - "全部机型": "All models", - "全部臂展": "All reaches", - "全部负载": "All payloads", - "刷新": "Refresh", - "组件": "Component", - "参数未核验": "Parameters unverified", - "固定几何": "Fixed geometry", - "规格未知": "Specs unknown", - "载": "Load", - "选": "Select", - "搜索型号 / 品牌 / 组件…": "Search model / brand / component…", - "拖拽到右侧视口加入场景 · 双击载入": "Drag into the viewport to add to the scene · double-click to load", - "重新扫描 assets/models/ 目录": "Rescan assets/models/ directory", - "重新扫描模型目录并重建列表。": "Rescan the model directory and rebuild the list.", - "机器组件库:共 {n} 个型号 / 组件已并入": "Machine library: {n} models / components merged in", - "{n} 项": "{n} items", - "{name} — 双击载入": "{name} — double-click to load", - "{name} 的 URDF 不可用": "URDF unavailable for {name}", - "{n} 轴": "{n} axes", - # --- 语言切换(main.py 菜单与对话框) --- - # 「语言 / Language」的译文见上(中文菜单里保留双语名,英文模式只留 Language) - "切换语言需要重启应用才能完全生效。现在重启吗?": "Restart the app to apply the language change fully. Restart now?", - "立即重启": "Restart now", - "稍后": "Later", - # --- v1.1 库层消息:planner 校验 / 报告 --- - "程序级": "Program-level", - "指令 {n}": "Motion {n}", - "轴 {axis} 低于下限位:{value:.3f} < {limit:.3f}": "Axis {axis} below lower limit: {value:.3f} < {limit:.3f}", - "轴 {axis} 超出上限位:{value:.3f} > {limit:.3f}": "Axis {axis} above upper limit: {value:.3f} > {limit:.3f}", - "✓ 程序校验通过": "✓ Program validation passed", - "✗ 程序校验未通过({n} 处错误)": "✗ Program validation failed ({n} error(s))", - " 指令 {motions} 条 / 路径点 {waypoints} 个 / TCP 路径 {length:.1f} mm": " {motions} motions / {waypoints} waypoints / TCP path {length:.1f} mm", - " 估算节拍 {time:.2f} s": " Estimated cycle time {time:.2f} s", - " 驻留合计 {total:.2f} s({n} 段 waitTime,已计入节拍)": " Dwell total {total:.2f} s ({n} waitTime segment(s), included in cycle time)", - " 节拍明细(按指令):": " Cycle breakdown (by motion):", - " 力矩校验(峰值 / 额定):": " Torque check (peak / rated):", - " 机械功率峰值 {power:.1f} W{avg}": " Peak mechanical power {power:.1f} W{avg}", - "(平均 {power:.1f} W)": " (avg {power:.1f} W)", - " 警告 {n} 处:": " {n} warning(s):", - " 错误 {n} 处:": " {n} error(s):", - "圆弧的起点、中间点、终点(近)共线,无法确定圆弧;请检查 via 点是否落在起终点的连线上": "Arc start, via and end points are (nearly) collinear; cannot define an arc — check that the via point is not on the start-end line", - "未知速度曲线风格 {profile};可用:{available}": "Unknown velocity profile {profile}; available: {available}", - "未知的指令类型:{type}": "Unknown command type: {type}", - "轴 {axis} 峰值速度 {speed:.1f} 超出限位 {limit:.1f}({ratio:.0f}%),该段路径弯折较急,建议放缓速度或增加过渡点": "Axis {axis} peak speed {speed:.1f} exceeds its limit {limit:.1f} ({ratio:.0f}%); this segment bends sharply — slow down or add transition points", - "未传入工作单元场景,跳过干涉检查": "No workcell scene provided; collision check skipped", - "干涉:{detail}": "Collision: {detail}", - "force_mode_end() 没有对应的进入力控指令——真机上 end_force_mode/FCDeact 会直接报错": "force_mode_end() has no matching force-mode start — end_force_mode/FCDeact will fault on a real controller", - "程序结束时仍处于力控状态(进入力控 N 次但未退出)——确认这是有意为之,否则请补 force_mode_end()": "Program ends while force mode is still active — confirm this is intentional, otherwise add force_mode_end()", - "力矩校验跳过:机器人没有可用的运动学模型": "Torque check skipped: no kinematics model available for the robot", - "力矩校验跳过:主链连杆缺少惯性参数({links})——可从 CAD 导出带 的 URDF,或用 DynamicsModel.set_link_inertial 手动录入": "Torque check skipped: main-chain links lack inertial parameters ({links}) — export a URDF with from CAD, or set them via DynamicsModel.set_link_inertial", - "关节 {joint} 峰值力矩 {torque:.1f} N·m 超出额定 {limit:.1f} N·m({ratio:.0f}%)——该段加减速或姿态对关节负载过重,建议放缓速度、增加过渡点或减小负载": "Joint {joint} peak torque {torque:.1f} N·m exceeds its rating {limit:.1f} N·m ({ratio:.0f}%) — this segment overloads the joint; slow down, add transition points or reduce the payload", - "关节 {joint} 峰值力矩 {torque:.1f} N·m 达到额定 {limit:.1f} N·m 的{ratio:.0f}%,余量偏小,建议留意": "Joint {joint} peak torque {torque:.1f} N·m reaches {ratio:.0f}% of its rating {limit:.1f} N·m — small margin, worth watching", - "该段 {progress:.0f}% 处逆解失败——目标可能超出工作空间、位于奇异位形附近,或与相邻点之间要求过大的姿态变化({exc})": "IK failed at {progress:.0f}% of the segment — the target may be out of the workspace, near a singularity, or demand too much reorientation between adjacent points ({exc})", - "moveJ 目标关节数 {n} 与机器人自由度 {dof} 不符": "moveJ target has {n} joints but the robot has {dof} DOF", - "moveJ 目标{violation}": "moveJ target {violation}", - "moveSpline 目标{violation}": "moveSpline target {violation}", - "target 既不是关节列表也不是位姿:{exc}": "target is neither a joint list nor a pose: {exc}", - "目标关节数 {n} 与自由度 {dof} 不符": "target has {n} joints but the robot has {dof} DOF", - "moveC 缺少 via(中间点)": "moveC is missing a via (intermediate) point", - "该段 {progress:.0f}% 处{violation}——直线/圆弧插补会把关节推出限位,需调整路径或工件位置": "{violation} at {progress:.0f}% of the segment — linear/circular interpolation would push joints out of limits; adjust the path or workpiece position", - "该段 {progress:.0f}% 处轴 {axis} 跳变 {step:.1f}°(> {limit}°),可能穿越奇异位形发生翻腕": "Axis {axis} jumps {step:.1f}° at {progress:.0f}% of the segment (> {limit}°) — possible wrist flip through a singularity", - "接近奇异位形(雅可比条件数 {cond:.2e}),关节速度可能突变,建议调整姿态或路径": "Near a singular configuration (Jacobian condition number {cond:.2e}); joint speeds may jump — adjust the pose or path", - # --- v1.1 库层消息:后处理告警 / 生成代码注释 --- - "[warning] {name} 尚未适配 IO 指令,将输出通用伪语法;下装前必须人工核对": "[warning] {name} does not adapt IO commands yet; generic pseudo-syntax will be emitted — verify manually before download", - "{name}: 第 {index} 条指令转换失败: {exc}": "{name}: failed to convert command {index}: {exc}", - "{name}: 模板渲染失败: {exc}": "{name}: template rendering failed: {exc}", - "{name} 尚未实现原生 SPLINE 指令;默认已将其降级为 PTP/LIN(目的地一致)": "{name} has no native SPLINE support; it has been downgraded to PTP/LIN (same destination)", - "[warning] {name}: 信号 {signal} 未映射到物理地址,已输出 TODO 占位(下装前必须补全)": "[warning] {name}: signal {signal} is not mapped to a physical address; a TODO placeholder was emitted (must be resolved before download)", - "TODO: 原语义为 {trigger} 边沿触发,需条件监控实现,当前按电平输出,请人工核对": "TODO: original semantics is {trigger}-edge triggered, which requires a condition monitor; currently emitted as a level check — verify manually", - "起弧前保护气预流 {seconds:.1f}s": "Gas pre-flow {seconds:.1f} s before arc start", - "熄弧后保护气滞流 {seconds:.1f}s": "Gas post-flow {seconds:.1f} s after arc end", - "TODO: 该品牌未适配原生力控指令(指令名随选件/版本而变),请按上述参数对照品牌手册人工实现": "TODO: this brand has no adapted native force-control command (the name varies by option/version); implement manually per the brand manual using the parameters above", - "{name}: 进入力控的 ForceMode 指令缺少 ComplianceSpec": "{name}: the ForceMode command entering force control lacks a ComplianceSpec", - "{name}: 不支持的指令类型: {type}": "{name}: unsupported command type: {type}", - "无法识别的目标表示,长度 {size}(期望 3/6/16)": "Unrecognized target representation, size {size} (expected 3/6/16)", - "不支持的欧拉角顺序: {order}": "Unsupported Euler order: {order}", - "不支持的线位移单位: {unit}": "Unsupported linear unit: {unit}", - "不支持的角度单位: {unit}": "Unsupported angle unit: {unit}", - # --- v1.1 库层消息:post 工厂 / 品牌后处理器 --- - "插件后处理器 {name} 未返回 BasePostProcessor 子类实例,而是 {cls}": "Plugin post-processor {name} did not return a BasePostProcessor instance but {cls}", - "品牌 {name} 有多个后处理器 {matches},请指定完整名称": "Brand {name} has multiple post-processors {matches}; specify the full name", - "未知的后处理器 {name};可用:{available}": "Unknown post-processor {name}; available: {available}", - "! TODO: 信号 {signal} 地址 {addr} 无法解析为数字量序号,需人工改为 DO[n]={level}": "! TODO: signal {signal} address {addr} cannot be resolved to a numeric index; change manually to DO[n]={level}", - "! TODO: 信号 {signal} 地址 {addr} 无法解析为数字量序号,需人工改为 WAIT DI[n]": "! TODO: signal {signal} address {addr} cannot be resolved to a numeric index; change manually to WAIT DI[n]", - "TODO: 原语义为 {trigger} 边沿触发,请人工核对": "TODO: original semantics is {trigger}-edge triggered — verify manually", - "TODO: 超时 {seconds:.2f}s 需用 SKIP 条件实现": "TODO: timeout {seconds:.2f} s requires the SKIP condition — implement manually", - "送丝 {wire:.1f}m/min": "Wire feed {wire:.1f} m/min", - "起弧前预流 {seconds:.1f}s": "gas pre-flow {seconds:.1f} s before arc start", - "FANUC TP 不支持的运动类型: {type}": "FANUC TP does not support motion type: {type}", - "FANUC TP 圆弧运动需要提供 via 中间点": "FANUC TP circular motion requires a via point", - "; TODO: 信号 {signal} 地址 {addr} 无法解析为数字量序号,需人工改为 $OUT[n] = {level}": "; TODO: signal {signal} address {addr} cannot be resolved to a numeric index; change manually to $OUT[n] = {level}", - "; TODO: 信号 {signal} 地址 {addr} 无法解析为数字量序号,需人工改为 WAIT FOR $IN[n]": "; TODO: signal {signal} address {addr} cannot be resolved to a numeric index; change manually to WAIT FOR $IN[n]", - "TODO: 超时 {seconds:.2f}s 需用 $TIMER 自行实现": "TODO: timeout {seconds:.2f} s requires $TIMER — implement manually", - "KRL 不支持的运动类型: {type}": "KRL does not support motion type: {type}", - "KRL 圆弧运动需要提供 via 中间点": "KRL circular motion requires a via point", - "# TODO: 信号 {signal} 地址 {addr} 无法解析为数字量序号,需人工改为 set_digital_out(n, {level})": "# TODO: signal {signal} address {addr} cannot be resolved to a numeric index; change manually to set_digital_out(n, {level})", - "# TODO: 信号 {signal} 地址 {addr} 无法解析为数字量序号,需人工改为 wait_digital_in(n)": "# TODO: signal {signal} address {addr} cannot be resolved to a numeric index; change manually to wait_digital_in(n)", - "TODO: 超时 {seconds:.2f}s 需自行实现看门狗": "TODO: timeout {seconds:.2f} s requires a watchdog — implement manually", - "TODO: 原语义为 {trigger} 边沿触发,需轮询实现,当前按电平输出,请人工核对": "TODO: original semantics is {trigger}-edge triggered, which requires polling; currently emitted as a level check — verify manually", - "URScript 不支持的运动类型: {type}": "URScript does not support motion type: {type}", - "URScript 圆弧运动需要提供 via 中间点": "URScript circular motion requires a via point", - "TODO: 原语义为 {trigger} 边沿触发,需 WaitUntil 边沿逻辑实现,当前按电平输出,请人工核对": "TODO: original semantics is {trigger}-edge triggered, which requires WaitUntil edge logic; currently emitted as a level check — verify manually", - "TODO: ABB Force Control 为 RobotWare 选件,FCRefForce/FCAct 的实参语法请对照《Application manual - Force Control》核对": "TODO: ABB Force Control is a RobotWare option; verify the FCRefForce/FCAct argument syntax against the Application manual - Force Control", - "RAPID 不支持的运动类型: {type}": "RAPID does not support motion type: {type}", - "RAPID 圆弧运动需要提供 via 中间点": "RAPID circular motion requires a via point", - "ESTUN 不支持的运动类型: {type}": "ESTUN does not support motion type: {type}", - "ESTUN 圆弧运动需要提供 via 中间点": "ESTUN circular motion requires a via point", - "JAKA 不支持的运动类型: {type}": "JAKA does not support motion type: {type}", - "JAKA 圆弧运动需要提供 via 中间点": "JAKA circular motion requires a via point", - "InoTeach 不支持的运动类型: {type}": "InoTeach does not support motion type: {type}", - "InoTeach 圆弧运动需要提供 via 中间点": "InoTeach circular motion requires a via point", - "# NOTE: 语法未经真实控制器验证,使用前请核对": "# NOTE: syntax not verified on a real controller; check before use", - # ---- 真机连接面板(v1.1.1)---- - "真机连接": "Real Controller", - "真机连接…": "Real Controller…", - "连接": "Connect", - "断开": "Disconnect", - "主机": "Host", - "端口": "Port", - "超时": "Timeout", - "未连接": "Not connected", - "已连接": "Connected", - "程序部署": "Program Deployment", - "部署当前程序": "Deploy Current Program", - "刷新列表": "Refresh List", - "回读": "Feedback", - "回读关节": "Read Joints", - "关节角 —": "Joints —", - "未知": "unknown", - "正在连接 {host}:{port} …": "Connecting to {host}:{port} …", - "连接失败:{exc}": "Connection failed: {exc}", - "服务端 {server} · 协议 v{api} · 关节数 {dof} · 能力 {caps}": "Server {server} · protocol v{api} · dof {dof} · caps {caps}", - "已连接:{desc}": "Connected: {desc}", - "已连接 {name}": "Connected to {name}", - "已断开 {name}": "Disconnected from {name}", - "未连接控制器——请先建立连接": "Controller not connected — connect first", - "未设置程序来源——请从离线编程面板打开本窗口": "No program source set — open this panel from the main window", - "没有可部署的程序——请先加载机器人并示教至少一个点": "Nothing to deploy — load a robot and teach at least one point first", - "部署失败:{exc}": "Deployment failed: {exc}", - "已部署 {name}:{handle}{note}": "Deployed {name}: {handle}{note}", - "已部署 {name}:{handle}": "Deployed {name}: {handle}", - "获取程序列表失败:{exc}": "Failed to list controller programs: {exc}", - "控制器程序({n})": "Controller programs ({n})", - "控制器上还没有程序": "No programs on the controller yet", - "回读失败:{exc}": "Read failed: {exc}", - "控制器不支持关节回读(能力协商 feedback=false)——不伪造数据": "Controller does not support joint feedback (negotiated caps.feedback=false) — no fabricated data", - "控制器不支持回读": "Controller does not support feedback", - "关节角(度):{vals}": "Joints (deg): {vals}", - "回读关节(度):{vals}": "Read joints (deg): {vals}", - "回读关节应用失败:{exc}": "Failed to apply read joints: {exc}", - "关节数不符(真机 {n},模型 {m})": "Joint count mismatch (controller {n}, model {m})", - "已按真机位形摆好视口": "Viewport set to the controller's actual pose", - "真机连接模块不可用。": "Real controller module unavailable.", - "连接真机控制器:部署当前程序、回读实际关节角、取回核对。\n传输可选 Socket(core-socket 行协议)或 SSH(SFTP 传程序 + exec)。\n无运动指令入口、无自由命令输入框——程序运动一律走部署。": ( - "Connect to a real controller: deploy the current program, read back actual joints,\n" - "fetch and verify. Transport: Socket (core-socket line protocol) or SSH\n" - "(SFTP upload + exec). No motion-command entry and no free-form command box —\n" - "motion goes through deployment only." - ), - # ---- 真机连接面板:SSH 传输与取回核对(v1.1.4)---- - "传输": "Transport", - "Socket(core-socket 行协议)": "Socket (core-socket line protocol)", - "SSH(SFTP 传程序 + exec)": "SSH (SFTP upload + exec)", - "用户名": "Username", - "密码": "Password", - "与私钥二选一": "or use a private key", - "私钥": "Private key", - "私钥文件路径,可留空": "Private key file path (optional)", - "远端目录": "Remote directory", - "取回核对": "Fetch & Verify", - "把控制器上的程序原文取回,与本次生成的代码逐字节比对——\n「部署报成功」与「控制器上确实是这份代码」是两件事": ( - "Fetch the program text back from the controller and compare it byte for byte with the\n" - 'code just generated — "deploy reported success" and "the controller really holds\n' - 'this code" are two different things' - ), - "正在连接 {transport} {host}:{port} …": "Connecting to {transport} {host}:{port} …", - "SSH 形态无回读通道": "SSH transport has no feedback channel", - "SSH 形态没有关节回读通道——回读请用 Socket;不伪造数据": ( - "SSH transport has no joint feedback channel — use Socket for feedback; no fabricated data" - ), - "SSH 无能力协商 · 远端目录 {dir} · 不支持关节回读": ( - "SSH: no capability negotiation · remote dir {dir} · joint feedback unsupported" - ), - "没有可核对的程序——请先部署一次": "Nothing to verify — deploy once first", - "取回失败:{exc}": "Fetch failed: {exc}", - "控制器上没有 {name}——部署可能没落盘": ( - "{name} not found on the controller — the deployment may not have landed" - ), - "控制器上没有 {name}": "{name} not found on the controller", - "核对 {name}:与本次生成的代码逐字节一致({lines} 行)": ( - "Verified {name}: byte-identical to the code just generated ({lines} lines)" - ), - "核对 {name}:**与本次生成的代码不一致**(控制器 {lines} 行 / 本地 {local} 行)——先别下线": ( - "Verified {name}: **DIFFERS from the code just generated** " - "(controller {lines} lines / local {local} lines) — do not go offline yet" - ), - "核对 {name}:已取回 {lines} 行(本次会话不是由本面板部署的它,只报了行数,不冒充逐字节结论)": ( - "Fetched {name}: {lines} lines (not deployed by this panel in this session — " - "line count only, no byte-level claim)" - ), - "核对 {name}:{lines} 行": "Verified {name}: {lines} lines", - # ---- link.describe()(真机连接状态行,随界面语言)---- - "服务端": "server", - "能力": "caps", - "请求": "requests", - "目录": "dir", - "已传": "uploaded", - "执行": "exec", - # ---- Program.deploy 备注(按连接形态如实描述回读能力)---- - "支持实际位置回读": "actual position feedback supported", - "离线导出,无实际位置回读": "offline export, no actual position feedback", - "{protocol} 连接,控制器不提供实际位置回读": ( - "{protocol} connection; the controller provides no actual position feedback" - ), - # ---- 轨迹编辑器圆弧编辑(v1.1.5)---- - # 口径:第 N 行的运动类型描述"到达第 N 点"的运动,moveC 以上一点为中间点 - "设为 moveC": "Set as moveC", - "moveC(圆弧)": "moveC (circular)", - "moveC(圆弧,以上一点为中间点)": "moveC (circular; previous point is the via)", - "moveC 中间点": "moveC via", - "可设为 moveC(圆弧)": "Can be set to moveC (circular)", - "不可设为 moveC:{reason}": "Cannot be set to moveC: {reason}", - "路点不存在": "No such waypoint", - "P{n} 缺少两个前驱点——圆弧需要起点与中间点,P1/P2 不能设为 moveC": ( - "P{n} has fewer than two predecessors — an arc needs a start and a via, " - "so P1/P2 cannot be moveC" - ), - "P{n} 已是圆弧终点,不能再作为本段的中间点": ( - "P{n} is already an arc endpoint and cannot serve as this segment's via" - ), - "P{n} 已把 P{k} 当中间点——本行不能同时作为圆弧终点": ( - "P{n} already uses P{k} as its via — this row cannot also be an arc endpoint" - ), - "P{n} 是抓取/放置点,不能作为圆弧中间点(中间点不停机)": ( - "P{n} is a pick/place point and cannot be an arc via (a via does not stop)" - ), - "{reason}——已按关节运动(moveJ)处理": "{reason} — handled as a joint move (moveJ)", - "P{n} 的圆弧正解失败——退化为关节运动(moveJ);中间点 P{k} 改为单独经过": ( - "Forward kinematics failed for the arc at P{n} — degraded to joint moves (moveJ); " - "via point P{k} is now passed through separately" - ), - "P{n} 的圆弧无法加入程序——退化为关节运动(moveJ);中间点 P{k} 改为单独经过": ( - "The arc at P{n} could not be added to the program — degraded to joint moves (moveJ); " - "via point P{k} is now passed through separately" - ), - ";{n} 项运动降级(见日志)": "; {n} motion(s) degraded (see log)", - "⚠ {note}": "⚠ {note}", - "✓ 程序校验通过,未发现问题": "✓ Program validated, no issues found", - "选中点改为圆弧运动:以上一点为中间点、再上一点为起点画圆弧到达本点。\n" - "中间点不再单独出指令(它只是一段圆弧的途经点),因此 P1/P2 不能设为圆弧,\n" - "相邻两行也不能同为圆弧,抓取/放置点不能当中间点。": ( - "Turn the selected point into a circular motion: the previous point is the via and " - "the one before it the start, then arc into this point.\n" - "The via no longer emits its own instruction (it is merely passed through), so P1/P2 " - "cannot be arcs, two adjacent rows cannot both be arcs, and pick/place points cannot " - "be vias." - ), - "改动后需重新校验:着色与节拍会随校验结果刷新(红=错误 / 橙=警告 / 绿=正常)\n" - "圆弧口径:第 N 行的运动类型描述「到达第 N 点」的运动——moveC 以上一点为中间点、" - "再上一点为起点;中间点行显示为「moveC 中间点」": ( - "Re-validate after edits: colours and cycle times refresh with the validation result " - "(red = error / orange = warning / green = OK)\n" - "Arc convention: a row's motion type describes the motion *arriving at* that point — " - "moveC uses the previous point as via and the one before it as start; via rows are " - 'labelled "moveC via"' - ), - # ---- 补漏:v1.0.10 GUI 接线时遗漏的条目(en 模式不再残留中文)---- - "保持": "Hold", - "夹紧": "Close", - "松开": "Open", - "(场景根)": "(Scene root)", - "场景根": "Scene root", - "拖拽节点改写挂载层级:挂到机器人=安装到法兰,自动对齐坐标系": ( - "Drag a node to re-parent it: dropping onto a robot mounts it on the " - "flange and auto-aligns the coordinate frame" - ), - "{name} 是机器人,不能被挂载": "{name} is a robot and cannot be mounted", - "{name} 是机器人,不能挂到 {host} 的法兰上": ( - "{name} is a robot and cannot be mounted on {host}'s flange" - ), - "挂载失败: {exc}": "Mounting failed: {exc}", - "已将 {name} 挂到 {host}": "Mounted {name} onto {host}", - "已将 {name} 装夹到 {host} 末端": "Mounted {name} onto {host}'s flange", - "装夹到末端": "Mount on flange", - "解除装夹": "Unmount", - "吸附到机器人法兰原点,工具坐标系与法兰对齐并随机器人运动": ( - "Snap to the robot flange origin: the tool frame aligns with the flange " - "and follows the robot" - ), - "解除挂载并保持当前世界位姿": "Unmount and keep the current world pose", - "按场景里的机器人/组件补齐设备清单(已存在的保留不动)": ( - "Fill the device list from the scene's robots/components " - "(existing entries are kept as-is)" - ), - "环境建模模块不可用。": "The environment modelling module is unavailable.", - "离线编程模块不可用。": "The offline programming module is unavailable.", - # ---- PLC ↔ 机器人 IO 点表(v1.1.6)---- - # 方向标签是词表数据(DIRECTION_LABELS),只在显示处经 tr() 翻译 - "PLC→机器人": "PLC→robot", - "机器人→PLC": "robot→PLC", - "传感器→PLC": "sensor→PLC", - # 地址解析 - "地址为空": "Address is empty", - "地址里有空白字符": "Address contains whitespace", - "地址里有逗号——一行只能写一个地址": "Address contains a comma — one address per row", - "地址里没有任何数字": "Address contains no digits", - "点表需要工作单元配置(CellConfig)": ( - "The point table requires a cell configuration (CellConfig)" - ), - # 点表行 - "机器人读写": "robot reads and writes", - "机器人写": "robot writes", - "机器人读": "robot reads", - "机器人未用": "not used by the robot", - "未声明": "undeclared", - "信号": "Signal", - "地址": "Address", - "方向": "Direction", - "用途": "Usage", - "归属": "Owner", - "点表 {total} 点:已映射 {mapped} / 未映射 {unmapped},{errors} 处错误 {warnings} 处警告": ( - "Point table: {total} point(s), {mapped} mapped / {unmapped} unmapped, " - "{errors} error(s), {warnings} warning(s)" - ), - " ✓ 点表未发现问题": " ✓ No point-table issues found", - # 校验 - "机器人程序在用 {usage},但没有任何设备给出物理地址——下装前必须补上": ( - "The robot program uses it ({usage}) but no device provides a physical address — " - "must be filled in before download" - ), - "点表里有它但没有地址(下装前补上,或从点表里删掉)": ( - "Listed in the point table but has no address " - "(fill it in before download, or remove it from the table)" - ), - "{addr} 已被 {other} 占用——两个信号不能共用一个物理地址": ( - "{addr} is already taken by {other} — two signals must not share one physical address" - ), - "{devices} 都声明了这个信号——归属必须唯一": ( - "{devices} all declare this signal — the owner must be unique" - ), - "{addr!r} 不像一个 IO 地址:{why}": "{addr!r} does not look like an IO address: {why}", - "机器人(SetDO)与 PLC 都在驱动它——必须明确主控方,否则谁后写谁生效": ( - "Both the robot (SetDO) and the PLC drive it — pick one owner, " - "otherwise the last writer wins" - ), - "机器人在等它,但机器人自己不写、PLC 也没驱动它——现场会卡在这一步": ( - "The robot waits on it, but the robot does not write it and the PLC does not drive it — " - "the cell will stall here" - ), - # GUI(IO 映射对话框 + 硬件列表按钮 + 状态栏) - "IO 映射": "IO map", - "IO 映射…": "IO map…", - "IO 映射 · {name}": "IO map · {name}", - "IO 校验": "Check IO", - "IO 点表校验": "IO point table check", - "编辑当前设备的信号地址(点表与 PLC 出码共用这一份数据)": ( - "Edit the selected device's signal addresses " - "(the point table and the PLC code share this one source)" - ), - "检查未映射 / 地址重复 / 两侧同时驱动 / 机器人等一个没人给的信号": ( - "Check for unmapped signals / duplicate addresses / both sides driving / " - "the robot waiting on a signal nobody gives" - ), - "请先在设备列表里选中一行": "Select a row in the device list first", - "地址(可编辑)": "Address (editable)", - "添加信号": "Add signal", - "信号名(如 gripper_close)": "Signal name (e.g. gripper_close)", - "自动编号": "Auto-number", - "按方向自动编号,跳过已被占用的地址": ( - "Number by direction, skipping addresses that are already taken" - ), - "自动编号:{n} 个信号(跳过已占用地址)": ( - "Auto-numbered {n} signal(s), skipping taken addresses" - ), - "IO 映射已更新:{name}({n} 个信号)": "IO map updated: {name} ({n} signal(s))", - "有问题的点必须在下载前处理(双击「Details」看逐点清单)": ( - 'Problem points must be fixed before download (double-click "Details" for the list)' - ), - "警告不阻塞下载,但现场必须有人看过": ( - "Warnings do not block download, but someone on site must have looked at them" - ), - "PLC 代码已生成,但点表有 {n} 处错误——下装前必须处理": ( - "PLC code generated, but the point table has {n} error(s) — fix before download" - ), - # ---- 场景拖拽摆放(v1.1.7)---- - "地面": "ground", - "{name} → ({x:.1f}, {y:.1f}, {z:.1f}) mm · 落在 {support} 上": ( - "{name} → ({x:.1f}, {y:.1f}, {z:.1f}) mm · placed on {support}" - ), - "{name} 位置未变": "{name} not moved", - "场景里没有 {name}": "{name} is not in the scene", - "{name} 是机器人——拖拽摆放只用于工件与设备": ( - "{name} is a robot — drag-and-place is for parts and equipment only" - ), - "{name} 不能拖动:对象已锁定": "{name} cannot be dragged: the object is locked", - "{name} 不能拖动:已挂载在 {host} 上,请先解除挂载": ( - "{name} cannot be dragged: mounted on {host}; unmount it first" - ), - # ---- 规模与性能基准(v1.1.8)---- - "RoboWorld 规划/校验规模基准": "RoboWorld planning / validation scale benchmark", - "机器人 URDF 路径": "robot URDF path", - "单次基准的运动条数": "number of motions in the single-run benchmark", - "规模表用的运动条数(逗号分隔,如 20,50,100)": ( - "motion counts for the scale table (comma separated, e.g. 20,50,100)" - ), - "每段重复次数(取最优)": "repetitions per phase (best-of)", - "输出 JSON": "emit JSON", - "找不到机器人模型;用 --robot 指定 URDF 路径": ( - "robot model not found; pass a URDF path with --robot" - ), - "规模与性能基准 · {robot}": "Scale and performance benchmark · {robot}", - " 指令 {commands} 条(其中运动 {motions} 条)→ 路点 {wps} 个": ( - " {commands} command(s) ({motions} motion(s)) → {wps} waypoint(s)" - ), - " 仿真节拍 {cycle:.2f} s · 路径长 {length:.1f} mm": ( - " simulated cycle time {cycle:.2f} s · TCP travel {length:.1f} mm" - ), - " 校验问题:{errors} 错误 / {warnings} 警告": ( - " validation: {errors} error(s) / {warnings} warning(s)" - ), - " 阶段耗时({repeats} 次取最优)": " phase timings (best of {repeats})", - " 单路点规划 {ms:.3f} ms · 整轮墙钟 {wall:.2f} s": ( - " {ms:.3f} ms per waypoint · {wall:.2f} s wall clock for the whole run" - ), - " (未接场景,干涉检查跳过)": " (no scene attached — interference check skipped)", - " 关节原点旋转:实算 {computed} 次 / 命中缓存 {hits} 次": ( - " Joint-origin rotations: {computed} computed / {hits} cache hit(s)" - ), - "规划": "planning", - "校验(含内部重新规划)": "validation (re-plans internally)", - "{per:.3f} ms/路点": "{per:.3f} ms/waypoint", - "{n} 处接触": "{n} contact(s)", - "{n} 条问题": "{n} issue(s)", - "未接场景,跳过": "no scene, skipped", - "运动条数 路点数 节拍(s) 规划(ms) 校验(ms) 干涉(ms) ms/路点": ( - "motions waypoints cycle(s) plan(ms) validate(ms) collision(ms) ms/wp" - ), - "规模表 · {robot}({repeats} 次取最优)": "Scale table · {robot} (best of {repeats})", - # ---- 干涉检查分段基准(v1.1.9)---- - "附加干涉检查分段基准(接仓库内置抓取工作站场景)": ( - "also benchmark interference checking (with the built-in grasping workstation)" - ), - "干涉基准的关节采样步长(度,逗号分隔)": ( - "joint-space sampling steps for the interference benchmark (degrees, comma separated)" - ), - "{n} 处接触 · 采样 {k} 次(每 {m} 路点 1 次)": ( - "{n} contact(s) · {k} sample(s) (1 per {m} waypoints)" - ), - "干涉检查分段基准 · {robot}": "Interference-check breakdown · {robot}", - " 指令 {commands} 条 → 路点 {wps} 个 · 干涉裕度 {margin:.1f} mm": ( - " {commands} command(s) → {wps} waypoint(s) · interference margin {margin:.1f} mm" - ), - " 机器人零件 {parts} 个 · 环境对象 {objs} 个(零件 {eparts} 个)": ( - " robot parts {parts} · environment objects {objs} (parts {eparts})" - ), - "采样口径": "sampling policy", - "采样数": "samples", - "每采样路点": "waypoints/sample", - "总耗时": "total", - "每次采样": "per sample", - "接触": "contacts", - "窄相对": "narrow pairs", - "广相剔除": "broad-phase rejects", - "默认": "default", - "默认(约 {n} 次)": "default (about {n} samples)", - "关节步长 {deg:g}°": "joint step {deg:g}°", - " 口径说明:「{default}」按总采样数约 300 的预算取样,程序越长采样点隔得越远;" - "给定关节步长的口径按「机器人可能移动多远」取样,不随程序变长而稀释。": ( - " Sampling policy: the 「{default}」 policy spends a budget of ~300 samples, " - "so the gap between two samples grows with program length; a joint-step policy " - "samples by how far the robot can actually move and does not thin out as the " - "program grows." - ), - "max_joint_step 必须是非负有限值,收到 {value}——它是采样判据(度),负值没有意义": ( - "max_joint_step must be a finite non-negative number, got {value} — it is a " - "sampling criterion in degrees, a negative value is meaningless" - ), - # ---- 几何内核等价性对拍(v1.1.9)---- - "几何内核等价性对拍(生产实现 vs 朴素参考实现)": ( - "Collision kernel equivalence check (production vs naive reference)" - ), - " {pairs} 对基本体 × {cases} 组位姿/裕度 · 裕度 {margins}": ( - " {pairs} primitive pair(s) × {cases} pose/margin case(s) · margins {margins}" - ), - " 碰撞判定差异 {n} 处 · 穿透深度完全相同 {exact}/{pairs} 次 · 最大数值差 {delta:.3e}": ( - " collision verdict differences: {n} · penetration depth identical in " - "{exact}/{pairs} case(s) · worst numeric delta {delta:.3e}" - ), - " AABB {a} · 顶点 {v} · SAT 轴 {s} 差异(均应为 0)": ( - " AABB {a} · vertices {v} · SAT axes {s} difference(s) (all should be 0)" - ), - " 同一批判定:生产 {fast:.3f} s · 朴素 {slow:.3f} s → {x:.1f}× 更快": ( - " Same batch of verdicts: production {fast:.3f} s · naive {slow:.3f} s " - "→ {x:.1f}× faster" - ), - # ---- 增量重算分场景基准(v1.2.0)---- - "增量重算分场景基准 · {robot}": "Incremental replan per-scenario benchmark · {robot}", - " 指令 {commands} 条(运动 {motions} 条)→ 路点 {wps} 个 · 冷启动 {base:.1f} ms": ( - " {commands} command(s) ({motions} motion(s)) → {wps} waypoint(s) · " - "cold start {base:.1f} ms" - ), - "场景": "Scenario", - "耗时": "Time", - "加速": "Speedup", - "分段复用": "Seg reuse", - "整程序": "Whole", - "等价": "Equiv", - "命中": "hit", - "差 {d:.1e}": "diff {d:.1e}", - " 分段复用率的分母是「指令数」而不是「路点数」——一条指令命中,意味着它那几十个路点全部免算": ( - " The segment reuse rate is over commands, not waypoints — one command hit " - "spares all of its dozens of waypoints" - ), - " 等价列=与关缓存的实算结果逐位比对(关节/位姿/时刻/速度),任何一项不为 0 都会在这里显示出来": ( - " The Equiv column compares bit-for-bit against a cache-disabled run " - "(joints/pose/time/velocity); any non-zero difference shows up here" - ), - "无改动": "No edit", - "改末尾运动": "Edit last motion", - "改中段运动": "Edit middle motion", - "改首条运动": "Edit first motion", - "只改速度": "Speed only", - "插入驻留": "Insert dwell", - "删除一条": "Delete one", - "加跑增量重算分场景基准(moveJ 程序)": ( - "Also run the incremental replan benchmark (moveJ program)" - ), - "增量基准再加跑 moveL(位姿目标)程序——两者复用率差别是结构性的": ( - "Also benchmark a moveL (pose-target) program — the reuse gap between the " - "two is structural" - ), - # --- 工艺时序(v1.3.1):序列模型 / 校验 / 双出口 / 设备树编辑器 --- - "添加工步": "Add step", - "校验时序": "Validate sequence", - "保存时序…": "Save sequence…", - "载入时序…": "Load sequence…", - "保存工艺时序": "Save process sequence", - "载入工艺时序": "Load process sequence", - "未校验": "Not validated", - "还没有工步——点「添加工步」把工序排进泳道": ( - 'No steps yet — click "Add step" to lay operations into the swimlanes' - ), - "步骤序号 →(驻留按秒加宽;非实测节拍)": ( - "Step # → (dwell widened by seconds; not a measured cycle)" - ), - "序号": "#", - "泳道": "Lane", - "内容": "Content", - "泳道(设备):": "Lane (device):", - "工步名:": "Step name:", - "子程序名:": "Subprogram name:", - "信号:": "Signal:", - "动作:": "Action:", - "置位(勾选=True,不勾=False)": "Set (checked = True, unchecked = False)", - "触发方式:": "Trigger:", - "电平": "Level", - "上升沿": "rising edge", - "下降沿": "falling edge", - "期望电平为 True": "Expected level is True", - "期望电平:": "Expected level:", - "超时 (s):": "Timeout (s):", - "0=无限等待。现场必须配看门狗,否则一步卡死整条线": ( - "0 = wait forever. A watchdog is mandatory on site, otherwise one step " - "stalls the whole line" - ), - "驻留 (s):": "Dwell (s):", - "添加工步:子程序调用": "Add step: subprogram call", - "添加工步:触发信号": "Add step: set signal", - "添加工步:等待信号": "Add step: wait for signal", - "添加工步:驻留": "Add step: dwell", - "子程序调用": "Subprogram call", - "触发信号": "Set signal", - "等待信号": "Wait for signal", - "调用": "Call", - "触发": "Set", - "等待": "Wait", - "驻留": "Dwell", - "工步 {n}": "Step {n}", - "已添加工步 {name}": "Added step {name}", - "先在步骤表里选中一行": "Select a row in the step table first", - "已经到头了": "Already at the end", - "信号名不能为空": "Signal name cannot be empty", - "子程序名不能为空": "Subprogram name cannot be empty", - "校验通过:{n} 个工步,驻留合计 {s}s": ("Validation passed: {n} steps, dwell total {s}s"), - "{mark} {e} 处错误 / {w} 处警告": "{mark} {e} errors / {w} warnings", - "(其余 {n} 条见日志)": "({n} more in the log)", - "时序已保存:{path}": "Sequence saved: {path}", - "载入失败: {exc}": "Load failed: {exc}", - "{n} 个工步 | 触发 {signal} | 等待 {wait} | 调用 {call} | 驻留合计 {dwell}s": ( - "{n} steps | set {signal} | wait {wait} | call {call} | dwell total {dwell}s" - ), - "调用 {sub}": "Call {sub}", - "{signal} = {value}": "{signal} = {value}", - "等 {signal} = {value}": "Wait {signal} = {value}", - "等 {signal}({trg})": "Wait {signal} ({trg})", - "等待 {signal}": "Wait {signal}", - "驻留 {s}s": "Dwell {s}s", - "调用 {sub} 前置输出": "Outputs before calling {sub}", - "末步输出": "Final outputs", - "驻留合计是唯一可计算的时间;触发与等待的耗时取决于现场机构": ( - "Dwell total is the only computable time; trigger and wait durations " - "depend on the on-site mechanism" - ), - "时序为空——设备树只编排了静态挂载,还没有任何工序": ( - "Sequence is empty — the device tree only has static mounts, no operations yet" - ), - "工步 {i}({name})": "Step {i} ({name})", - "等待触发方式必须是 {opts} 之一,收到 {got}": ("Wait trigger must be one of {opts}, got {got}"), - "驻留时长不能为负,收到 {s}s": "Dwell duration cannot be negative, got {s}s", - "不支持的时序 schema:{schema}": "Unsupported sequence schema: {schema}", - "未知工步类型:{kind}": "Unknown step kind: {kind}", - "(缺失)": "(missing)", - "{where} 与工步 {j} 重名": "{where} has the same name as step {j}", - "{where} 的泳道 {device} 不在场景设备里": ( - "{where} uses lane {device}, which is not a device in the scene" - ), - "{where} 触发的信号 {signal} 不在点表里——下装前必须补上映射": ( - "{where} sets signal {signal}, which is not in the point table — map it " - "before downloading" - ), - "{where} 等待的信号 {signal} 不在点表里——下装前必须补上映射": ( - "{where} waits on signal {signal}, which is not in the point table — map " - "it before downloading" - ), - "{where} 无限等待 {signal}——现场必须配看门狗,否则一步卡死整条线": ( - "{where} waits on {signal} forever — a watchdog is mandatory on site, " - "otherwise one step stalls the whole line" - ), - "{where} 是{trg}触发,期望电平不参与判定": ( - "{where} triggers on {trg}, so the expected level is not used in the check" - ), - "{where} 调用的子程序 {sub} 不存在": ("{where} calls subprogram {sub}, which does not exist"), - "工步 {name}(调用 {sub})不是程序指令,未写入——调用语法由品牌主程序层生成": ( - "Step {name} (calls {sub}) is not a program instruction and was not " - "emitted — call syntax is generated by the brand main-program layer" - ), - # ---- 零件库(v1.3.3) ---- - "未知基本体类型 {kind}(支持 {opts})": ("Unknown primitive kind {kind} (supported: {opts})"), - "box 基本体需要 size=(长, 宽, 高),零件 {name}": ( - "A box primitive needs size=(L, W, H); part {name}" - ), - "box 尺寸必须为正,收到 {size}": ("Box dimensions must be positive, got {size}"), - "{kind} 基本体需要正的 radius,零件 {name}": ( - "A {kind} primitive needs a positive radius; part {name}" - ), - "cylinder 基本体需要正的 height,零件 {name}": ( - "A cylinder primitive needs a positive height; part {name}" - ), - "颜色分量必须在 0~1 之间,收到 {c}": ("Color components must be within 0..1, got {c}"), - "零件 id 必须是字母开头的短标识符(字母/数字/_/-),收到 {id}": ( - "Part id must be a short identifier starting with a letter " - "(letters/digits/_/-), got {id}" - ), - "零件缺少显示名({id})": "Part is missing a display name ({id})", - "零件 {name} 没有任何几何基本体": ("Part {name} has no geometry primitives"), - "零件 {name} 里有重名的基本体:{dupes}": ("Part {name} has duplicate primitive names: {dupes}"), - "不支持的零件库 schema:{schema}(期望 {want})": ( - "Unsupported parts-library schema: {schema} (expected {want})" - ), - "零件库中已存在同名零件 id:{id}": ("A part with id {id} already exists in the library"), - "零件库中没有 id 为 {id} 的零件": ("No part with id {id} in the library"), - "组件目录里已存在 {cid}——先 unregister 或换一个零件 id": ( - "{cid} already exists in the component catalog — unregister first " - "or pick another part id" - ), - "自定义零件(来自零件库导入,几何固定)": ( - "Custom part (imported from a parts library; fixed geometry)" - ), - "导入零件…": "Import Parts…", - "导入零件库 JSON(core.parts/1,参数化基本体的自定义工件/结构件)": ( - "Import a parts-library JSON (core.parts/1; custom workpiece / " - "structural parts made of parametric primitives)" - ), - "组件库不可用,无法导入零件": ("Component catalog unavailable; cannot import parts"), - "导入零件库": "Import Parts Library", - "零件库 JSON (*.json);;所有文件 (*)": "Parts Library JSON (*.json);;All Files (*)", - "导入零件失败:{kind}: {msg}": "Part import failed: {kind}: {msg}", - "零件库已导入:{n} 个自定义零件(「自定义」分类下,可拖入视口)": ( - "Parts library imported: {n} custom parts (under the Custom " - "category; drag into the viewport)" - ), - # ---- v1.3.4 工程文件(`.core`)与界面接线 ---- # - " •未保存": " •unsaved", - "未命名工程": "Untitled project", - "保存工程": "Save Project", - "打开工程": "Open Project", - "工程文件模块不可用。": "Project file support is unavailable.", - "应用时的问题:": "Issues while applying:", - "打开工程(&O)…": "&Open Project…", - ( - "打开 .core 工程文件:一次载入场景、示教程序(含抓取/放置语义)、工艺时序、设备点表与零件库来源。\n" - "缺段/坏段只跳过那一段并在报告里说明——不会因为一段格式问题打不开整个工程。" - ): ( - "Open a .core project: loads the scene, the teach program (including the pick/place semantics that only live in the project file), the process sequence, the device IO table and the parts-library sources in one go.\n" - "A missing or broken section only skips that section and is reported — a single bad section never blocks the whole project." - ), - "保存工程(&S)…": "&Save Project…", - ( - "把当前工作会话存成一个 .core 工程文件(原子写入)。\n" - "本库不认识的段与字段会原样保留,往返一趟不丢内容。" - ): ( - "Save the current work session as a single .core project file (atomic write).\n" - "Sections and fields this library does not understand are preserved verbatim, so a round trip never loses content." - ), - "场景段已跳过:环境建模不可用": "Scene section skipped: world modeling unavailable", - "示教程序段已跳过:路径编辑器不可用": "Teach program section skipped: path editor unavailable", - "工艺时序段已跳过:时序编辑器不可用": "Process sequence section skipped: sequence editor unavailable", - "设备点表段已跳过:工作单元模块不可用": "Device IO table section skipped: workcell module unavailable", - "保存工程失败": "Save Project Failed", - "工程已保存:{name}({n} 段)": "Project saved: {name} ({n} sections)", - "打开工程失败": "Open Project Failed", - "已打开 {name}": "Opened {name}", - "工程已打开:{name}": "Project opened: {name}", - "零件库来源已跳过:机器组件库不可用": "Parts sources skipped: robot library unavailable", - "场景段载入失败:{msg}": "Scene section failed to load: {msg}", - "示教点语义 {a} 个与解析出的 {b} 个点不一致,已按默认语义重建(不猜哪个是抓取点)": ( - "{a} point semantics do not match the {b} points parsed from the code; " - "rebuilt with default semantics (we do not guess which point is the pick)" - ), - "工艺时序段载入失败:{msg}": "Process sequence section failed to load: {msg}", - "设备点表段载入失败:{msg}": "Device IO table section failed to load: {msg}", - "零件库文件找不到:{src}(未导入;工程里仍记录着它)": ( - "Parts-library file not found: {src} (not imported; the project still " "records it)" - ), - "已从工程恢复零件库:{n} 个自定义零件": "Parts library restored from the project: {n} custom parts", - "场景里的机器人模型找不到:{urdf}(场景仍已载入)": ( - "Robot model referenced by the scene not found: {urdf} (the scene itself " - "was still loaded)" - ), - "零件库导入失败:{src}({msg})": "Parts library import failed: {src} ({msg})", - "示教点语义含未知值({msg}),已按默认语义重建": ( - "Point semantics contain an unknown value ({msg}); rebuilt with default " "semantics" - ), - "RoboWorld 工程 (*.rwproj *.robotproj *.core);;所有文件 (*)": ( - "RoboWorld project (*.rwproj *.robotproj *.core);;All Files (*)" - ), - "示教程序": "Teach program", - "各机示教程序": "Per-robot teach programs", - "各机示教程序段必须是对象": "Per-robot teach programs section must be an object", - "各机示教程序段:{name} 的内容必须是对象": "Per-robot teach programs: {name} must be an object", - "各机示教程序段:{name} 的 positions 必须是数组": "Per-robot teach programs: positions of {name} must be an array", - "多机示教点段已跳过:离线编程模块不可用": "Per-robot teach points skipped: offline programming module unavailable", - "工艺时序": "Process sequence", - "设备点表": "Device IO table", - "零件库来源": "Parts sources", - "标定集": "Calibration set", - "未知段": "Unknown section", - "(未指定)": "(not specified)", - "(内容为空)": "(empty)", - "{title}:未知段": "{title}: unknown section", - "场景段必须是对象": "The scene section must be an object", - "场景段的 objects 必须是数组": "The scene section's objects must be an array", - "示教程序段必须是对象": "The teach program section must be an object", - "示教程序段缺 code": "The teach program section is missing code", - "示教程序段的 code 必须是字符串": "The teach program section's code must be a string", - "示教程序段的 point_types 必须是数组": "The teach program section's point_types must be an array", - "工艺时序段必须是对象": "The process sequence section must be an object", - "工艺时序段的 steps 必须是数组": "The process sequence section's steps must be an array", - "设备点表段必须是对象": "The device IO table section must be an object", - "设备点表段缺 devices": "The device IO table section is missing devices", - "设备点表段的 devices 必须是数组": "The device IO table section's devices must be an array", - "零件库来源段必须是对象": "The parts sources section must be an object", - "零件库来源段缺 sources": "The parts sources section is missing sources", - "零件库来源段的 sources 必须是字符串数组": "The parts sources section's sources must be an array of strings", - "标定集段必须是对象": "The calibration section must be an object", - "(未命名工程)": "(untitled project)", - "查看 .core 工程文件的内容摘要(不加载场景/机器人)": "Print a content summary of a .core project file (no scene/robot loading)", - "工程文件路径": "path to the project file", - "以 JSON 输出摘要": "print the summary as JSON", - "工程里没有任何可识别的段": "No recognizable section in this project", - "{title}:{n} 个对象 | {r} 台机器人({name})": "{title}: {n} object(s) | {r} robot(s) ({name})", - "{title}:{n} 行 | {p} 个示教点(抓取 {k} / 放置 {v})": "{title}: {n} line(s) | {p} taught point(s) (pick {k} / place {v})", - "{title}:{r} 台机器人 | 合计 {p} 个示教点(抓取 {k} / 放置 {v})": "{title}: {r} robot(s) | {p} taught point(s) in total (pick {k} / place {v})", - "{title}:{n} 个工步 | 驻留合计 {d}s": "{title}: {n} step(s) | dwell total {d}s", - "{title}:{n} 台设备 | {s} 个已映射信号": "{title}: {n} device(s) | {s} mapped signal(s)", - "{title}:{n} 个零件库文件": "{title}: {n} parts-library file(s)", - "{title}:{detail}": "{title}: {detail}", - "设备点表里有一台设备缺 name": "A device in the IO table has no name", - " (空工程:没有任何段)": " (empty project: no sections)", - "工程文件的内容必须是一个对象": "A project file's content must be an object", - "工程文件的 sections 必须是一个对象": "A project file's sections must be an object", - "保存路径为空": "Save path is empty", - "{title}:格式异常": "{title}: malformed", - "标定集 schema 不支持:{schema}": "Unsupported calibration schema: {schema}", - "{name}:原样保留(本版本不解释)": "{name}: preserved verbatim (not interpreted here)", - "工程:{name}": "Project: {name}", - "保存版本:core {v} | 最后修改 {t}": "Saved with: core {v} | last modified {t}", - "已载入 {n} 段:{names}": "Loaded {n} section(s): {names}", - "跳过 {n} 段(内容原样保留,保存时写回):": ( - "Skipped {n} section(s) (content preserved verbatim and written back on " "save):" - ), - "未知段 {names}——本版本不解释,原样保留": ( - "Unknown section(s) {names} — not interpreted by this version, preserved " "verbatim" - ), - "未知顶层键 {keys}——原样保留": "Unknown top-level key(s) {keys} — preserved verbatim", - "未知工程段:{name}(可用:{opts})": "Unknown project section: {name} (available: {opts})", - "备注:{note}": "Note: {note}", - "工程里没有:{names}": "Not in this project: {names}", - "不是 RoboWorld 工程文件(schema={got},期望 {want})": "Not a RoboWorld project file (schema={got}, expected {want})", - "工程由 core {saved} 保存,当前库版本 {cur}——按现行 schema 读取,未做版本迁移": ( - "Saved by core {saved}; current library version is {cur} — read with the " - "current schema, no version migration was performed" - ), - "打不开:{msg}": "Cannot open: {msg}", - " · {name}:{reason}": " · {name}: {reason}", - "工程段 {name} 无法序列化:{msg}": "Project section {name} cannot be serialized: {msg}", - "工程无法序列化:{msg}": "Project cannot be serialized: {msg}", - "写入工程文件失败:{msg}": "Failed to write the project file: {msg}", - "读取工程文件失败:{msg}": "Failed to read the project file: {msg}", - "工程文件不是合法 JSON:{msg}": "Project file is not valid JSON: {msg}", - # ---- 会话支撑(v1.3.5):最近打开 + 崩溃自动恢复 ---- # - "查看最近打开记录与崩溃自动恢复状态": "Show recent projects and crash-autosave status", - "最近打开({n} 条):": "Recent projects ({n}):", - " (文件已不存在)": " (file no longer exists)", - "自动保存:无": "Autosave: none", - "自动保存:{saved_at} | 源工程 {src}": "Autosave: {saved_at} | source project {src}", - "(未知时间)": "(unknown time)", - "快照:{path}": "Snapshot: {path}", - "最近打开(&R)": "Recent &projects", - "最近打开过的 .core 工程文件(最新在前,最多 10 条)。\n文件已不存在的条目会置灰,不会被悄悄藏掉。": ( - "Recently opened .core project files (newest first, up to 10).\n" - "Entries whose file no longer exists are greyed out, not hidden." - ), - "(暂无记录)": "(no entries yet)", - "清除最近打开记录": "Clear recent projects", - "已清除最近打开记录": "Recent projects cleared", - "恢复上次会话": "Restore last session", - "检测到上次会话的自动保存。\n\n工程:{source}\n保存时间:{saved}\n\n是否恢复?(不恢复也可以稍后用「打开工程」手动打开快照:\n{snapshot})": ( - "An autosaved snapshot from your last session was found.\n\n" - "Project: {source}\nSaved at: {saved}\n\n" - "Restore it? (You can also open the snapshot manually later via Open Project:\n{snapshot})" - ), - "已从自动保存恢复:{saved}": "Restored from autosave: {saved}", - # ---- 工程模板(v1.3.6):从模板新建工程 ---- # - "从模板新建工程(&N)…": "New project from &template…", - "从模板新建工程": "New project from template", - "从内置模板(抓取工作站 / 分拣工位 / 机床上下料 / 3C 装配 / 汽车焊装)新建工程:\n场景与起步程序一步到位,不必从空白开始。\n当前工程有未保存改动时会先询问。": ( - "Start a new project from a built-in template (grasping workstation / " - "sorting station / machine tending / 3C assembly / automotive welding):\n" - "scene and starter program in one step — no blank slate.\nAsks first " - "when the current project has unsaved changes." - ), - "工程模板模块不可用。": "Project template module unavailable.", - "选择一个模板作为新工程的起点:": "Pick a template as the starting point of the new project:", - "当前工程有未保存改动,新建模板工程会丢弃这些改动。\n\n是否继续?": ( - "The current project has unsaved changes; creating a template project " - "will discard them.\n\nContinue?" - ), - "已从模板新建:{name}": "New project from template: {name}", - "已从模板新建:{name}(尚未保存,可直接修改后「保存工程」)": ( - "New project from template: {name} (not saved yet — edit freely, then " "use Save Project)" - ), - "未知工程模板:{tid}(可用:{opts})": "Unknown project template: {tid} (available: {opts})", - "抓取工作站": "Grasping workstation", - "分拣工位": "Sorting station", - "机床上下料": "Machine tending", - "UR5e + 吸盘 + 输送带 + 工件 + 托盘,附 8 点抓取循环示例程序(含抓取/放置语义)": ( - "UR5e + suction cup + conveyor + workpieces + pallet, with an 8-point " - "pick-and-place sample program (pick/place semantics included)" - ), - "输送带 + 双料箱分拣 + 围栏,机器人与工件已摆好,示教程序从 Home 位起步": ( - "Conveyor + dual-bin sorting + fence; robot and workpieces laid out, " - "teach program starts from Home" - ), - "输送带 + 台钳工位 + 机床柜体 + 成品料箱,机器人与工件已摆好,示教程序从 Home 位起步": ( - "Conveyor + vise station + machine cabinet + output bin; robot and " - "workpieces laid out, teach program starts from Home" - ), - # ---- 工程模板扩容(v1.3.8):3C 装配 / 汽车焊装 ---- # - "3C 装配工位": "3C assembly workcell", - "汽车焊装工位": "Automotive welding workcell", - "上料托盘(3 块板件)+ 装配治具 + 成品托盘 + 围栏,机器人与工件已摆好,示教程序从 Home 位起步": ( - "Feed pallet (3 boards) + assembly fixture + finished pallet + fence; " - "robot and workpieces laid out, teach program starts from Home" - ), - "焊接变位机 + 输送辊道 + 控制柜 + 围栏,机器人与工件已摆好,示教程序从 Home 位起步": ( - "Welding positioner + roller conveyor + controller cabinet + fence; " - "robot and workpieces laid out, teach program starts from Home" - ), - # ---- v1.4.1 补齐:英文模式下曾漏出中文的片段(语言切换的坑) ---- # - "一键载入抓取工作站(UR5e + 吸盘 + 输送带 + 托盘),\n": ( - "Load the grasping workcell (UR5e + suction cup + conveyor + pallet) " "in one click,\n" - ), - "从内置模板(抓取工作站 / 分拣工位 / 机床上下料 / 3C 装配 / 汽车焊装)新建工程:\n": ( - "Create a project from a built-in template (grasping / sorting / machine " - "tending / 3C assembly / automotive welding):\n" - ), - "分段 {entries}/{wp_max} 条({wp} 路点) · ": ( - "Segments {entries}/{wp_max} ({wp} waypoints) · " - ), - "将从第 {line} 行起全局运行({n}/{total} 点)": ( - "Will run globally from line {line} ({n}/{total} points)" - ), - "将运行:{robot} · {n}/{total} 点": "Will run: {robot} · {n}/{total} points", - "打开 .core 工程文件:一次载入场景、示教程序(含抓取/放置语义)、": ( - "Open a .core project: loads scene, teach program (with grasp/release " "semantics), " - ), - "把当前工作会话存成一个 .core 工程文件(原子写入)。\n": ( - "Save the current session as a .core project (atomic write).\n" - ), - "抓取工作站演示已就绪:{n} 个示例示教点{cycle_txt},": ( - "Grasping workcell demo ready: {n} sample taught points{cycle_txt}, " - ), - "挂到「机器人」= 安装到法兰,工具将跟随机器人运动;\n": ( - 'Mounting to "Robot" = installing on the flange, the tool then follows ' "the robot;\n" - ), - "整程序 {entries}/{max} 份({wp} 路点) · ": ( - "Whole program {entries}/{max} entries ({wp} waypoints) · " - ), - "最近打开过的 .core 工程文件(最新在前,最多 10 条)。\n": ( - "Recently opened .core projects (newest first, up to 10).\n" - ), - "条目 {entries}/{max} · 命中 {hits} / 未命中 {misses}": ( - "Entries {entries}/{max} · hits {hits} / misses {misses}" - ), - "检测到上次会话的自动保存。\n\n": "Autosave from the last session detected.\n\n", - "没有可运行的路径点": "No runnable path points", - "示教点语义 {a} 个与解析出的 {b} 个点不一致,": ( - "{a} taught point semantics do not match the {b} parsed points, " - ), - "程序异常": "Program error", - "程序遇到未处理的异常,当前操作已停止。\n\n": ( - "The program hit an unhandled exception; the current action stopped.\n\n" - ), - "第 {line} 行:机器人“{robot}”已不在场景中,已停止运行": ( - "Line {line}: robot “{robot}” is no longer in the scene, run stopped" - ), - "记录时赋予该示教点的工艺语义。\n": ( - "Process semantics assigned to this point when recorded.\n" - ), - "连接真机控制器:部署当前程序、回读实际关节角、取回核对。\n": ( - "Connect a real controller: deploy the current program, read back actual " - "joints, verify.\n" - ), - "重新扫描完成:发现 {discovered} 个入口点,登记 {registered} 个,": ( - "Rescan finished: {discovered} entry points found, {registered} registered, " - ), - "{name} 生成失败:{exc}": "Generating {name} failed: {exc}", - "{name}:无示教点,已跳过": "{name}: no taught points, skipped", - "多机回放中…({n} 台)": "Playing back {n} robots…", - "已生成 {n} 台机器人的代码": "Generated code for {n} robots", - "所有已校验的机器人同时回放——检查多机协同是否撞车": ( - "Play back every validated robot at once — check for multi-robot conflicts" - ), - "接近点 → 抓取点 → 提升点": "approach → pick → lift", - "没有已生成轨迹的机器人——请先校验程序": ( - "No robot with a generated trajectory — validate the program first" - ), - "程序遇到未处理的异常,当前操作已停止。\n\n崩溃日志已写入:\n{p}\n\n自动保存的那份工程还在,重新打开会提示恢复。": ( - "The program hit an unhandled exception; the current action stopped.\n\n" - "Crash log written to:\n{p}\n\nThe autosaved project is still there — " - "reopening offers to restore it." - ), - # ---- 路径点与代码行的右键动作(v1.4.2) ---- # - "移动到 {robot} 的 P{n}": "Move {robot} to P{n}", - "把这一台直接走到示教点 P{n} 的关节位形": ( - "Move this robot straight to the joint pose of taught point P{n}" - ), - "已把 {robot} 移动到示教点 P{n}": "Moved {robot} to taught point P{n}", - "▶ 执行此行": "▶ Run this line", - "第 {line} 行不是可执行的 movej 指令": ("Line {line} is not an executable movej instruction"), - # ---- 命令行入口(ADR-003:core doctor / post / world) ---- # - "环境自检": "Environment self-check", - "RoboWorld 命令行:环境自检与资源查询": ( - "RoboWorld command line: environment self-check and resource queries" - ), - "可用": "OK", - "缺失": "missing", - "缺少": "missing", - "未安装": "not installed", - "界面不可用,库层不受影响": "no GUI, library layer unaffected", - "Python {ver}(要求 {req}+): {state}": "Python {ver} (requires {req}+): {state}", - "核心依赖: {state}{detail}": "Core dependencies: {state}{detail}", - "GUI 依赖: {state}{detail}": "GUI dependencies: {state}{detail}", - "构建档位: {level}(隐藏清单来自 {origin})": ( - "Build level: {level} (hidden list from {origin})" - ), - "内置默认清单": "built-in default list", - "构建档位: 读取失败({exc})": "Build level: unreadable ({exc})", - "C++ 加速层: {state}": "C++ accelerator: {state}", - "C++ 加速层: 缺失": "C++ accelerator: missing", - "模型库: {n} 个 URDF({root})": "Model library: {n} URDF(s) ({root})", - "预置工作站: {n} 个({root})": "Preset workcells: {n} ({root})", - "结论: 有 {n} 项硬缺失,请先安装依赖": ( - "Result: {n} hard failure(s) — install the dependencies first" - ), - "结论: 环境可用": "Result: environment is ready", - "后处理器清单与查询": "Post-processor listing and lookup", - "回显该后处理器的元信息": "Print that post-processor's metadata", - "列出全部后处理器": "List every post-processor", - "后处理器 {n} 个:": "Post-processors ({n}):", - "构造失败": "construction failed", - "未知后处理器: {name}({exc})": "Unknown post-processor: {name} ({exc})", - "{name}: 品牌 {brand} / 控制器 {controller} / 扩展名 .{ext}": ( - "{name}: brand {brand} / controller {controller} / extension .{ext}" - ), - "预置工作站清单与查询": "Preset workcell listing and lookup", - "载入该工作站并打印摘要": "Load that workcell and print a summary", - "列出全部预置工作站": "List every preset workcell", - "预置工作站 {n} 个:": "Preset workcells ({n}):", - "未找到预置工作站目录: {path}": "Preset workcell directory not found: {path}", - "未找到工作站: {name}": "Workcell not found: {name}", - "无": "none", - "工作站 {name}: 机器人 {r} 台({names}),对象 {o} 个": ( - "Workcell {name}: {r} robot(s) ({names}), {o} object(s)" - ), -} +# re-export:词条数据已外置至 support/i18n_catalog.py(2026-10-18,Phase 1 完成)。 +# 守护 H9(tools/check_arch_rules.py)与 tools/build_web_site.py 经本模块取词条, +# 调用面保持不变。 +from support.i18n_catalog import _CATALOG_EN # noqa: F401,E402 diff --git a/support/i18n_catalog.py b/support/i18n_catalog.py new file mode 100644 index 0000000..7d0c4dc --- /dev/null +++ b/support/i18n_catalog.py @@ -0,0 +1,1377 @@ +"""英文词条目录:support.i18n 的英文翻译数据表(纯数据,无逻辑)。 + +``_CATALOG_EN`` 的 key = 源码中的中文文案原样(zh 为源语言),value 为英文 +译文(f-string 模板用命名占位符)。行数随词条规模增长,属数据外置的既定 +形态(豁免与债务登记见 tools/check_arch_rules.py 的 I18N_HOME/LEGACY_DEBT); +外部一律经 ``support.i18n`` 取用(该模块 re-export 本表,调用面不变)。 +""" + +from __future__ import annotations + +__all__ = ["_CATALOG_EN"] + +# --------------------------------------------------------------------------- +# 英文目录(key = 源码中的中文文案原样;模板用命名占位符) +# --------------------------------------------------------------------------- + +_CATALOG_EN: dict[str, str] = { + # --- main.py:菜单 / 全局 --- + "抓取工作站演示(一键)(&D)": "Grasping workstation demo (one-click)(&D)", + "载入预置工作站(&W)...": "Load preset workcell(&W)...", + "预置工作站": "Preset workcells", + "选择工作站:": "Select workcell:", + "抓取工作站演示": "Grasping workstation demo", + "显示&机器组件库": "Show &machine library", + "插件": "Plugins", + "插件状态": "Plugin status", + "插件状态…": "Plugin status…", + "插件覆盖层": "Plugin overlays", + "重新扫描已安装插件": "Rescan installed plugins", + "重新扫描机器组件库": "Rescan machine library", + "当前环境无插件层(adapter.plugins 不可用)。": "Plugin layer unavailable (adapter.plugins is not installed).", + "未找到预置场景({cells} 下没有 .json)": "No preset scenes found (no .json under {cells})", + "已载入 {name}({n} 个对象)": "Loaded {name} ({n} objects)", + "已载入: {name}": "Loaded: {name}", + "关节数: {n}": "Axes: {n}", + "模板构建失败:{kind}: {msg}": "Template build failed: {kind}: {msg}", + "示例程序生成失败:{kind}: {msg}": "Demo program build failed: {kind}: {msg}", + "离线编程机器人初始化失败,无法生成示例程序。": "Offline-programming robot init failed; cannot build demo program.", + "所需模块不可用(模板/环境建模/离线编程/抓取工艺)。": "Required modules unavailable (templates / world modeling / OLP / grasping process).", + "抓取工作站演示已就绪:{n} 个示例示教点{cycle_txt},回放中(可暂停/单步/停止)": "Grasping workstation demo ready: {n} demo taught points{cycle_txt}, playing back (pause/step/stop available)", + ",节拍 {t} s": ", cycle {t} s", + "节拍 {t} s": "Cycle {t} s", + "RoboWorld": "RoboWorld", + "IO —": "IO —", + "节拍 —": "Cycle —", + "干涉 {n}": "Interference {n}", + "干涉 —": "Interference —", + "报错 {n}": "{n} errors", + "无报错": "No errors", + "当前没有示教点。\n请先在 3D 视口中调整关节角度并点击「记录」按钮。": "No taught points yet.\nAdjust joint angles in the 3D viewport and click Record first.", + "重新扫描失败:{exc}": "Rescan failed: {exc}", + "重新扫描完成:发现 {discovered} 个入口点,登记 {registered} 个,跳过 {skipped} 个,失败 {failed} 个。": "Rescan finished: found {discovered} entry point(s), registered {registered}, skipped {skipped}, failed {failed}.", + # 缓存可观测性(v1.2.4) + "缓存状态…": "Cache status…", + "清空缓存与统计": "Clear caches & statistics", + "缓存状态": "Cache status", + "当前环境无缓存层(service.incremental 不可用)。": "Cache layer unavailable in this environment (service.incremental missing).", + '内容寻址缓存不会给出过期结果;这里的数字解释"活少干了多少"。': "Content-addressed caches can never serve stale results; these numbers show how much work they saved.", + "已清空全部缓存并把统计归零——下次校验将全量真算。": "All caches cleared and statistics reset — the next validation will recompute everything.", + "规划与分段缓存(TrajectoryCache)": "Plan & segment cache (TrajectoryCache)", + "干涉检查结果缓存(CollisionResultCache)": "Collision-check result cache (CollisionResultCache)", + "力矩校验结果缓存(DynamicsResultCache)": "Torque-check result cache (DynamicsResultCache)", + "已关闭": "Disabled", + "整程序 {entries}/{max} 份({wp} 路点) · 命中 {hits} / 未命中 {misses}(命中率 {rate:.0%})": "Full-program {entries}/{max} snapshots ({wp} waypoints) · {hits} hits / {misses} misses ({rate:.0%} hit rate)", + "分段 {entries}/{wp_max} 条({wp} 路点) · 命中 {hits} / 未命中 {misses}(命中率 {rate:.0%})": "Per-segment {entries}/{wp_max} entries ({wp} waypoints) · {hits} hits / {misses} misses ({rate:.0%} hit rate)", + "LRU 淘汰 {n} 次": "{n} LRU eviction(s)", + "条目 {entries}/{max} · 命中 {hits} / 未命中 {misses}(命中率 {rate:.0%})· 存储 {stores} · 淘汰 {evictions}": "{entries}/{max} entries · {hits} hits / {misses} misses ({rate:.0%} hit rate) · {stores} stores · {evictions} evictions", + "本轮还没有缓存活动(计数全为 0)。": "No cache activity yet this session (all counters are zero).", + "机器人模型不可用:{urdf}": "Robot model unavailable: {urdf}", + "该型号 URDF 不可用": "URDF unavailable for this model", + "请先从「机器人库」载入一个型号。": "Load a model from the robot library first.", + "未加载机器人模型,无法确定目标品牌与后处理器。\n请先从「机器人库」载入一个型号。": "No robot model loaded; cannot determine target brand and post-processor.\nLoad a model from the robot library first.", + "请先加载机器人": "Load a robot first", + "载入失败": "Load failed", + "保存失败": "Save failed", + "关闭": "Close", + "(无)": "(none)", + "(无可调参数)": "(no adjustable parameters)", + "(该设备无关节)": "(this device has no joints)", + "⚠ 参数未核验 — ": "⚠ Parameters unverified — ", + "载入预置工作站": "Load preset workcell", + "单元模型不可用": "Cell model unavailable", + "请先在「路径示教」里 Record 至少一个示教点": "Record at least one taught point in Path Teaching first", + "\n 最近一次渲染覆盖层的问题:\n": "\n Recent plugin overlay problems:\n", + "\n\n可用后处理器:\n ": "\n\nAvailable post-processors:\n ", + "机器人:{name} 目标语言:{lang} 指令数:{n}": "Robot: {name} Target language: {lang} Motions: {n}", + "绕 X (°)": "X (°)", + "绕 Y (°)": "Y (°)", + "绕 Z (°)": "Z (°)", + "、": ", ", + "({note})": "({note})", + # --- main.py:抓取工作站演示 tooltip(多段整组) --- + "记录时赋予该示教点的工艺语义。\n抓取点:插入夹紧 IO,播放时把工件吸附到法兰;\n放置点:插入松开 IO,播放时把工件释放到世界。": "Process semantics assigned to this taught point.\nPick point: inserts grip IO; on playback the workpiece snaps to the flange;\nPlace point: inserts release IO; on playback the workpiece is left in the world.", + "挂到「机器人」= 安装到法兰,工具将跟随机器人运动;\n挂到其它对象 = 保持当前位置,仅建立随动关系。": 'Attach to "Robot" = tool install: the object follows the robot flange;\nAttach to any other object = keep current position, follow relationship only.', + "一键载入抓取工作站(UR5e + 吸盘 + 输送带 + 托盘),\n自动装上示例抓取程序并开始回放,状态栏显示节拍。": "One-click load of the grasping workstation (UR5e + gripper + conveyor + pallet),\nauto-loads the demo pick program and starts playback; the status bar shows cycle time.", + "暂停 / 继续;暂停保留当前点位,便于逐点核对": "Pause / resume; pausing keeps the current point for step-by-step checks", + "⏭ 单步": "⏭ Step", + "⏸ 暂停": "⏸ Pause", + "▶ 继续": "▶ Resume", + "走一个示教点——核对抓取/放置时机最有用的操作": "Step one taught point — the most useful way to check grasp/release timing", + "被抓取的场景对象;留空表示只演示轨迹": "Scene object to grasp; leave empty to demo trajectory only", + "工件:": "Workpiece:", + # --- main.py:右侧标签页 / 对象属性 --- + "属性": "Properties", + "场景设备库": "Scene Device Library", + "设备树": "Device Tree", + "导出打包": "Export & Package", + "对象属性": "Object properties", + "位置": "Position", + "参数": "Parameters", + "关节": "Joints", + "信息": "Info", + "日志": "Log", + "删除": "Delete", + "从场景同步": "Sync from scene", + "自定义": "Custom", + # --- 组件分类(与设备库分组同源,设备树「类型」列也用它)--- + "基础设施": "Infrastructure", + "安全设施": "Safety facilities", + "工件": "Workpiece", + "夹具": "Fixture", + "变位机": "Positioner", + "输送设备": "Conveyor", + "末端工具": "End effector", + "机器人": "Robot", + "范围 {lo} ~ {hi} {unit}": "Range {lo} ~ {hi} {unit}", + "范围 {lo}° ~ {hi}°": "Range {lo}° ~ {hi}°", + # --- main.py:代码生成 / PLC / 硬件清单 --- + "PLC 程序逻辑": "PLC program logic", + "代码预览": "Code preview", + "生成": "Generate", + "代码生成失败": "Code generation failed", + "保存为文件…": "Save to file…", + "保存路径代码": "Save path code", + "保存 {lang} 代码": "Save {lang} code", + "已保存 {lang} 代码: {name}": "Saved {lang} code: {name}", + "生成的机器人代码 — {lang}": "Generated robot code — {lang}", + "生成的 PLC 代码(西门子 SCL)将显示在这里": "Generated PLC code (Siemens SCL) will appear here", + "生成机器人代码": "Generate robot code", + "机器人子程序": "Robot subroutine", + "机器人子程序代码(需先加载机器人并示教)": "Robot subroutine code (load a robot and teach points first)", + "西门子 SCL": "Siemens SCL", + "自动(按机器人)": "Auto (by robot)", + "硬件列表": "Hardware list", + "添加设备": "Add device", + "传感器": "Sensor", + "名称": "Name", + "类型": "Type", + "厂商": "Vendor", + "型号": "Model", + "通信": "Comm", + "目标": "Target", + "品牌": "Brand", + "名称:": "Name:", + "厂商:": "Vendor:", + "型号:": "Model:", + "控制器:": "Controller:", + "类型:": "Type:", + "点位:": "Points:", + "挂载到:": "Attach to:", + "轴数:": "Axes:", + "重复定位精度:": "Repeatability:", + "额定负载:": "Rated payload:", + "最大臂展:": "Max reach:", + "未加载模型": "No model loaded", + "资源库": "Library", + "工作单元干涉检查": "Cell interference check", + "干涉检查": "Interference check", + "程序校验": "Program validation", + "程序校验与节拍估算": "Program validation & cycle-time estimate", + "路径示教": "Path teaching", + "协作臂": "Collaborative", + "工业臂": "Industrial", + "待机位姿": "Home pose", + "关节值数量 {n} 与自由度 {dof} 不符(关节: {names})": "Joint value count {n} does not match DOF {dof} (joints: {names})", + # --- program_panel.py --- + "程序概览": "Program overview", + "估算节拍": "Estimate cycle time", + "校验程序 (F7)": "Validate (F7)", + "校验结果": "Validation results", + "后处理与导出": "Post-processing & export", + "后处理器": "Post-processor", + "生成代码": "Generate code", + "一键导出…": "One-click export…", + "导出全部…": "Export all…", + "为每一台机器人各落一份独立程序文件到同一目录(UR .script / KUKA .src / ABB .mod / FANUC .tp)": ( + "Write one independent program file per robot into the same folder" + " (UR .script / KUKA .src / ABB .mod / FANUC .tp)" + ), + "已导出 {n} 台机器人的独立程序到 {dest}": ("Exported {n} independent robot programs to {dest}"), + "上传真机": "Upload to controller", + "真机验证": "Controller verification", + "回放": "Playback", + "全部回放": "Play all", + "未选中设备——已生成全部 {n} 台的代码": ( + "No device selected — generated code for all {n} robots" + ), + "停止": "Stop", + # 代码预览(路径示教)的播放条——原先 Record/Play/Stop/Clear 是英文, + # 与同一行的「暂停 / 单步」混着排(2026-09-21 界面体检) + "● 记录": "● Record", + "● 正在记录": "● Recording", + "▶ 播放": "▶ Play", + "■ 停止": "■ Stop", + "示教点:0": "Points: 0", + "示教点:{n}": "Points: {n}", + "示教点:{n}({detail})": "Points: {n} ({detail})", + "就绪": "Ready", + "—": "—", + "X (毫米)": "X (mm)", + "Y (毫米)": "Y (mm)", + "Z (毫米)": "Z (mm)", + # 菜单栏:原先 File / View 与其中若干条目是英文 + "文件(&F)": "File(&F)", + "打开模型(&O)…": "Open Model(&O)…", + "保存路径代码(&S)…": "Save Path Code(&S)…", + "载入路径代码(&L)…": "Load Path Code(&L)…", + "生成机器人代码(&G)…": "Generate Robot Code(&G)…", + "退出(&X)": "Exit(&X)", + "视图(&V)": "View(&V)", + "重置视图(&R)": "Reset View(&R)", + "语言 / Language": "Language", + "从轨迹起点开始回放。": "Play back from the trajectory start.", + "停止回放。": "Stop playback.", + "看一遍路径怎么走": "Preview how the path moves", + "轨迹回放中…": "Playing back trajectory…", + "TCP 路径": "TCP path", + "路径点": "Waypoints", + "运动类型": "Motion type", + "进给速度": "Feed speed", + "直线插补": "Linear interpolation", + "自动": "Auto", + "自动(按机器人品牌)": "Auto (by robot brand)", + "未加载机器人": "No robot loaded", + "还没校验": "Not validated yet", + "通过": "Pass", + "不通过": "Failed", + "结果": "Result", + "错误": "Errors", + "警示": "Warnings", + "节拍 (s)": "Cycle (s)", + "没程序(未示教)": "No program (nothing taught yet)", + "看这台": "Show this one", + "校验全部机器人": "Validate all robots", + "还没有登记任何机器人——请先载入机器人型号。": "No robot registered yet — load a robot model first.", + "当前版本不支持批量校验。": "This version does not support batch validation.", + "每条指令都失败": "Every motion failed", + "{n} 处错误": "{n} error(s)", + "{w} 个 / {m} 条指令": "{w} points / {m} motions", + "{name} · 就绪": "{name} · Ready", + "校验完成:{res}": "Validation finished: {res}", + "校验异常:{kind}: {msg}": "Validation error: {kind}: {msg}", + "{name} 校验异常:{kind}: {msg}": "Validation error on {name}: {kind}: {msg}", + "校验全部": "Validate all", + "逐台校验所有机器人并汇总结果——不必一台一台切过去按 F7": "Validate every robot and summarize — no need to switch one by one and press F7", + "校验全部:{res}": "Validate all: {res}", + "{n} 台通过": "{n} passed", + "{n} 台有问题": "{n} have problems", + "{n} 台没程序": "{n} have no program", + "没有可校验的机器人": "No robot to validate", + "{name} 还没有校验结果": "{name} has no validation result yet", + "这台": "This robot", + "抓取向导": "Grasp wizard", + "抓取向导…": "Grasp wizard…", + "由抓取点/放置点自动生成接近、提升、撤离点——不必逐个手动示教": "Generate approach / lift / retract points from the pick and place poses — no need to teach them one by one", + "当前版本不支持抓取向导。": "This version does not support the grasp wizard.", + "请先记录至少一个示教点——把机器人摆到要抓的位置,点「记录」": "Record at least one taught point first — jog the robot to the grasp pose and press Record", + "还没有载入机器人,无法计算抓取点位": "No robot loaded — cannot compute grasp points", + "还没有示教点——请先把机器人摆到抓取位并记录一个点": "No taught point yet — jog to the grasp pose and record one", + "抓取点下标 {i} 超出示教点范围(共 {n} 个)": "Pick index {i} is out of range ({n} taught points)", + "放置点下标 {i} 超出示教点范围(共 {n} 个)": "Place index {i} is out of range ({n} taught points)", + "接近点": "Approach", + "抓取点": "Pick", + "提升点": "Lift", + "放置点": "Place", + "撤离点": "Retract", + "{label}(抬高 {dz:.0f} mm)逆解失败:{msg}": "Inverse kinematics failed for {label} (raised {dz:.0f} mm): {msg}", + "抓取向导已生成 {n} 个点({seq})": "Grasp wizard generated {n} points ({seq})", + "P{n}": "P{n}", + "(不生成放置侧)": "(no place side)", + "接近高度(抓取点上方)": "Approach height (above pick)", + "提升高度(抓取点上方)": "Lift height (above pick)", + "撤离高度(放置点上方)": "Retract height (above place)", + "替换原有示教点(默认追加到末尾)": "Replace existing taught points (default: append)", + "将生成 {n} 个点:{seq}": "Will generate {n} points: {seq}", + "点位算不出来,示教点未改动:\n{msg}": "Could not compute the points; taught points are unchanged:\n{msg}", + "取消": "Cancel", + "没有示教点可供校验——请先记录若干关节位形": "No taught points to validate — record some joint poses first", + "请先加载机器人并示教至少一个点": "Load a robot and teach at least one point first", + "请先校验程序以生成轨迹,再回放": "Validate the program to generate a trajectory before playback", + "请先载入机器人再添加路点": "Load a robot before adding waypoints", + "请先载入机器人型号,再校验程序": "Load a robot model before validating", + "请先在列表中选中要删除的路点": "Select a waypoint in the list to delete", + "请先在列表中选中要改运动类型的路点": "Select a waypoint in the list to change its motion type", + "请先在列表中选中要编辑的路点": "Select a waypoint in the list to edit", + "插入": "Insert", + "上移": "Move up", + "下移": "Move down", + "编辑位姿…": "Edit pose…", + "编辑路点 {n}": "Edit waypoint {n}", + "设为 moveJ": "Set as moveJ", + "设为 moveL": "Set as moveL", + "moveJ(关节插值)": "moveJ (joint interpolation)", + "moveL(直线插值)": "moveL (linear interpolation)", + "moveJ(关节)": "moveJ (joint)", + "moveL(直线)": "moveL (linear)", + "轨迹编辑": "Trajectory editing", + "操作": "Actions", + "状态": "Status", + "选择导出目录": "Select export directory", + "导入 {n} 个示教点": "Imported {n} taught points", + "导出失败: {exc}": "Export failed: {exc}", + "已导出:{handle}{note}": "Deployed: {handle}{note}", + "已生成 {choice} 代码,共 {lines} 行": "Generated {choice} code, {lines} lines", + "生成失败: {exc}": "Generation failed: {exc}", + "生成失败:\n{exc}": "Generation failed:\n{exc}", + "生成的控制器代码将显示在这里": "Generated controller code will appear here", + "已插入路点 {n},请重新校验": "Inserted waypoint {n}; re-validate", + "已删除路点 {n},请重新校验": "Deleted waypoint {n}; re-validate", + "P{a} 已移到 P{b},请重新校验": "P{a} moved to P{b}; re-validate", + "请先在列表中选中要移动的路点": "Select a waypoint in the list first", + "{n} 已更新,请重新校验": "{n} updated; re-validate", + "{n} 已设为 {label},请重新校验": "{n} set to {label}; re-validate", + "修改选中路点的关节角(度)与运动类型(双击同效)": "Edit the selected waypoint's joint angles (deg) and motion type (double-click works too)", + "在选中点之后插入一个路点(复制其关节值)": "Insert a waypoint after the selection (copies its joint values)", + "一步构建程序并按品牌导出(UR .script / KUKA .src / ABB .mod / FANUC .tp)": "Build the program and export by brand in one step (UR .script / KUKA .src / ABB .mod / FANUC .tp)", + "选中点改为关节运动(走平滑关节插值,不保证直线)": "Switch selection to joint motion (smooth joint interpolation; not a straight line)", + "选中点改为直线运动(TCP 沿直线,走 IK 插补)": "Switch selection to linear motion (TCP follows a straight line via IK interpolation)", + "该校验警告涉及此路点(见校验结果)": "A validation warning concerns this waypoint (see validation results)", + "该校验错误涉及此路点(见校验结果)": "A validation error concerns this waypoint (see validation results)", + "改动后需重新校验:着色与节拍会随校验结果刷新(红=错误 / 橙=警告 / 绿=正常)": "Re-validate after edits: coloring and cycle time refresh with validation results (red=error / orange=warning / green=ok)", + "预留:接入 ControllerLink 后支持直连控制器上传": "Reserved: direct controller upload once ControllerLink is connected", + "预留:接入 ControllerLink.read_joints 后做真机回读比对": "Reserved: read-back comparison once ControllerLink.read_joints is available", + # --- world/panel.py(场景面板) --- + "场景对象": "Scene objects", + "对象": "Object", + "场景为空": "Scene is empty", + "0 个对象": "0 objects", + "{n} 个对象": "{n} objects", + "▢ 机器人": "▢ Robot", + "保存场景": "Save scene", + "保存场景…": "Save scene…", + "加载场景": "Load scene", + "加载场景…": "Load scene…", + "加载失败": "Load failed", + "场景文件 (*.json)": "Scene files (*.json)", + "场景已保存: {name}": "Scene saved: {name}", + "场景已加载: {name}({n} 个对象)": "Scene loaded: {name} ({n} objects)", + "场景已清空": "Scene cleared", + "已删除对象": "Object deleted", + "已添加 {name}": "Added {name}", + "添加失败: {exc}": "Add failed: {exc}", + "清空": "Clear", + "清空场景": "Clear scene", + "确定要移除所有场景对象吗?(机器人不受影响)": "Remove all scene objects? (robots are not affected)", + "工艺时序(泳道图)": "Process timeline (swimlanes)", + "时间 →": "Time →", + "未检查": "Not checked", + "检查干涉 (F6)": "Check interference (F6)", + "✓ 无干涉": "✓ No interference", + "✗ {n} 处干涉:{shown}{more}": "✗ {n} interference(s): {shown}{more}", + "发现 {n} 处干涉": "Found {n} interference(s)", + "干涉检查通过:无干涉": "Interference check passed: no interference", + "检查失败: {exc}": "Check failed: {exc}", + "机器人加载失败: {exc}": "Robot load failed: {exc}", + " …等共 {n} 处": " …{n} in total", + "拖入对象即可生成泳道,按时间轴编排工艺步骤,后续将驱动“工艺 → 机器动作”的自动映射。": 'Drop objects here to create swimlanes and sequence process steps on a timeline; automatic "process → machine action" mapping is planned.', + # --- robot_library/panel.py(机器人库面板) --- + "全部": "All", + "全部品牌": "All brands", + "全部机型": "All models", + "全部臂展": "All reaches", + "全部负载": "All payloads", + "刷新": "Refresh", + "组件": "Component", + "参数未核验": "Parameters unverified", + "固定几何": "Fixed geometry", + "规格未知": "Specs unknown", + "载": "Load", + "选": "Select", + "搜索型号 / 品牌 / 组件…": "Search model / brand / component…", + "拖拽到右侧视口加入场景 · 双击载入": "Drag into the viewport to add to the scene · double-click to load", + "重新扫描 assets/models/ 目录": "Rescan assets/models/ directory", + "重新扫描模型目录并重建列表。": "Rescan the model directory and rebuild the list.", + "机器组件库:共 {n} 个型号 / 组件已并入": "Machine library: {n} models / components merged in", + "{n} 项": "{n} items", + "{name} — 双击载入": "{name} — double-click to load", + "{name} 的 URDF 不可用": "URDF unavailable for {name}", + "{n} 轴": "{n} axes", + # --- 语言切换(main.py 菜单与对话框) --- + # 「语言 / Language」的译文见上(中文菜单里保留双语名,英文模式只留 Language) + "切换语言需要重启应用才能完全生效。现在重启吗?": "Restart the app to apply the language change fully. Restart now?", + "立即重启": "Restart now", + "稍后": "Later", + # --- v1.1 库层消息:planner 校验 / 报告 --- + "程序级": "Program-level", + "指令 {n}": "Motion {n}", + "轴 {axis} 低于下限位:{value:.3f} < {limit:.3f}": "Axis {axis} below lower limit: {value:.3f} < {limit:.3f}", + "轴 {axis} 超出上限位:{value:.3f} > {limit:.3f}": "Axis {axis} above upper limit: {value:.3f} > {limit:.3f}", + "✓ 程序校验通过": "✓ Program validation passed", + "✗ 程序校验未通过({n} 处错误)": "✗ Program validation failed ({n} error(s))", + " 指令 {motions} 条 / 路径点 {waypoints} 个 / TCP 路径 {length:.1f} mm": " {motions} motions / {waypoints} waypoints / TCP path {length:.1f} mm", + " 估算节拍 {time:.2f} s": " Estimated cycle time {time:.2f} s", + " 驻留合计 {total:.2f} s({n} 段 waitTime,已计入节拍)": " Dwell total {total:.2f} s ({n} waitTime segment(s), included in cycle time)", + " 节拍明细(按指令):": " Cycle breakdown (by motion):", + " 力矩校验(峰值 / 额定):": " Torque check (peak / rated):", + " 机械功率峰值 {power:.1f} W{avg}": " Peak mechanical power {power:.1f} W{avg}", + "(平均 {power:.1f} W)": " (avg {power:.1f} W)", + " 警告 {n} 处:": " {n} warning(s):", + " 错误 {n} 处:": " {n} error(s):", + "圆弧的起点、中间点、终点(近)共线,无法确定圆弧;请检查 via 点是否落在起终点的连线上": "Arc start, via and end points are (nearly) collinear; cannot define an arc — check that the via point is not on the start-end line", + "未知速度曲线风格 {profile};可用:{available}": "Unknown velocity profile {profile}; available: {available}", + "未知的指令类型:{type}": "Unknown command type: {type}", + "轴 {axis} 峰值速度 {speed:.1f} 超出限位 {limit:.1f}({ratio:.0f}%),该段路径弯折较急,建议放缓速度或增加过渡点": "Axis {axis} peak speed {speed:.1f} exceeds its limit {limit:.1f} ({ratio:.0f}%); this segment bends sharply — slow down or add transition points", + "未传入工作单元场景,跳过干涉检查": "No workcell scene provided; collision check skipped", + "干涉:{detail}": "Collision: {detail}", + "force_mode_end() 没有对应的进入力控指令——真机上 end_force_mode/FCDeact 会直接报错": "force_mode_end() has no matching force-mode start — end_force_mode/FCDeact will fault on a real controller", + "程序结束时仍处于力控状态(进入力控 N 次但未退出)——确认这是有意为之,否则请补 force_mode_end()": "Program ends while force mode is still active — confirm this is intentional, otherwise add force_mode_end()", + "力矩校验跳过:机器人没有可用的运动学模型": "Torque check skipped: no kinematics model available for the robot", + "力矩校验跳过:主链连杆缺少惯性参数({links})——可从 CAD 导出带 的 URDF,或用 DynamicsModel.set_link_inertial 手动录入": "Torque check skipped: main-chain links lack inertial parameters ({links}) — export a URDF with from CAD, or set them via DynamicsModel.set_link_inertial", + "关节 {joint} 峰值力矩 {torque:.1f} N·m 超出额定 {limit:.1f} N·m({ratio:.0f}%)——该段加减速或姿态对关节负载过重,建议放缓速度、增加过渡点或减小负载": "Joint {joint} peak torque {torque:.1f} N·m exceeds its rating {limit:.1f} N·m ({ratio:.0f}%) — this segment overloads the joint; slow down, add transition points or reduce the payload", + "关节 {joint} 峰值力矩 {torque:.1f} N·m 达到额定 {limit:.1f} N·m 的{ratio:.0f}%,余量偏小,建议留意": "Joint {joint} peak torque {torque:.1f} N·m reaches {ratio:.0f}% of its rating {limit:.1f} N·m — small margin, worth watching", + "该段 {progress:.0f}% 处逆解失败——目标可能超出工作空间、位于奇异位形附近,或与相邻点之间要求过大的姿态变化({exc})": "IK failed at {progress:.0f}% of the segment — the target may be out of the workspace, near a singularity, or demand too much reorientation between adjacent points ({exc})", + "moveJ 目标关节数 {n} 与机器人自由度 {dof} 不符": "moveJ target has {n} joints but the robot has {dof} DOF", + "moveJ 目标{violation}": "moveJ target {violation}", + "moveSpline 目标{violation}": "moveSpline target {violation}", + "target 既不是关节列表也不是位姿:{exc}": "target is neither a joint list nor a pose: {exc}", + "目标关节数 {n} 与自由度 {dof} 不符": "target has {n} joints but the robot has {dof} DOF", + "moveC 缺少 via(中间点)": "moveC is missing a via (intermediate) point", + "该段 {progress:.0f}% 处{violation}——直线/圆弧插补会把关节推出限位,需调整路径或工件位置": "{violation} at {progress:.0f}% of the segment — linear/circular interpolation would push joints out of limits; adjust the path or workpiece position", + "该段 {progress:.0f}% 处轴 {axis} 跳变 {step:.1f}°(> {limit}°),可能穿越奇异位形发生翻腕": "Axis {axis} jumps {step:.1f}° at {progress:.0f}% of the segment (> {limit}°) — possible wrist flip through a singularity", + "接近奇异位形(雅可比条件数 {cond:.2e}),关节速度可能突变,建议调整姿态或路径": "Near a singular configuration (Jacobian condition number {cond:.2e}); joint speeds may jump — adjust the pose or path", + # --- v1.1 库层消息:后处理告警 / 生成代码注释 --- + "[warning] {name} 尚未适配 IO 指令,将输出通用伪语法;下装前必须人工核对": "[warning] {name} does not adapt IO commands yet; generic pseudo-syntax will be emitted — verify manually before download", + "{name}: 第 {index} 条指令转换失败: {exc}": "{name}: failed to convert command {index}: {exc}", + "{name}: 模板渲染失败: {exc}": "{name}: template rendering failed: {exc}", + "{name} 尚未实现原生 SPLINE 指令;默认已将其降级为 PTP/LIN(目的地一致)": "{name} has no native SPLINE support; it has been downgraded to PTP/LIN (same destination)", + "[warning] {name}: 信号 {signal} 未映射到物理地址,已输出 TODO 占位(下装前必须补全)": "[warning] {name}: signal {signal} is not mapped to a physical address; a TODO placeholder was emitted (must be resolved before download)", + "TODO: 原语义为 {trigger} 边沿触发,需条件监控实现,当前按电平输出,请人工核对": "TODO: original semantics is {trigger}-edge triggered, which requires a condition monitor; currently emitted as a level check — verify manually", + "起弧前保护气预流 {seconds:.1f}s": "Gas pre-flow {seconds:.1f} s before arc start", + "熄弧后保护气滞流 {seconds:.1f}s": "Gas post-flow {seconds:.1f} s after arc end", + "TODO: 该品牌未适配原生力控指令(指令名随选件/版本而变),请按上述参数对照品牌手册人工实现": "TODO: this brand has no adapted native force-control command (the name varies by option/version); implement manually per the brand manual using the parameters above", + "{name}: 进入力控的 ForceMode 指令缺少 ComplianceSpec": "{name}: the ForceMode command entering force control lacks a ComplianceSpec", + "{name}: 不支持的指令类型: {type}": "{name}: unsupported command type: {type}", + "无法识别的目标表示,长度 {size}(期望 3/6/16)": "Unrecognized target representation, size {size} (expected 3/6/16)", + "不支持的欧拉角顺序: {order}": "Unsupported Euler order: {order}", + "不支持的线位移单位: {unit}": "Unsupported linear unit: {unit}", + "不支持的角度单位: {unit}": "Unsupported angle unit: {unit}", + # --- v1.1 库层消息:post 工厂 / 品牌后处理器 --- + "插件后处理器 {name} 未返回 BasePostProcessor 子类实例,而是 {cls}": "Plugin post-processor {name} did not return a BasePostProcessor instance but {cls}", + "品牌 {name} 有多个后处理器 {matches},请指定完整名称": "Brand {name} has multiple post-processors {matches}; specify the full name", + "未知的后处理器 {name};可用:{available}": "Unknown post-processor {name}; available: {available}", + "! TODO: 信号 {signal} 地址 {addr} 无法解析为数字量序号,需人工改为 DO[n]={level}": "! TODO: signal {signal} address {addr} cannot be resolved to a numeric index; change manually to DO[n]={level}", + "! TODO: 信号 {signal} 地址 {addr} 无法解析为数字量序号,需人工改为 WAIT DI[n]": "! TODO: signal {signal} address {addr} cannot be resolved to a numeric index; change manually to WAIT DI[n]", + "TODO: 原语义为 {trigger} 边沿触发,请人工核对": "TODO: original semantics is {trigger}-edge triggered — verify manually", + "TODO: 超时 {seconds:.2f}s 需用 SKIP 条件实现": "TODO: timeout {seconds:.2f} s requires the SKIP condition — implement manually", + "送丝 {wire:.1f}m/min": "Wire feed {wire:.1f} m/min", + "起弧前预流 {seconds:.1f}s": "gas pre-flow {seconds:.1f} s before arc start", + "FANUC TP 不支持的运动类型: {type}": "FANUC TP does not support motion type: {type}", + "FANUC TP 圆弧运动需要提供 via 中间点": "FANUC TP circular motion requires a via point", + "; TODO: 信号 {signal} 地址 {addr} 无法解析为数字量序号,需人工改为 $OUT[n] = {level}": "; TODO: signal {signal} address {addr} cannot be resolved to a numeric index; change manually to $OUT[n] = {level}", + "; TODO: 信号 {signal} 地址 {addr} 无法解析为数字量序号,需人工改为 WAIT FOR $IN[n]": "; TODO: signal {signal} address {addr} cannot be resolved to a numeric index; change manually to WAIT FOR $IN[n]", + "TODO: 超时 {seconds:.2f}s 需用 $TIMER 自行实现": "TODO: timeout {seconds:.2f} s requires $TIMER — implement manually", + "KRL 不支持的运动类型: {type}": "KRL does not support motion type: {type}", + "KRL 圆弧运动需要提供 via 中间点": "KRL circular motion requires a via point", + "# TODO: 信号 {signal} 地址 {addr} 无法解析为数字量序号,需人工改为 set_digital_out(n, {level})": "# TODO: signal {signal} address {addr} cannot be resolved to a numeric index; change manually to set_digital_out(n, {level})", + "# TODO: 信号 {signal} 地址 {addr} 无法解析为数字量序号,需人工改为 wait_digital_in(n)": "# TODO: signal {signal} address {addr} cannot be resolved to a numeric index; change manually to wait_digital_in(n)", + "TODO: 超时 {seconds:.2f}s 需自行实现看门狗": "TODO: timeout {seconds:.2f} s requires a watchdog — implement manually", + "TODO: 原语义为 {trigger} 边沿触发,需轮询实现,当前按电平输出,请人工核对": "TODO: original semantics is {trigger}-edge triggered, which requires polling; currently emitted as a level check — verify manually", + "URScript 不支持的运动类型: {type}": "URScript does not support motion type: {type}", + "URScript 圆弧运动需要提供 via 中间点": "URScript circular motion requires a via point", + "TODO: 原语义为 {trigger} 边沿触发,需 WaitUntil 边沿逻辑实现,当前按电平输出,请人工核对": "TODO: original semantics is {trigger}-edge triggered, which requires WaitUntil edge logic; currently emitted as a level check — verify manually", + "TODO: ABB Force Control 为 RobotWare 选件,FCRefForce/FCAct 的实参语法请对照《Application manual - Force Control》核对": "TODO: ABB Force Control is a RobotWare option; verify the FCRefForce/FCAct argument syntax against the Application manual - Force Control", + "RAPID 不支持的运动类型: {type}": "RAPID does not support motion type: {type}", + "RAPID 圆弧运动需要提供 via 中间点": "RAPID circular motion requires a via point", + "ESTUN 不支持的运动类型: {type}": "ESTUN does not support motion type: {type}", + "ESTUN 圆弧运动需要提供 via 中间点": "ESTUN circular motion requires a via point", + "JAKA 不支持的运动类型: {type}": "JAKA does not support motion type: {type}", + "JAKA 圆弧运动需要提供 via 中间点": "JAKA circular motion requires a via point", + "InoTeach 不支持的运动类型: {type}": "InoTeach does not support motion type: {type}", + "InoTeach 圆弧运动需要提供 via 中间点": "InoTeach circular motion requires a via point", + "# NOTE: 语法未经真实控制器验证,使用前请核对": "# NOTE: syntax not verified on a real controller; check before use", + # ---- 真机连接面板(v1.1.1)---- + "真机连接": "Real Controller", + "真机连接…": "Real Controller…", + "连接": "Connect", + "断开": "Disconnect", + "主机": "Host", + "端口": "Port", + "超时": "Timeout", + "未连接": "Not connected", + "已连接": "Connected", + "程序部署": "Program Deployment", + "部署当前程序": "Deploy Current Program", + "刷新列表": "Refresh List", + "回读": "Feedback", + "回读关节": "Read Joints", + "关节角 —": "Joints —", + "未知": "unknown", + "正在连接 {host}:{port} …": "Connecting to {host}:{port} …", + "连接失败:{exc}": "Connection failed: {exc}", + "服务端 {server} · 协议 v{api} · 关节数 {dof} · 能力 {caps}": "Server {server} · protocol v{api} · dof {dof} · caps {caps}", + "已连接:{desc}": "Connected: {desc}", + "已连接 {name}": "Connected to {name}", + "已断开 {name}": "Disconnected from {name}", + "未连接控制器——请先建立连接": "Controller not connected — connect first", + "未设置程序来源——请从离线编程面板打开本窗口": "No program source set — open this panel from the main window", + "没有可部署的程序——请先加载机器人并示教至少一个点": "Nothing to deploy — load a robot and teach at least one point first", + "部署失败:{exc}": "Deployment failed: {exc}", + "已部署 {name}:{handle}{note}": "Deployed {name}: {handle}{note}", + "已部署 {name}:{handle}": "Deployed {name}: {handle}", + "获取程序列表失败:{exc}": "Failed to list controller programs: {exc}", + "控制器程序({n})": "Controller programs ({n})", + "控制器上还没有程序": "No programs on the controller yet", + "回读失败:{exc}": "Read failed: {exc}", + "控制器不支持关节回读(能力协商 feedback=false)——不伪造数据": "Controller does not support joint feedback (negotiated caps.feedback=false) — no fabricated data", + "控制器不支持回读": "Controller does not support feedback", + "关节角(度):{vals}": "Joints (deg): {vals}", + "回读关节(度):{vals}": "Read joints (deg): {vals}", + "回读关节应用失败:{exc}": "Failed to apply read joints: {exc}", + "关节数不符(真机 {n},模型 {m})": "Joint count mismatch (controller {n}, model {m})", + "已按真机位形摆好视口": "Viewport set to the controller's actual pose", + "真机连接模块不可用。": "Real controller module unavailable.", + "连接真机控制器:部署当前程序、回读实际关节角、取回核对。\n传输可选 Socket(core-socket 行协议)或 SSH(SFTP 传程序 + exec)。\n无运动指令入口、无自由命令输入框——程序运动一律走部署。": ( + "Connect to a real controller: deploy the current program, read back actual joints,\n" + "fetch and verify. Transport: Socket (core-socket line protocol) or SSH\n" + "(SFTP upload + exec). No motion-command entry and no free-form command box —\n" + "motion goes through deployment only." + ), + # ---- 真机连接面板:SSH 传输与取回核对(v1.1.4)---- + "传输": "Transport", + "Socket(core-socket 行协议)": "Socket (core-socket line protocol)", + "SSH(SFTP 传程序 + exec)": "SSH (SFTP upload + exec)", + "用户名": "Username", + "密码": "Password", + "与私钥二选一": "or use a private key", + "私钥": "Private key", + "私钥文件路径,可留空": "Private key file path (optional)", + "远端目录": "Remote directory", + "取回核对": "Fetch & Verify", + "把控制器上的程序原文取回,与本次生成的代码逐字节比对——\n「部署报成功」与「控制器上确实是这份代码」是两件事": ( + "Fetch the program text back from the controller and compare it byte for byte with the\n" + 'code just generated — "deploy reported success" and "the controller really holds\n' + 'this code" are two different things' + ), + "正在连接 {transport} {host}:{port} …": "Connecting to {transport} {host}:{port} …", + "SSH 形态无回读通道": "SSH transport has no feedback channel", + "SSH 形态没有关节回读通道——回读请用 Socket;不伪造数据": ( + "SSH transport has no joint feedback channel — use Socket for feedback; no fabricated data" + ), + "SSH 无能力协商 · 远端目录 {dir} · 不支持关节回读": ( + "SSH: no capability negotiation · remote dir {dir} · joint feedback unsupported" + ), + "没有可核对的程序——请先部署一次": "Nothing to verify — deploy once first", + "取回失败:{exc}": "Fetch failed: {exc}", + "控制器上没有 {name}——部署可能没落盘": ( + "{name} not found on the controller — the deployment may not have landed" + ), + "控制器上没有 {name}": "{name} not found on the controller", + "核对 {name}:与本次生成的代码逐字节一致({lines} 行)": ( + "Verified {name}: byte-identical to the code just generated ({lines} lines)" + ), + "核对 {name}:**与本次生成的代码不一致**(控制器 {lines} 行 / 本地 {local} 行)——先别下线": ( + "Verified {name}: **DIFFERS from the code just generated** " + "(controller {lines} lines / local {local} lines) — do not go offline yet" + ), + "核对 {name}:已取回 {lines} 行(本次会话不是由本面板部署的它,只报了行数,不冒充逐字节结论)": ( + "Fetched {name}: {lines} lines (not deployed by this panel in this session — " + "line count only, no byte-level claim)" + ), + "核对 {name}:{lines} 行": "Verified {name}: {lines} lines", + # ---- link.describe()(真机连接状态行,随界面语言)---- + "服务端": "server", + "能力": "caps", + "请求": "requests", + "目录": "dir", + "已传": "uploaded", + "执行": "exec", + # ---- Program.deploy 备注(按连接形态如实描述回读能力)---- + "支持实际位置回读": "actual position feedback supported", + "离线导出,无实际位置回读": "offline export, no actual position feedback", + "{protocol} 连接,控制器不提供实际位置回读": ( + "{protocol} connection; the controller provides no actual position feedback" + ), + # ---- 轨迹编辑器圆弧编辑(v1.1.5)---- + # 口径:第 N 行的运动类型描述"到达第 N 点"的运动,moveC 以上一点为中间点 + "设为 moveC": "Set as moveC", + "moveC(圆弧)": "moveC (circular)", + "moveC(圆弧,以上一点为中间点)": "moveC (circular; previous point is the via)", + "moveC 中间点": "moveC via", + "可设为 moveC(圆弧)": "Can be set to moveC (circular)", + "不可设为 moveC:{reason}": "Cannot be set to moveC: {reason}", + "路点不存在": "No such waypoint", + "P{n} 缺少两个前驱点——圆弧需要起点与中间点,P1/P2 不能设为 moveC": ( + "P{n} has fewer than two predecessors — an arc needs a start and a via, " + "so P1/P2 cannot be moveC" + ), + "P{n} 已是圆弧终点,不能再作为本段的中间点": ( + "P{n} is already an arc endpoint and cannot serve as this segment's via" + ), + "P{n} 已把 P{k} 当中间点——本行不能同时作为圆弧终点": ( + "P{n} already uses P{k} as its via — this row cannot also be an arc endpoint" + ), + "P{n} 是抓取/放置点,不能作为圆弧中间点(中间点不停机)": ( + "P{n} is a pick/place point and cannot be an arc via (a via does not stop)" + ), + "{reason}——已按关节运动(moveJ)处理": "{reason} — handled as a joint move (moveJ)", + "P{n} 的圆弧正解失败——退化为关节运动(moveJ);中间点 P{k} 改为单独经过": ( + "Forward kinematics failed for the arc at P{n} — degraded to joint moves (moveJ); " + "via point P{k} is now passed through separately" + ), + "P{n} 的圆弧无法加入程序——退化为关节运动(moveJ);中间点 P{k} 改为单独经过": ( + "The arc at P{n} could not be added to the program — degraded to joint moves (moveJ); " + "via point P{k} is now passed through separately" + ), + ";{n} 项运动降级(见日志)": "; {n} motion(s) degraded (see log)", + "⚠ {note}": "⚠ {note}", + "✓ 程序校验通过,未发现问题": "✓ Program validated, no issues found", + "选中点改为圆弧运动:以上一点为中间点、再上一点为起点画圆弧到达本点。\n" + "中间点不再单独出指令(它只是一段圆弧的途经点),因此 P1/P2 不能设为圆弧,\n" + "相邻两行也不能同为圆弧,抓取/放置点不能当中间点。": ( + "Turn the selected point into a circular motion: the previous point is the via and " + "the one before it the start, then arc into this point.\n" + "The via no longer emits its own instruction (it is merely passed through), so P1/P2 " + "cannot be arcs, two adjacent rows cannot both be arcs, and pick/place points cannot " + "be vias." + ), + "改动后需重新校验:着色与节拍会随校验结果刷新(红=错误 / 橙=警告 / 绿=正常)\n" + "圆弧口径:第 N 行的运动类型描述「到达第 N 点」的运动——moveC 以上一点为中间点、" + "再上一点为起点;中间点行显示为「moveC 中间点」": ( + "Re-validate after edits: colours and cycle times refresh with the validation result " + "(red = error / orange = warning / green = OK)\n" + "Arc convention: a row's motion type describes the motion *arriving at* that point — " + "moveC uses the previous point as via and the one before it as start; via rows are " + 'labelled "moveC via"' + ), + # ---- 补漏:v1.0.10 GUI 接线时遗漏的条目(en 模式不再残留中文)---- + "保持": "Hold", + "夹紧": "Close", + "松开": "Open", + "(场景根)": "(Scene root)", + "场景根": "Scene root", + "拖拽节点改写挂载层级:挂到机器人=安装到法兰,自动对齐坐标系": ( + "Drag a node to re-parent it: dropping onto a robot mounts it on the " + "flange and auto-aligns the coordinate frame" + ), + "{name} 是机器人,不能被挂载": "{name} is a robot and cannot be mounted", + "{name} 是机器人,不能挂到 {host} 的法兰上": ( + "{name} is a robot and cannot be mounted on {host}'s flange" + ), + "挂载失败: {exc}": "Mounting failed: {exc}", + "已将 {name} 挂到 {host}": "Mounted {name} onto {host}", + "已将 {name} 装夹到 {host} 末端": "Mounted {name} onto {host}'s flange", + "装夹到末端": "Mount on flange", + "解除装夹": "Unmount", + "吸附到机器人法兰原点,工具坐标系与法兰对齐并随机器人运动": ( + "Snap to the robot flange origin: the tool frame aligns with the flange " + "and follows the robot" + ), + "解除挂载并保持当前世界位姿": "Unmount and keep the current world pose", + "按场景里的机器人/组件补齐设备清单(已存在的保留不动)": ( + "Fill the device list from the scene's robots/components " + "(existing entries are kept as-is)" + ), + "环境建模模块不可用。": "The environment modelling module is unavailable.", + "离线编程模块不可用。": "The offline programming module is unavailable.", + # ---- PLC ↔ 机器人 IO 点表(v1.1.6)---- + # 方向标签是词表数据(DIRECTION_LABELS),只在显示处经 tr() 翻译 + "PLC→机器人": "PLC→robot", + "机器人→PLC": "robot→PLC", + "传感器→PLC": "sensor→PLC", + # 地址解析 + "地址为空": "Address is empty", + "地址里有空白字符": "Address contains whitespace", + "地址里有逗号——一行只能写一个地址": "Address contains a comma — one address per row", + "地址里没有任何数字": "Address contains no digits", + "点表需要工作单元配置(CellConfig)": ( + "The point table requires a cell configuration (CellConfig)" + ), + # 点表行 + "机器人读写": "robot reads and writes", + "机器人写": "robot writes", + "机器人读": "robot reads", + "机器人未用": "not used by the robot", + "未声明": "undeclared", + "信号": "Signal", + "地址": "Address", + "方向": "Direction", + "用途": "Usage", + "归属": "Owner", + "点表 {total} 点:已映射 {mapped} / 未映射 {unmapped},{errors} 处错误 {warnings} 处警告": ( + "Point table: {total} point(s), {mapped} mapped / {unmapped} unmapped, " + "{errors} error(s), {warnings} warning(s)" + ), + " ✓ 点表未发现问题": " ✓ No point-table issues found", + # 校验 + "机器人程序在用 {usage},但没有任何设备给出物理地址——下装前必须补上": ( + "The robot program uses it ({usage}) but no device provides a physical address — " + "must be filled in before download" + ), + "点表里有它但没有地址(下装前补上,或从点表里删掉)": ( + "Listed in the point table but has no address " + "(fill it in before download, or remove it from the table)" + ), + "{addr} 已被 {other} 占用——两个信号不能共用一个物理地址": ( + "{addr} is already taken by {other} — two signals must not share one physical address" + ), + "{devices} 都声明了这个信号——归属必须唯一": ( + "{devices} all declare this signal — the owner must be unique" + ), + "{addr!r} 不像一个 IO 地址:{why}": "{addr!r} does not look like an IO address: {why}", + "机器人(SetDO)与 PLC 都在驱动它——必须明确主控方,否则谁后写谁生效": ( + "Both the robot (SetDO) and the PLC drive it — pick one owner, " + "otherwise the last writer wins" + ), + "机器人在等它,但机器人自己不写、PLC 也没驱动它——现场会卡在这一步": ( + "The robot waits on it, but the robot does not write it and the PLC does not drive it — " + "the cell will stall here" + ), + # GUI(IO 映射对话框 + 硬件列表按钮 + 状态栏) + "IO 映射": "IO map", + "IO 映射…": "IO map…", + "IO 映射 · {name}": "IO map · {name}", + "IO 校验": "Check IO", + "IO 点表校验": "IO point table check", + "编辑当前设备的信号地址(点表与 PLC 出码共用这一份数据)": ( + "Edit the selected device's signal addresses " + "(the point table and the PLC code share this one source)" + ), + "检查未映射 / 地址重复 / 两侧同时驱动 / 机器人等一个没人给的信号": ( + "Check for unmapped signals / duplicate addresses / both sides driving / " + "the robot waiting on a signal nobody gives" + ), + "请先在设备列表里选中一行": "Select a row in the device list first", + "地址(可编辑)": "Address (editable)", + "添加信号": "Add signal", + "信号名(如 gripper_close)": "Signal name (e.g. gripper_close)", + "自动编号": "Auto-number", + "按方向自动编号,跳过已被占用的地址": ( + "Number by direction, skipping addresses that are already taken" + ), + "自动编号:{n} 个信号(跳过已占用地址)": ( + "Auto-numbered {n} signal(s), skipping taken addresses" + ), + "IO 映射已更新:{name}({n} 个信号)": "IO map updated: {name} ({n} signal(s))", + "有问题的点必须在下载前处理(双击「Details」看逐点清单)": ( + 'Problem points must be fixed before download (double-click "Details" for the list)' + ), + "警告不阻塞下载,但现场必须有人看过": ( + "Warnings do not block download, but someone on site must have looked at them" + ), + "PLC 代码已生成,但点表有 {n} 处错误——下装前必须处理": ( + "PLC code generated, but the point table has {n} error(s) — fix before download" + ), + # ---- 场景拖拽摆放(v1.1.7)---- + "地面": "ground", + "{name} → ({x:.1f}, {y:.1f}, {z:.1f}) mm · 落在 {support} 上": ( + "{name} → ({x:.1f}, {y:.1f}, {z:.1f}) mm · placed on {support}" + ), + "{name} 位置未变": "{name} not moved", + "场景里没有 {name}": "{name} is not in the scene", + "{name} 是机器人——拖拽摆放只用于工件与设备": ( + "{name} is a robot — drag-and-place is for parts and equipment only" + ), + "{name} 不能拖动:对象已锁定": "{name} cannot be dragged: the object is locked", + "{name} 不能拖动:已挂载在 {host} 上,请先解除挂载": ( + "{name} cannot be dragged: mounted on {host}; unmount it first" + ), + # ---- 规模与性能基准(v1.1.8)---- + "RoboWorld 规划/校验规模基准": "RoboWorld planning / validation scale benchmark", + "机器人 URDF 路径": "robot URDF path", + "单次基准的运动条数": "number of motions in the single-run benchmark", + "规模表用的运动条数(逗号分隔,如 20,50,100)": ( + "motion counts for the scale table (comma separated, e.g. 20,50,100)" + ), + "每段重复次数(取最优)": "repetitions per phase (best-of)", + "输出 JSON": "emit JSON", + "找不到机器人模型;用 --robot 指定 URDF 路径": ( + "robot model not found; pass a URDF path with --robot" + ), + "规模与性能基准 · {robot}": "Scale and performance benchmark · {robot}", + " 指令 {commands} 条(其中运动 {motions} 条)→ 路点 {wps} 个": ( + " {commands} command(s) ({motions} motion(s)) → {wps} waypoint(s)" + ), + " 仿真节拍 {cycle:.2f} s · 路径长 {length:.1f} mm": ( + " simulated cycle time {cycle:.2f} s · TCP travel {length:.1f} mm" + ), + " 校验问题:{errors} 错误 / {warnings} 警告": ( + " validation: {errors} error(s) / {warnings} warning(s)" + ), + " 阶段耗时({repeats} 次取最优)": " phase timings (best of {repeats})", + " 单路点规划 {ms:.3f} ms · 整轮墙钟 {wall:.2f} s": ( + " {ms:.3f} ms per waypoint · {wall:.2f} s wall clock for the whole run" + ), + " (未接场景,干涉检查跳过)": " (no scene attached — interference check skipped)", + " 关节原点旋转:实算 {computed} 次 / 命中缓存 {hits} 次": ( + " Joint-origin rotations: {computed} computed / {hits} cache hit(s)" + ), + "规划": "planning", + "校验(含内部重新规划)": "validation (re-plans internally)", + "{per:.3f} ms/路点": "{per:.3f} ms/waypoint", + "{n} 处接触": "{n} contact(s)", + "{n} 条问题": "{n} issue(s)", + "未接场景,跳过": "no scene, skipped", + "运动条数 路点数 节拍(s) 规划(ms) 校验(ms) 干涉(ms) ms/路点": ( + "motions waypoints cycle(s) plan(ms) validate(ms) collision(ms) ms/wp" + ), + "规模表 · {robot}({repeats} 次取最优)": "Scale table · {robot} (best of {repeats})", + # ---- 干涉检查分段基准(v1.1.9)---- + "附加干涉检查分段基准(接仓库内置抓取工作站场景)": ( + "also benchmark interference checking (with the built-in grasping workstation)" + ), + "干涉基准的关节采样步长(度,逗号分隔)": ( + "joint-space sampling steps for the interference benchmark (degrees, comma separated)" + ), + "{n} 处接触 · 采样 {k} 次(每 {m} 路点 1 次)": ( + "{n} contact(s) · {k} sample(s) (1 per {m} waypoints)" + ), + "干涉检查分段基准 · {robot}": "Interference-check breakdown · {robot}", + " 指令 {commands} 条 → 路点 {wps} 个 · 干涉裕度 {margin:.1f} mm": ( + " {commands} command(s) → {wps} waypoint(s) · interference margin {margin:.1f} mm" + ), + " 机器人零件 {parts} 个 · 环境对象 {objs} 个(零件 {eparts} 个)": ( + " robot parts {parts} · environment objects {objs} (parts {eparts})" + ), + "采样口径": "sampling policy", + "采样数": "samples", + "每采样路点": "waypoints/sample", + "总耗时": "total", + "每次采样": "per sample", + "接触": "contacts", + "窄相对": "narrow pairs", + "广相剔除": "broad-phase rejects", + "默认": "default", + "默认(约 {n} 次)": "default (about {n} samples)", + "关节步长 {deg:g}°": "joint step {deg:g}°", + " 口径说明:「{default}」按总采样数约 300 的预算取样,程序越长采样点隔得越远;" + "给定关节步长的口径按「机器人可能移动多远」取样,不随程序变长而稀释。": ( + " Sampling policy: the 「{default}」 policy spends a budget of ~300 samples, " + "so the gap between two samples grows with program length; a joint-step policy " + "samples by how far the robot can actually move and does not thin out as the " + "program grows." + ), + "max_joint_step 必须是非负有限值,收到 {value}——它是采样判据(度),负值没有意义": ( + "max_joint_step must be a finite non-negative number, got {value} — it is a " + "sampling criterion in degrees, a negative value is meaningless" + ), + # ---- 几何内核等价性对拍(v1.1.9)---- + "几何内核等价性对拍(生产实现 vs 朴素参考实现)": ( + "Collision kernel equivalence check (production vs naive reference)" + ), + " {pairs} 对基本体 × {cases} 组位姿/裕度 · 裕度 {margins}": ( + " {pairs} primitive pair(s) × {cases} pose/margin case(s) · margins {margins}" + ), + " 碰撞判定差异 {n} 处 · 穿透深度完全相同 {exact}/{pairs} 次 · 最大数值差 {delta:.3e}": ( + " collision verdict differences: {n} · penetration depth identical in " + "{exact}/{pairs} case(s) · worst numeric delta {delta:.3e}" + ), + " AABB {a} · 顶点 {v} · SAT 轴 {s} 差异(均应为 0)": ( + " AABB {a} · vertices {v} · SAT axes {s} difference(s) (all should be 0)" + ), + " 同一批判定:生产 {fast:.3f} s · 朴素 {slow:.3f} s → {x:.1f}× 更快": ( + " Same batch of verdicts: production {fast:.3f} s · naive {slow:.3f} s " + "→ {x:.1f}× faster" + ), + # ---- 增量重算分场景基准(v1.2.0)---- + "增量重算分场景基准 · {robot}": "Incremental replan per-scenario benchmark · {robot}", + " 指令 {commands} 条(运动 {motions} 条)→ 路点 {wps} 个 · 冷启动 {base:.1f} ms": ( + " {commands} command(s) ({motions} motion(s)) → {wps} waypoint(s) · " + "cold start {base:.1f} ms" + ), + "场景": "Scenario", + "耗时": "Time", + "加速": "Speedup", + "分段复用": "Seg reuse", + "整程序": "Whole", + "等价": "Equiv", + "命中": "hit", + "差 {d:.1e}": "diff {d:.1e}", + " 分段复用率的分母是「指令数」而不是「路点数」——一条指令命中,意味着它那几十个路点全部免算": ( + " The segment reuse rate is over commands, not waypoints — one command hit " + "spares all of its dozens of waypoints" + ), + " 等价列=与关缓存的实算结果逐位比对(关节/位姿/时刻/速度),任何一项不为 0 都会在这里显示出来": ( + " The Equiv column compares bit-for-bit against a cache-disabled run " + "(joints/pose/time/velocity); any non-zero difference shows up here" + ), + "无改动": "No edit", + "改末尾运动": "Edit last motion", + "改中段运动": "Edit middle motion", + "改首条运动": "Edit first motion", + "只改速度": "Speed only", + "插入驻留": "Insert dwell", + "删除一条": "Delete one", + "加跑增量重算分场景基准(moveJ 程序)": ( + "Also run the incremental replan benchmark (moveJ program)" + ), + "增量基准再加跑 moveL(位姿目标)程序——两者复用率差别是结构性的": ( + "Also benchmark a moveL (pose-target) program — the reuse gap between the " + "two is structural" + ), + # --- 工艺时序(v1.3.1):序列模型 / 校验 / 双出口 / 设备树编辑器 --- + "添加工步": "Add step", + "校验时序": "Validate sequence", + "保存时序…": "Save sequence…", + "载入时序…": "Load sequence…", + "保存工艺时序": "Save process sequence", + "载入工艺时序": "Load process sequence", + "未校验": "Not validated", + "还没有工步——点「添加工步」把工序排进泳道": ( + 'No steps yet — click "Add step" to lay operations into the swimlanes' + ), + "步骤序号 →(驻留按秒加宽;非实测节拍)": ( + "Step # → (dwell widened by seconds; not a measured cycle)" + ), + "序号": "#", + "泳道": "Lane", + "内容": "Content", + "泳道(设备):": "Lane (device):", + "工步名:": "Step name:", + "子程序名:": "Subprogram name:", + "信号:": "Signal:", + "动作:": "Action:", + "置位(勾选=True,不勾=False)": "Set (checked = True, unchecked = False)", + "触发方式:": "Trigger:", + "电平": "Level", + "上升沿": "rising edge", + "下降沿": "falling edge", + "期望电平为 True": "Expected level is True", + "期望电平:": "Expected level:", + "超时 (s):": "Timeout (s):", + "0=无限等待。现场必须配看门狗,否则一步卡死整条线": ( + "0 = wait forever. A watchdog is mandatory on site, otherwise one step " + "stalls the whole line" + ), + "驻留 (s):": "Dwell (s):", + "添加工步:子程序调用": "Add step: subprogram call", + "添加工步:触发信号": "Add step: set signal", + "添加工步:等待信号": "Add step: wait for signal", + "添加工步:驻留": "Add step: dwell", + "子程序调用": "Subprogram call", + "触发信号": "Set signal", + "等待信号": "Wait for signal", + "调用": "Call", + "触发": "Set", + "等待": "Wait", + "驻留": "Dwell", + "工步 {n}": "Step {n}", + "已添加工步 {name}": "Added step {name}", + "先在步骤表里选中一行": "Select a row in the step table first", + "已经到头了": "Already at the end", + "信号名不能为空": "Signal name cannot be empty", + "子程序名不能为空": "Subprogram name cannot be empty", + "校验通过:{n} 个工步,驻留合计 {s}s": ("Validation passed: {n} steps, dwell total {s}s"), + "{mark} {e} 处错误 / {w} 处警告": "{mark} {e} errors / {w} warnings", + "(其余 {n} 条见日志)": "({n} more in the log)", + "时序已保存:{path}": "Sequence saved: {path}", + "载入失败: {exc}": "Load failed: {exc}", + "{n} 个工步 | 触发 {signal} | 等待 {wait} | 调用 {call} | 驻留合计 {dwell}s": ( + "{n} steps | set {signal} | wait {wait} | call {call} | dwell total {dwell}s" + ), + "调用 {sub}": "Call {sub}", + "{signal} = {value}": "{signal} = {value}", + "等 {signal} = {value}": "Wait {signal} = {value}", + "等 {signal}({trg})": "Wait {signal} ({trg})", + "等待 {signal}": "Wait {signal}", + "驻留 {s}s": "Dwell {s}s", + "调用 {sub} 前置输出": "Outputs before calling {sub}", + "末步输出": "Final outputs", + "驻留合计是唯一可计算的时间;触发与等待的耗时取决于现场机构": ( + "Dwell total is the only computable time; trigger and wait durations " + "depend on the on-site mechanism" + ), + "时序为空——设备树只编排了静态挂载,还没有任何工序": ( + "Sequence is empty — the device tree only has static mounts, no operations yet" + ), + "工步 {i}({name})": "Step {i} ({name})", + "等待触发方式必须是 {opts} 之一,收到 {got}": ("Wait trigger must be one of {opts}, got {got}"), + "驻留时长不能为负,收到 {s}s": "Dwell duration cannot be negative, got {s}s", + "不支持的时序 schema:{schema}": "Unsupported sequence schema: {schema}", + "未知工步类型:{kind}": "Unknown step kind: {kind}", + "(缺失)": "(missing)", + "{where} 与工步 {j} 重名": "{where} has the same name as step {j}", + "{where} 的泳道 {device} 不在场景设备里": ( + "{where} uses lane {device}, which is not a device in the scene" + ), + "{where} 触发的信号 {signal} 不在点表里——下装前必须补上映射": ( + "{where} sets signal {signal}, which is not in the point table — map it " + "before downloading" + ), + "{where} 等待的信号 {signal} 不在点表里——下装前必须补上映射": ( + "{where} waits on signal {signal}, which is not in the point table — map " + "it before downloading" + ), + "{where} 无限等待 {signal}——现场必须配看门狗,否则一步卡死整条线": ( + "{where} waits on {signal} forever — a watchdog is mandatory on site, " + "otherwise one step stalls the whole line" + ), + "{where} 是{trg}触发,期望电平不参与判定": ( + "{where} triggers on {trg}, so the expected level is not used in the check" + ), + "{where} 调用的子程序 {sub} 不存在": ("{where} calls subprogram {sub}, which does not exist"), + "工步 {name}(调用 {sub})不是程序指令,未写入——调用语法由品牌主程序层生成": ( + "Step {name} (calls {sub}) is not a program instruction and was not " + "emitted — call syntax is generated by the brand main-program layer" + ), + # ---- 零件库(v1.3.3) ---- + "未知基本体类型 {kind}(支持 {opts})": ("Unknown primitive kind {kind} (supported: {opts})"), + "box 基本体需要 size=(长, 宽, 高),零件 {name}": ( + "A box primitive needs size=(L, W, H); part {name}" + ), + "box 尺寸必须为正,收到 {size}": ("Box dimensions must be positive, got {size}"), + "{kind} 基本体需要正的 radius,零件 {name}": ( + "A {kind} primitive needs a positive radius; part {name}" + ), + "cylinder 基本体需要正的 height,零件 {name}": ( + "A cylinder primitive needs a positive height; part {name}" + ), + "颜色分量必须在 0~1 之间,收到 {c}": ("Color components must be within 0..1, got {c}"), + "零件 id 必须是字母开头的短标识符(字母/数字/_/-),收到 {id}": ( + "Part id must be a short identifier starting with a letter " + "(letters/digits/_/-), got {id}" + ), + "零件缺少显示名({id})": "Part is missing a display name ({id})", + "零件 {name} 没有任何几何基本体": ("Part {name} has no geometry primitives"), + "零件 {name} 里有重名的基本体:{dupes}": ("Part {name} has duplicate primitive names: {dupes}"), + "不支持的零件库 schema:{schema}(期望 {want})": ( + "Unsupported parts-library schema: {schema} (expected {want})" + ), + "零件库中已存在同名零件 id:{id}": ("A part with id {id} already exists in the library"), + "零件库中没有 id 为 {id} 的零件": ("No part with id {id} in the library"), + "组件目录里已存在 {cid}——先 unregister 或换一个零件 id": ( + "{cid} already exists in the component catalog — unregister first " + "or pick another part id" + ), + "自定义零件(来自零件库导入,几何固定)": ( + "Custom part (imported from a parts library; fixed geometry)" + ), + "导入零件…": "Import Parts…", + "导入零件库 JSON(core.parts/1,参数化基本体的自定义工件/结构件)": ( + "Import a parts-library JSON (core.parts/1; custom workpiece / " + "structural parts made of parametric primitives)" + ), + "组件库不可用,无法导入零件": ("Component catalog unavailable; cannot import parts"), + "导入零件库": "Import Parts Library", + "零件库 JSON (*.json);;所有文件 (*)": "Parts Library JSON (*.json);;All Files (*)", + "导入零件失败:{kind}: {msg}": "Part import failed: {kind}: {msg}", + "零件库已导入:{n} 个自定义零件(「自定义」分类下,可拖入视口)": ( + "Parts library imported: {n} custom parts (under the Custom " + "category; drag into the viewport)" + ), + # ---- v1.3.4 工程文件(`.core`)与界面接线 ---- # + " •未保存": " •unsaved", + "未命名工程": "Untitled project", + "保存工程": "Save Project", + "打开工程": "Open Project", + "工程文件模块不可用。": "Project file support is unavailable.", + "应用时的问题:": "Issues while applying:", + "打开工程(&O)…": "&Open Project…", + ( + "打开 .core 工程文件:一次载入场景、示教程序(含抓取/放置语义)、工艺时序、设备点表与零件库来源。\n" + "缺段/坏段只跳过那一段并在报告里说明——不会因为一段格式问题打不开整个工程。" + ): ( + "Open a .core project: loads the scene, the teach program (including the pick/place semantics that only live in the project file), the process sequence, the device IO table and the parts-library sources in one go.\n" + "A missing or broken section only skips that section and is reported — a single bad section never blocks the whole project." + ), + "保存工程(&S)…": "&Save Project…", + ( + "把当前工作会话存成一个 .core 工程文件(原子写入)。\n" + "本库不认识的段与字段会原样保留,往返一趟不丢内容。" + ): ( + "Save the current work session as a single .core project file (atomic write).\n" + "Sections and fields this library does not understand are preserved verbatim, so a round trip never loses content." + ), + "场景段已跳过:环境建模不可用": "Scene section skipped: world modeling unavailable", + "示教程序段已跳过:路径编辑器不可用": "Teach program section skipped: path editor unavailable", + "工艺时序段已跳过:时序编辑器不可用": "Process sequence section skipped: sequence editor unavailable", + "设备点表段已跳过:工作单元模块不可用": "Device IO table section skipped: workcell module unavailable", + "保存工程失败": "Save Project Failed", + "工程已保存:{name}({n} 段)": "Project saved: {name} ({n} sections)", + "打开工程失败": "Open Project Failed", + "已打开 {name}": "Opened {name}", + "工程已打开:{name}": "Project opened: {name}", + "零件库来源已跳过:机器组件库不可用": "Parts sources skipped: robot library unavailable", + "场景段载入失败:{msg}": "Scene section failed to load: {msg}", + "示教点语义 {a} 个与解析出的 {b} 个点不一致,已按默认语义重建(不猜哪个是抓取点)": ( + "{a} point semantics do not match the {b} points parsed from the code; " + "rebuilt with default semantics (we do not guess which point is the pick)" + ), + "工艺时序段载入失败:{msg}": "Process sequence section failed to load: {msg}", + "设备点表段载入失败:{msg}": "Device IO table section failed to load: {msg}", + "零件库文件找不到:{src}(未导入;工程里仍记录着它)": ( + "Parts-library file not found: {src} (not imported; the project still " "records it)" + ), + "已从工程恢复零件库:{n} 个自定义零件": "Parts library restored from the project: {n} custom parts", + "场景里的机器人模型找不到:{urdf}(场景仍已载入)": ( + "Robot model referenced by the scene not found: {urdf} (the scene itself " + "was still loaded)" + ), + "零件库导入失败:{src}({msg})": "Parts library import failed: {src} ({msg})", + "示教点语义含未知值({msg}),已按默认语义重建": ( + "Point semantics contain an unknown value ({msg}); rebuilt with default " "semantics" + ), + "RoboWorld 工程 (*.rwproj *.robotproj *.core);;所有文件 (*)": ( + "RoboWorld project (*.rwproj *.robotproj *.core);;All Files (*)" + ), + "示教程序": "Teach program", + "各机示教程序": "Per-robot teach programs", + "各机示教程序段必须是对象": "Per-robot teach programs section must be an object", + "各机示教程序段:{name} 的内容必须是对象": "Per-robot teach programs: {name} must be an object", + "各机示教程序段:{name} 的 positions 必须是数组": "Per-robot teach programs: positions of {name} must be an array", + "多机示教点段已跳过:离线编程模块不可用": "Per-robot teach points skipped: offline programming module unavailable", + "工艺时序": "Process sequence", + "设备点表": "Device IO table", + "零件库来源": "Parts sources", + "标定集": "Calibration set", + "未知段": "Unknown section", + "(未指定)": "(not specified)", + "(内容为空)": "(empty)", + "{title}:未知段": "{title}: unknown section", + "场景段必须是对象": "The scene section must be an object", + "场景段的 objects 必须是数组": "The scene section's objects must be an array", + "示教程序段必须是对象": "The teach program section must be an object", + "示教程序段缺 code": "The teach program section is missing code", + "示教程序段的 code 必须是字符串": "The teach program section's code must be a string", + "示教程序段的 point_types 必须是数组": "The teach program section's point_types must be an array", + "工艺时序段必须是对象": "The process sequence section must be an object", + "工艺时序段的 steps 必须是数组": "The process sequence section's steps must be an array", + "设备点表段必须是对象": "The device IO table section must be an object", + "设备点表段缺 devices": "The device IO table section is missing devices", + "设备点表段的 devices 必须是数组": "The device IO table section's devices must be an array", + "零件库来源段必须是对象": "The parts sources section must be an object", + "零件库来源段缺 sources": "The parts sources section is missing sources", + "零件库来源段的 sources 必须是字符串数组": "The parts sources section's sources must be an array of strings", + "标定集段必须是对象": "The calibration section must be an object", + "(未命名工程)": "(untitled project)", + "查看 .core 工程文件的内容摘要(不加载场景/机器人)": "Print a content summary of a .core project file (no scene/robot loading)", + "工程文件路径": "path to the project file", + "以 JSON 输出摘要": "print the summary as JSON", + "工程里没有任何可识别的段": "No recognizable section in this project", + "{title}:{n} 个对象 | {r} 台机器人({name})": "{title}: {n} object(s) | {r} robot(s) ({name})", + "{title}:{n} 行 | {p} 个示教点(抓取 {k} / 放置 {v})": "{title}: {n} line(s) | {p} taught point(s) (pick {k} / place {v})", + "{title}:{r} 台机器人 | 合计 {p} 个示教点(抓取 {k} / 放置 {v})": "{title}: {r} robot(s) | {p} taught point(s) in total (pick {k} / place {v})", + "{title}:{n} 个工步 | 驻留合计 {d}s": "{title}: {n} step(s) | dwell total {d}s", + "{title}:{n} 台设备 | {s} 个已映射信号": "{title}: {n} device(s) | {s} mapped signal(s)", + "{title}:{n} 个零件库文件": "{title}: {n} parts-library file(s)", + "{title}:{detail}": "{title}: {detail}", + "设备点表里有一台设备缺 name": "A device in the IO table has no name", + " (空工程:没有任何段)": " (empty project: no sections)", + "工程文件的内容必须是一个对象": "A project file's content must be an object", + "工程文件的 sections 必须是一个对象": "A project file's sections must be an object", + "保存路径为空": "Save path is empty", + "{title}:格式异常": "{title}: malformed", + "标定集 schema 不支持:{schema}": "Unsupported calibration schema: {schema}", + "{name}:原样保留(本版本不解释)": "{name}: preserved verbatim (not interpreted here)", + "工程:{name}": "Project: {name}", + "保存版本:core {v} | 最后修改 {t}": "Saved with: core {v} | last modified {t}", + "已载入 {n} 段:{names}": "Loaded {n} section(s): {names}", + "跳过 {n} 段(内容原样保留,保存时写回):": ( + "Skipped {n} section(s) (content preserved verbatim and written back on " "save):" + ), + "未知段 {names}——本版本不解释,原样保留": ( + "Unknown section(s) {names} — not interpreted by this version, preserved " "verbatim" + ), + "未知顶层键 {keys}——原样保留": "Unknown top-level key(s) {keys} — preserved verbatim", + "未知工程段:{name}(可用:{opts})": "Unknown project section: {name} (available: {opts})", + "备注:{note}": "Note: {note}", + "工程里没有:{names}": "Not in this project: {names}", + "不是 RoboWorld 工程文件(schema={got},期望 {want})": "Not a RoboWorld project file (schema={got}, expected {want})", + "工程由 core {saved} 保存,当前库版本 {cur}——按现行 schema 读取,未做版本迁移": ( + "Saved by core {saved}; current library version is {cur} — read with the " + "current schema, no version migration was performed" + ), + "打不开:{msg}": "Cannot open: {msg}", + " · {name}:{reason}": " · {name}: {reason}", + "工程段 {name} 无法序列化:{msg}": "Project section {name} cannot be serialized: {msg}", + "工程无法序列化:{msg}": "Project cannot be serialized: {msg}", + "写入工程文件失败:{msg}": "Failed to write the project file: {msg}", + "读取工程文件失败:{msg}": "Failed to read the project file: {msg}", + "工程文件不是合法 JSON:{msg}": "Project file is not valid JSON: {msg}", + # ---- 会话支撑(v1.3.5):最近打开 + 崩溃自动恢复 ---- # + "查看最近打开记录与崩溃自动恢复状态": "Show recent projects and crash-autosave status", + "最近打开({n} 条):": "Recent projects ({n}):", + " (文件已不存在)": " (file no longer exists)", + "自动保存:无": "Autosave: none", + "自动保存:{saved_at} | 源工程 {src}": "Autosave: {saved_at} | source project {src}", + "(未知时间)": "(unknown time)", + "快照:{path}": "Snapshot: {path}", + "最近打开(&R)": "Recent &projects", + "最近打开过的 .core 工程文件(最新在前,最多 10 条)。\n文件已不存在的条目会置灰,不会被悄悄藏掉。": ( + "Recently opened .core project files (newest first, up to 10).\n" + "Entries whose file no longer exists are greyed out, not hidden." + ), + "(暂无记录)": "(no entries yet)", + "清除最近打开记录": "Clear recent projects", + "已清除最近打开记录": "Recent projects cleared", + "恢复上次会话": "Restore last session", + "检测到上次会话的自动保存。\n\n工程:{source}\n保存时间:{saved}\n\n是否恢复?(不恢复也可以稍后用「打开工程」手动打开快照:\n{snapshot})": ( + "An autosaved snapshot from your last session was found.\n\n" + "Project: {source}\nSaved at: {saved}\n\n" + "Restore it? (You can also open the snapshot manually later via Open Project:\n{snapshot})" + ), + "已从自动保存恢复:{saved}": "Restored from autosave: {saved}", + # ---- 工程模板(v1.3.6):从模板新建工程 ---- # + "从模板新建工程(&N)…": "New project from &template…", + "从模板新建工程": "New project from template", + "从内置模板(抓取工作站 / 分拣工位 / 机床上下料 / 3C 装配 / 汽车焊装)新建工程:\n场景与起步程序一步到位,不必从空白开始。\n当前工程有未保存改动时会先询问。": ( + "Start a new project from a built-in template (grasping workstation / " + "sorting station / machine tending / 3C assembly / automotive welding):\n" + "scene and starter program in one step — no blank slate.\nAsks first " + "when the current project has unsaved changes." + ), + "工程模板模块不可用。": "Project template module unavailable.", + "选择一个模板作为新工程的起点:": "Pick a template as the starting point of the new project:", + "当前工程有未保存改动,新建模板工程会丢弃这些改动。\n\n是否继续?": ( + "The current project has unsaved changes; creating a template project " + "will discard them.\n\nContinue?" + ), + "已从模板新建:{name}": "New project from template: {name}", + "已从模板新建:{name}(尚未保存,可直接修改后「保存工程」)": ( + "New project from template: {name} (not saved yet — edit freely, then " "use Save Project)" + ), + "未知工程模板:{tid}(可用:{opts})": "Unknown project template: {tid} (available: {opts})", + "抓取工作站": "Grasping workstation", + "分拣工位": "Sorting station", + "机床上下料": "Machine tending", + "UR5e + 吸盘 + 输送带 + 工件 + 托盘,附 8 点抓取循环示例程序(含抓取/放置语义)": ( + "UR5e + suction cup + conveyor + workpieces + pallet, with an 8-point " + "pick-and-place sample program (pick/place semantics included)" + ), + "输送带 + 双料箱分拣 + 围栏,机器人与工件已摆好,示教程序从 Home 位起步": ( + "Conveyor + dual-bin sorting + fence; robot and workpieces laid out, " + "teach program starts from Home" + ), + "输送带 + 台钳工位 + 机床柜体 + 成品料箱,机器人与工件已摆好,示教程序从 Home 位起步": ( + "Conveyor + vise station + machine cabinet + output bin; robot and " + "workpieces laid out, teach program starts from Home" + ), + # ---- 工程模板扩容(v1.3.8):3C 装配 / 汽车焊装 ---- # + "3C 装配工位": "3C assembly workcell", + "汽车焊装工位": "Automotive welding workcell", + "上料托盘(3 块板件)+ 装配治具 + 成品托盘 + 围栏,机器人与工件已摆好,示教程序从 Home 位起步": ( + "Feed pallet (3 boards) + assembly fixture + finished pallet + fence; " + "robot and workpieces laid out, teach program starts from Home" + ), + "焊接变位机 + 输送辊道 + 控制柜 + 围栏,机器人与工件已摆好,示教程序从 Home 位起步": ( + "Welding positioner + roller conveyor + controller cabinet + fence; " + "robot and workpieces laid out, teach program starts from Home" + ), + # ---- v1.4.1 补齐:英文模式下曾漏出中文的片段(语言切换的坑) ---- # + "一键载入抓取工作站(UR5e + 吸盘 + 输送带 + 托盘),\n": ( + "Load the grasping workcell (UR5e + suction cup + conveyor + pallet) " "in one click,\n" + ), + "从内置模板(抓取工作站 / 分拣工位 / 机床上下料 / 3C 装配 / 汽车焊装)新建工程:\n": ( + "Create a project from a built-in template (grasping / sorting / machine " + "tending / 3C assembly / automotive welding):\n" + ), + "分段 {entries}/{wp_max} 条({wp} 路点) · ": ( + "Segments {entries}/{wp_max} ({wp} waypoints) · " + ), + "将从第 {line} 行起全局运行({n}/{total} 点)": ( + "Will run globally from line {line} ({n}/{total} points)" + ), + "将运行:{robot} · {n}/{total} 点": "Will run: {robot} · {n}/{total} points", + "打开 .core 工程文件:一次载入场景、示教程序(含抓取/放置语义)、": ( + "Open a .core project: loads scene, teach program (with grasp/release " "semantics), " + ), + "把当前工作会话存成一个 .core 工程文件(原子写入)。\n": ( + "Save the current session as a .core project (atomic write).\n" + ), + "抓取工作站演示已就绪:{n} 个示例示教点{cycle_txt},": ( + "Grasping workcell demo ready: {n} sample taught points{cycle_txt}, " + ), + "挂到「机器人」= 安装到法兰,工具将跟随机器人运动;\n": ( + 'Mounting to "Robot" = installing on the flange, the tool then follows ' "the robot;\n" + ), + "整程序 {entries}/{max} 份({wp} 路点) · ": ( + "Whole program {entries}/{max} entries ({wp} waypoints) · " + ), + "最近打开过的 .core 工程文件(最新在前,最多 10 条)。\n": ( + "Recently opened .core projects (newest first, up to 10).\n" + ), + "条目 {entries}/{max} · 命中 {hits} / 未命中 {misses}": ( + "Entries {entries}/{max} · hits {hits} / misses {misses}" + ), + "检测到上次会话的自动保存。\n\n": "Autosave from the last session detected.\n\n", + "没有可运行的路径点": "No runnable path points", + "示教点语义 {a} 个与解析出的 {b} 个点不一致,": ( + "{a} taught point semantics do not match the {b} parsed points, " + ), + "程序异常": "Program error", + "程序遇到未处理的异常,当前操作已停止。\n\n": ( + "The program hit an unhandled exception; the current action stopped.\n\n" + ), + "第 {line} 行:机器人“{robot}”已不在场景中,已停止运行": ( + "Line {line}: robot “{robot}” is no longer in the scene, run stopped" + ), + "记录时赋予该示教点的工艺语义。\n": ( + "Process semantics assigned to this point when recorded.\n" + ), + "连接真机控制器:部署当前程序、回读实际关节角、取回核对。\n": ( + "Connect a real controller: deploy the current program, read back actual " + "joints, verify.\n" + ), + "重新扫描完成:发现 {discovered} 个入口点,登记 {registered} 个,": ( + "Rescan finished: {discovered} entry points found, {registered} registered, " + ), + "{name} 生成失败:{exc}": "Generating {name} failed: {exc}", + "{name}:无示教点,已跳过": "{name}: no taught points, skipped", + "多机回放中…({n} 台)": "Playing back {n} robots…", + "已生成 {n} 台机器人的代码": "Generated code for {n} robots", + "所有已校验的机器人同时回放——检查多机协同是否撞车": ( + "Play back every validated robot at once — check for multi-robot conflicts" + ), + "接近点 → 抓取点 → 提升点": "approach → pick → lift", + "没有已生成轨迹的机器人——请先校验程序": ( + "No robot with a generated trajectory — validate the program first" + ), + "程序遇到未处理的异常,当前操作已停止。\n\n崩溃日志已写入:\n{p}\n\n自动保存的那份工程还在,重新打开会提示恢复。": ( + "The program hit an unhandled exception; the current action stopped.\n\n" + "Crash log written to:\n{p}\n\nThe autosaved project is still there — " + "reopening offers to restore it." + ), + # ---- 路径点与代码行的右键动作(v1.4.2) ---- # + "移动到 {robot} 的 P{n}": "Move {robot} to P{n}", + "把这一台直接走到示教点 P{n} 的关节位形": ( + "Move this robot straight to the joint pose of taught point P{n}" + ), + "已把 {robot} 移动到示教点 P{n}": "Moved {robot} to taught point P{n}", + "▶ 执行此行": "▶ Run this line", + "第 {line} 行不是可执行的 movej 指令": ("Line {line} is not an executable movej instruction"), + # ---- 命令行入口(ADR-003:core doctor / post / world) ---- # + "环境自检": "Environment self-check", + "RoboWorld 命令行:环境自检与资源查询": ( + "RoboWorld command line: environment self-check and resource queries" + ), + "可用": "OK", + "缺失": "missing", + "缺少": "missing", + "未安装": "not installed", + "界面不可用,库层不受影响": "no GUI, library layer unaffected", + "Python {ver}(要求 {req}+): {state}": "Python {ver} (requires {req}+): {state}", + "核心依赖: {state}{detail}": "Core dependencies: {state}{detail}", + "GUI 依赖: {state}{detail}": "GUI dependencies: {state}{detail}", + "构建档位: {level}(隐藏清单来自 {origin})": ( + "Build level: {level} (hidden list from {origin})" + ), + "内置默认清单": "built-in default list", + "构建档位: 读取失败({exc})": "Build level: unreadable ({exc})", + "C++ 加速层: {state}": "C++ accelerator: {state}", + "C++ 加速层: 缺失": "C++ accelerator: missing", + "模型库: {n} 个 URDF({root})": "Model library: {n} URDF(s) ({root})", + "预置工作站: {n} 个({root})": "Preset workcells: {n} ({root})", + "结论: 有 {n} 项硬缺失,请先安装依赖": ( + "Result: {n} hard failure(s) — install the dependencies first" + ), + "结论: 环境可用": "Result: environment is ready", + "后处理器清单与查询": "Post-processor listing and lookup", + "回显该后处理器的元信息": "Print that post-processor's metadata", + "列出全部后处理器": "List every post-processor", + "后处理器 {n} 个:": "Post-processors ({n}):", + "构造失败": "construction failed", + "未知后处理器: {name}({exc})": "Unknown post-processor: {name} ({exc})", + "{name}: 品牌 {brand} / 控制器 {controller} / 扩展名 .{ext}": ( + "{name}: brand {brand} / controller {controller} / extension .{ext}" + ), + "预置工作站清单与查询": "Preset workcell listing and lookup", + "载入该工作站并打印摘要": "Load that workcell and print a summary", + "列出全部预置工作站": "List every preset workcell", + "预置工作站 {n} 个:": "Preset workcells ({n}):", + "未找到预置工作站目录: {path}": "Preset workcell directory not found: {path}", + "未找到工作站: {name}": "Workcell not found: {name}", + "无": "none", + "工作站 {name}: 机器人 {r} 台({names}),对象 {o} 个": ( + "Workcell {name}: {r} robot(s) ({names}), {o} object(s)" + ), +} diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 9140e0c..169c6fa 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -256,9 +256,11 @@ def test_catalog_has_no_duplicate_keys(): 用 ast 读字面量而不是正则扫文本:键里有 ``\\"`` 转义时正则会在转义引号处 截断,把一条正常条目误判成两条"重复"。 """ - from support import i18n + from support import i18n_catalog - tree = ast.parse(Path(i18n.__file__).read_text(encoding="utf-8")) + # _CATALOG_EN 自 2026-10-18 起定义在 i18n_catalog.py(数据表外置,Phase 1), + # i18n.py 仅 re-export——重复键检测要读数据真身。 + tree = ast.parse(Path(i18n_catalog.__file__).read_text(encoding="utf-8")) keys = [] for node in ast.walk(tree): value = getattr(node, "value", None) diff --git a/tools/check_arch_rules.py b/tools/check_arch_rules.py index 6338f20..9abd41d 100644 --- a/tools/check_arch_rules.py +++ b/tools/check_arch_rules.py @@ -118,7 +118,10 @@ # capabilities.py 后它降到 596 行(< 软限 600)——债务还完就下调,别留在清单里 # 误导后人。下面这条仍在软限之上(601 行),保留。 "app/window/project_session.py": "ADR-002 第 8 步:工程/会话组(601 行;模板与自动保存两块可再分)", - "support/i18n.py": "词条数据外置(Phase 1)", + # 2026-10-18 移除 ``support/i18n.py``:词条数据外置(Phase 1)完成,逻辑代码 + # 降至 118 行(< 软限 600),_CATALOG_EN 原样外置到 support/i18n_catalog.py + # ——债务还完就下调,见下方新登记的数据表条目。 + "support/i18n_catalog.py": "词条数据表(2026-10-18 外置;非逻辑代码,行数=词条规模,1377 行超硬限属数据外置的既定形态,参考 CODING_RULES §3.2 过渡态)", "adapter/robot_library/primitives.py": "View 支撑拆分(Phase 1)", "adapter/robot_library/urdf_model.py": "View 支撑拆分(Phase 1)", "domain/collision.py": "Domain 拆分(Phase 2)", @@ -157,7 +160,9 @@ # **代码搬迁同样守恒**:搬走的方法条数在源文件下调、在目标文件立键,总数不变—— # 例(拆 MainWindow 第 3 步):main.py 217 → 189,app/window 新立 22 + 6 = 28。 # --------------------------------------------------------------------------- # -I18N_HOME = {"support/i18n.py"} +# 词条之家 = i18n 逻辑 + 外置数据表(2026-10-18 外置):两者都按 key 记账, +# 豁免 H5 棘轮与 H9 统计(968 条中文 key 是数据不是硬编码文案)。 +I18N_HOME = {"support/i18n.py", "support/i18n_catalog.py"} CJK_RE = re.compile(r"[\u4e00-\u9fff]") CJK_BASELINE = { "adapter/cloudhttp.py": 49, @@ -367,7 +372,7 @@ def check_i18n_coverage(errors: list, warns: list) -> None: shown = key if len(key) <= 48 else key[:45] + "..." errors.append( f'H9 i18n 词条缺失(英文版会显示中文):"{shown}" —— {where};' - f"请在 support/i18n.py 的 _CATALOG_EN 补英文译文" + f"请在 support/i18n_catalog.py 的 _CATALOG_EN 补英文译文" ) if dynamic: warns.append( From 553435a83f48758c8f5766bf17e4dd7196553a20 Mon Sep 17 00:00:00 2001 From: JayH Date: Sun, 18 Oct 2026 23:35:32 +0800 Subject: [PATCH 3/3] =?UTF-8?q?tests:=20=E8=A1=A5=E5=85=A5=20conftest.py?= =?UTF-8?q?=EF=BC=88CORE=5FCONFIG=5FDIR=20=E9=9A=94=E7=A6=BB=20fixture?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 该文件一直在本地工作区但从未入库:autouse fixture 把每个用例的 CORE_CONFIG_DIR 指到独立临时目录,避免全量测试写真实 ~/.core 后 弹恢复询问模态框卡死(BUILD_QUEUE「Day1 冒出来的两个绿着的坑」)。 不入库则干净 clone 跑全量测试会复现卡死。 --- tests/conftest.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3a95be5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +"""共享 pytest fixture:GUI 测试的用户目录隔离。 + +背景(BUILD_QUEUE「Day1 冒出来的两个绿着的坑」之一):此前只有部分测试文件 +各自 monkeypatch 了 ``CORE_CONFIG_DIR``,其余 GUI 测试会把自动保存快照写进 +**真实的** ``~/.core/autosave``——于是全量跑到后面的用例新建 ``MainWindow`` +时,``showEvent → maybe_prompt_recovery`` 弹出恢复询问模态框等人点击: +单文件全绿、全量卡死在 99%(历史上卡过 +``test_ui_layout.py::test_no_tab_page_is_clipped_at_minimum_window``)。 + +这里用 autouse fixture 把 ``CORE_CONFIG_DIR`` 指到每个用例独立的临时目录, +与 ``tools/smoke_gui_demo.py`` 的冒烟口径一致。时序保证: + +- ``app/session.py`` / ``support/i18n.py`` 等都在**调用时**读环境变量, + fixture 在用例前设置即可生效; +- 个别测试自带的 ``monkeypatch.setenv("CORE_CONFIG_DIR", ...)`` 在本 fixture + 之后执行,按需覆盖仍然有效,互不冲突。 +""" + +import pytest + + +@pytest.fixture(autouse=True) +def _isolated_config_dir(tmp_path, monkeypatch): + """每个用例独立的用户配置目录,测试不读写真实的 ``~/.core``。""" + monkeypatch.setenv("CORE_CONFIG_DIR", str(tmp_path / "cfg"))