diff --git a/.gitignore b/.gitignore index 66bcc2f..e6a4033 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,7 @@ dist/ *_operation_history.json *.log -docs/screenshots/ +assets/screenshots/ .DS_Store Thumbs.db diff --git a/README.md b/README.md index eaa7fa4..a46a88f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,15 @@ 这是一个基于 Python、pythonocc-core/OCCT、VTK 和 PySide6/Qt 的 STEP 模型查看、选择、局部编辑与导出原型。 -当前项目目录下的 `geom_extract.step` 是默认测试模型。程序入口是 `main.py`。 +仓库里的 `assets/models/geom_extract.step` 是默认测试模型。程序入口是 `main.py`。 + +## 快速读懂 + +- 这是一个 STEP B-Rep 结果几何查看和受限局部编辑工具,不是完整参数化 CAD 内核。 +- 主入口是 `python main.py`;默认模型是 `assets/models/geom_extract.step`;立方体测试模型是 `assets/models/cube_10mm.step`。 +- 左侧操作面板按工作流排列:STEP 文件、模型结构树、鼠标选择模式、按 ID 选择、编辑、导出、显示、测量、可编辑对象、圆柱面候选、操作历史和对象信息。 +- 局部编辑会先做计划、风险提示、预览和后台执行;失败时会尽量回滚,成功后进入撤销/重做历史。 +- 新开 Codex 聊天框继续开发时,只需要 Codex 阅读 README 末尾的“Codex 项目记忆”;普通开发者可以忽略那一节。 ## 最终要实现什么 @@ -10,45 +18,45 @@ - 加载 `.step` / `.stp` 模型文件。 - 展示三维模型,支持旋转、缩放、平移。 -- 读取 STEP 中的零件、装配、solid 等结构。 -- 用户可以选择零件、solid、面、边和局部几何特征。 +- 读取 STEP 中的零件、装配、Solid 等结构。 +- 用户可以选择零件、Solid、Face、Edge 和局部几何特征。 - 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。 - 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。 -- 用户可以修改可控的局部特征,比如平面推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、凸台直径调整、边倒圆、边倒角和第一版直接边长调整。 -- 用户可以撤销/重做修改,避免实验性编辑一步做坏。 +- 用户可以修改可控的局部特征,比如平面推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、凸台直径调整、Edge倒圆、Edge倒角和修改Edge长度。 +- 用户可以撤销/重做修改,避免一步编辑做坏。 - 用户可以导出修改后的完整模型。 - 如果 STEP 文件里存在多个零件,用户可以只导出选中的某个零件。 需要注意:STEP 文件通常是 B-Rep 几何数据,不是带完整建模历史的参数化 CAD 原文件。所以“随便选一条边改长度”不一定能稳定实现,实际会转化成更可靠的操作,比如移动某个面、重切某个孔、调整某个圆角等。 -## 第一版先实现什么 +## 最小系统先实现什么 -第一版目标是先把完整工作链路打通: +最小系统目标是先把完整工作链路打通: - 能打开 STEP。 - 能显示模型。 - 能读取 STEP 中的零件/装配标签。 -- 能选择 part、solid、face、edge。 +- 能选择 Part、Solid、Face、Edge。 - 能显示选中对象的基本属性。 - 能高亮选中对象。 - 能导出整个当前模型。 - 能导出选中的零件。 -- 能撤销/重做实验性编辑。 +- 能撤销/重做编辑。 - 先实现少量可控编辑操作,用于验证后续特征编辑路线。 ## 现在已经实现了什么 当前已经实现: -- 默认加载 `geom_extract.step`。 +- 默认加载 `assets/models/geom_extract.step`。 - 支持打开其他 `.step` / `.stp` 文件。 - 打开 STEP 时会先在临时模型里完成读取和显示网格生成;失败时当前模型保持不变。 -- 使用 XCAF 读取 STEP 中的 part / assembly 标签。 +- 使用 XCAF 读取 STEP 中的 Part / Assembly 标签。 - 模型结构树会用中文显示 STEP 里的装配、零件和实体层级,并在对应零件下面列出实体子节点。 - 点击模型结构树中的实体子节点,可以直接选中、高亮、查看属性并用于导出该实体。 - 使用 OCCT 读取真实 B-Rep 拓扑。 - 使用 VTK 显示模型,并通过 PySide6/Qt 承载桌面界面。 -- 左侧操作面板按功能模块分组显示,并使用四边同色的轻量彩色边框和标题层级区分文件、选择、显示、导出、编辑、候选、历史和属性信息。 +- 左侧操作面板按功能模块分组显示,并使用四边同色的轻量彩色边框和标题层级区分文件、模型树、选择、编辑、导出、显示、测量、候选、历史和属性信息。 - 支持选择模式: - Part - Solid @@ -56,26 +64,26 @@ - Edge - Feature - 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。 -- `Face` 模式会把属于同一几何面的连续区域一起高亮;平面会按共面且投影范围相接/重叠查找,圆柱面会按同一实体内同轴、同半径且轴向连续/重叠查找,不再要求旧段和新段必须共享同一条拓扑 edge。例如推拉后侧壁被 OCCT 拆成上下多段 face,点击任意一段时都会把整片同域区域一起高亮。 -- 推拉后程序会尽量保留侧壁面区域的 `逻辑 Face ID`:即使 OCCT 把原来的一个侧壁拓扑 face 重建成上下两段,新旧两段也会绑定回推拉前那片侧壁的逻辑 ID。属性表会同时显示 `逻辑 Face ID` 和 `拓扑 Face ID`;前者面向用户选择和按 ID 定位,后者用于调试当前 B-Rep 拓扑。 -- 操作历史会记录 `target_logical_id` 和当时的拓扑 face id。点击历史记录定位 face / feature 时,会优先用逻辑 ID 找回当前模型中的整片面区域;撤销/重做快照也会保留这层逻辑 ID 映射。 -- 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界 edge。 +- `Face` 模式会把属于同一几何面的连续区域一起高亮;平面会按共面且投影范围相接/重叠查找,圆柱面会按同一实体内同轴、同半径且轴向连续/重叠查找,不再要求旧段和新段必须共享同一条拓扑Edge。例如推拉后侧壁被 OCCT 拆成上下多段Face,点击任意一段时都会把整片同域区域一起高亮。 +- 推拉后程序会尽量保留侧壁面区域的 `逻辑 Face ID`:即使 OCCT 把原来的一个侧壁拓扑Face重建成上下两段,新旧两段也会绑定回推拉前那片侧壁的逻辑 ID。属性表会同时显示 `逻辑 Face ID` 和 `拓扑 Face ID`;前者面向用户选择和按 ID 定位,后者用于调试当前 B-Rep 拓扑。 +- 操作历史会记录 `target_logical_id` 和当时的拓扑Face ID。点击历史记录定位Face / Feature时,会优先用逻辑 ID 找回当前模型中的整片面区域;撤销/重做快照也会保留这层逻辑 ID 映射。 +- 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界Edge。 - 支持按当前选择模式输入 ID 直接选择 Part / Solid / Face / Edge / Feature。 - Face / Feature 按 ID 选择会优先解析 `逻辑 Face ID`。例如推拉前侧壁是 558,推拉后当前拓扑里变成 1556 和 1802,只要它们被绑定到逻辑 558,输入 558 就会选中这片面区域。 -- `鼠标选择模式` 下拉框不会响应鼠标滚轮,避免鼠标经过时误切换模式;需要点击后手动选择。 +- `鼠标选择模式` 下拉框关闭时会把鼠标滚轮交还给左侧滚动面板,避免鼠标经过时误切换模式;打开下拉列表后仍可用滚轮滚动选项。 - `按 ID 选择` 会在输入框和 `选择` 按钮之间同步显示当前鼠标选择模式。 -- `Feature` 模式现在会把点到的 face 解释为局部几何特征候选: +- `Feature` 模式现在会把点到的Face解释为局部几何特征候选: - 平面会识别为可推拉平面候选。 - - 平面会尝试查找同一 solid 内投影重叠的相对平面,用于估算薄壁/壳体局部区域厚度。 + - 平面会尝试查找同一Solid内投影重叠的相对平面,用于估算薄壁/壳体局部区域厚度。 - 圆柱面会识别为圆柱孔候选、槽/半孔候选、圆角/倒圆候选、凸台/外圆候选或未明确圆柱特征。 - - 圆柱特征会先把同一实体内同轴、同半径且轴向连续/重叠的完整圆柱 face 当作同一侧壁区域,再尝试找出边界 edge、相邻 face、端面 face、开口端相邻 face 和疑似底面 face,并一起高亮相关局部区域。 - - 槽/半孔候选会额外显示槽宽、圆弧长度、槽深估算和槽边界相邻 face。 - - 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑 face。 + - 圆柱特征会先把同一实体内同轴、同半径且轴向连续/重叠的完整圆柱Face当作同一侧壁区域,再尝试找出边界Edge、相邻Face、端面Face、开口端相邻Face和疑似底面Face,并一起高亮相关局部区域。 + - 槽/半孔候选会额外显示槽宽、圆弧长度、槽深估算和槽边界相邻Face。 + - 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑Face。 - 支持显示选中对象信息,并提供 `属性表` / `原始文本` 两种查看方式: - 零件名称 - - solid 数量 - - face 数量 - - edge 数量 + - Solid 数量 + - Face 数量 + - Edge 数量 - 面类型 - 面面积 - 平面法向 @@ -84,163 +92,176 @@ - 边长度 - 圆边半径/直径 - 属性面板现在还会显示更多几何调试信息: - - part / solid 的包围盒、尺寸、体积、重心、表面积。 - - face 的方向、面积中心、UV 参数范围、边界边数量、包围盒。 - - 平面 face 的几何法向和按拓扑方向修正后的法向。 - - 平面 face 的推拉向外方向、向内方向和方向判断置信度。 - - 圆柱 face 的轴线点、轴方向、半径、直径、角度跨度、估算高度和是否接近完整圆柱。 - - edge 的参数范围、起点、终点、长度中心、包围盒。 - - 直线 edge 的方向,圆弧 edge 的圆心、轴线、半径和直径。 + - Part / Solid 的包围盒、尺寸、体积、重心、表面积。 + - Face 的方向、面积中心、UV 参数范围、边界边数量、包围盒。 + - 平面Face的几何法向和按拓扑方向修正后的法向。 + - 平面Face的推拉向外方向、向内方向和方向判断置信度。 + - 圆柱Face的轴线点、轴方向、半径、直径、角度跨度、估算高度和是否接近完整圆柱。 + - Edge 的参数范围、起点、终点、长度中心、包围盒。 + - 直线Edge的方向,圆弧Edge的圆心、轴线、半径和直径。 - 属性面板支持复制当前对象 ID、拾取坐标和完整信息。 - 面/边属性查询带缓存;重复选择同一个对象会复用上次计算结果,模型编辑、撤销、重做或重新加载后缓存会自动清空。 -- 支持高亮选中的零件、solid、面、边或局部特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face 选择会对共面平面和同轴同半径圆柱面做同域区域高亮。 -- 默认模型边线渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔推拉后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部 edge,仍然可以单独高亮查看。 +- 支持高亮选中的零件、Solid、Face、Edge或局部特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face 选择会对共面平面和同轴同半径圆柱面做同域区域高亮。 +- 默认模型边线渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔推拉后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部Edge,仍然可以单独高亮查看。 - 支持只显示当前选中对象、对准当前选中对象,并可一键恢复显示完整模型。 - 支持两点测量: - 可以把当前选中对象的拾取点设为 A 或 B;如果没有拾取点,会退回使用对象中心、面积中心、长度中心、重心或包围盒中心。 - 设置 A/B 后会显示两点距离和 X/Y/Z 方向差值。 - 3D 视图中会显示一条青色测量线;测量结果可以复制,加载新模型时会自动清除旧测量。 -- Feature 模式会在属性表里显示特征来源 face、侧壁 face、同域圆柱范围、端面 face、疑似底面 face、开口端相邻 face、边界 edge 和当前可用操作。 +- Feature 模式会在属性表里显示特征来源Face、侧壁Face、同域圆柱范围、端面Face、疑似底面Face、开口端相邻Face、边界Edge和当前可用操作。 - 支持导出当前完整模型为 STEP。 -- 支持导出选中的 part 为 STEP。 -- 支持导出选中的 solid 为 STEP。 -- 支持导出选中的 face / 同域面区域为 STEP;如果当前 face 与共面或同轴同半径的相邻面一起高亮,导出也会包含整片黄色区域。 +- 支持导出选中的Part为 STEP。 +- 支持导出选中的Solid为 STEP。 +- 支持导出选中的Face / 同域面区域为 STEP;如果当前Face与共面或同轴同半径的相邻面一起高亮,导出也会包含整片黄色区域。 - 支持导出选中特征区域为 STEP。 -- 支持导出选中的 edge 为 STEP。 -- 支持导出质量检查:导出前会检查 B-Rep 有效性、solid / face / edge 数量、体积和包围盒;如果发现风险,会先弹窗确认。 -- 支持一键修复当前模型或选中零件/solid:执行 ShapeFix 和同域面/边合并,修复会进入后台编辑流程,并支持撤销/重做。 -- 导出完整模型、part 或 solid 前,会先尝试执行 ShapeFix 和同域面/边合并,减少外部 CAD 打开时出现碎面、坏体或明显拼接痕迹的概率。 -- 实验性编辑按钮会根据当前选中对象自动启用或禁用,并通过悬停提示说明为什么当前操作不可用,减少 face、edge、孔、凸台、盲孔等对象混用导致的误操作。 -- 支持扫描第一版可编辑对象: +- 支持导出选中的Edge为 STEP。 +- 支持导出质量检查:导出前会检查 B-Rep 有效性、Solid / Face / Edge 数量、体积和包围盒;如果发现风险,会先弹窗确认。 +- 支持一键修复当前模型或选中零件/Solid:执行 ShapeFix 和同域面/边合并,修复会进入后台编辑流程,并支持撤销/重做。 +- 导出完整模型、Part 或 Solid 前,会先尝试执行 ShapeFix 和同域面/边合并,减少外部 CAD 打开时出现碎面、坏体或明显拼接痕迹的概率。 +- 编辑按钮会根据当前选中对象自动启用或禁用,并通过悬停提示说明为什么当前操作不可用,减少 Face、Edge、孔、凸台、盲孔等对象混用导致的误操作。 +- 支持扫描可编辑对象: - 默认扫描优先保证界面响应速度,会列出一组有代表性的候选。 - `深度扫描` 会尝试列出更多候选,适合默认列表里没有找到目标对象时使用。 - - 可推拉的平面 face。 - - 可调整孔径的圆柱 face 候选。 + - 可推拉的平面Face。 + - 可调整孔径的圆柱Face候选。 - 可调整槽/半孔宽度的局部圆柱孔/槽候选。 - - 可调整盲孔深度的圆柱 face 候选。 - - 可添加新圆角的直线 edge 候选。 - - 可添加倒角的直线 edge 候选。 - - 可尝试通过端面推拉调整长度的直线 edge 候选。 + - 可调整盲孔深度的圆柱Face候选。 + - 可添加新圆角的直线Edge候选。 + - 可添加倒角的直线Edge候选。 + - 可尝试直接调整长度的直线Edge候选,简单平面多面体会优先局部边形变。 - 表格会显示当前值、状态、风险、置信度和说明。 - - 盲孔深度候选会在列表阶段先确认疑似底面,列表里的深度值优先使用底面 face 轴向位置计算。 - - 点击表格行会直接选中对应 face 或 edge,方便继续执行推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、边倒圆、边倒角或直接边长调整。 + - 盲孔深度候选会在列表阶段先确认疑似底面,列表里的深度值优先使用底面Face轴向位置计算。 + - 点击表格行会直接选中对应Face或Edge,方便继续执行推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、Edge倒圆、Edge倒角或修改Edge长度。 - 支持列出圆柱面候选特征。 -- 圆柱候选会显示在表格中,点击候选行可以直接选中并高亮对应 face。 -- 圆柱候选可以按类型筛选:全部、孔/槽候选、圆角候选、凸柱/外圆候选、未明确分类。 -- 圆柱候选的类型下拉框不会响应鼠标滚轮,避免滚动左侧面板时误改筛选条件。 -- 圆柱候选表格会显示初步分类: - - `hole/groove candidate`:孔/槽一类的凹向圆柱候选。 +- 圆柱面候选会显示在表格中,点击候选行可以直接选中并高亮对应 Face。 +- 圆柱面候选可以按类型筛选:全部、孔/槽候选、圆角候选、凸柱/外圆候选、未明确分类。 +- 圆柱面候选的类型下拉框关闭时会把鼠标滚轮交还给左侧滚动面板,避免滚动面板时误改筛选条件。 +- 圆柱面候选表格会显示初步分类: + - `hole/groove candidate`:孔/槽一类的凹向圆柱面候选。 - `round/fillet candidate`:小半径局部圆柱面,可能是圆角/倒圆。 - - `boss/outer-round candidate`:凸柱/外圆角一类的外向圆柱候选。 + - `boss/outer-round candidate`:凸柱/外圆角一类的外向圆柱面候选。 - `cylindrical face`:暂时无法明确分类的圆柱面。 -- 圆柱候选表格会显示直径、角度跨度、估算高度和置信度。 -- 圆柱候选表格会显示 `risk`,用于提示调整圆柱孔径的风险。 -- 圆柱候选表默认列出前 60 个候选,以避免打开大模型时刷新过慢。 -- 实验性支持平面 face 推拉。 +- 圆柱面候选表格会显示直径、角度跨度、估算高度和置信度。 +- 圆柱面候选表格会显示 `risk`,用于提示调整圆柱孔径的风险。 +- 圆柱面候选表默认列出前 60 个候选,以避免打开大模型时刷新过慢。 +- 当前版本支持平面Face推拉。 - 平面推拉会自动判断面两侧的 inside / outside,输入正数表示向外加料,输入负数表示向内切削。 - 推拉平面会先显示半透明预览体,表示推拉方向和大致范围,然后在后台执行真实 OCCT 布尔计算。 -- 推拉平面现在会从选中 face 出发,自动查找共面且相接/重叠的相邻 face,并把它们作为同一片平面区域一起推拉。 +- 推拉平面现在会从选中Face出发,自动查找共面且相接/重叠的相邻Face,并把它们作为同一片平面区域一起推拉。 - 这可以减少 STEP 碎面导致的“只推了一小块,导出后像贴上去一块,中间有伤疤”的情况。 - 如果选中的平面被识别为完整圆柱端盖,并且是向外拉长,程序会优先使用“完整旧段 + 新段圆柱”的专用 Fuse 路线,而不是只把一个短圆柱 prism 拼到端面上。简单圆柱场景下,这会让拉长结果保持为一个连续圆柱侧面,减少新旧圆柱段拼接痕迹。 - 推拉加料时,工具体会向原实体内部保留一个很小的重叠量,再执行 Fuse,避免只靠两个面贴合导致导出后像“原模型 + 新增块”叠在一起。 - Fuse/Cut 完成后会执行更强的推拉结果清理:按模型尺寸设置小容差,显式禁止保留 internal edges,并多轮执行 OCCT 同域面/边合并,减少第三方 CAD 中出现不必要共面分割线的概率。 - 布尔计算前会启用 OCCT glue/fuzzy 选项,布尔后会调用 `SimplifyResult` 和同域合并;但某些 CAD/OCCT 场景仍可能保留拓扑分割边。当前程序会先在选择层把同域平面/圆柱区域作为整体高亮,并在默认边线渲染中隐藏同域内部拓扑边和几何重复拼接边;真正从 STEP/B-Rep 拓扑里消除这类分割边,还需要后续做更专门的局部圆柱/侧壁重建。 - 如果推拉本身产生了真实台阶边界,第三方 CAD 仍会显示这条几何边;清理只能减少不必要的共面残留边,不能消除真实形状边界。 -- 推拉平面会先生成操作计划:检查距离是否为 0、方向置信度和相对 face 尺寸的风险;能推拉时直接执行,不能推拉时会提示阻止原因。 -- 调整圆柱孔径也会先显示半透明预览体:红色表示切削 cutter,绿色表示缩小孔径时的补料范围;如果圆柱侧壁被拆成多段同域 face,工具范围会优先覆盖整段同域圆柱。 -- 支持第一版盲孔/盲槽深度调整: - - 只对 `hole/groove candidate` 且端部类型为 `blind`、并识别到疑似底面 face 的圆柱候选开放。 +- 推拉平面会先生成操作计划:检查距离是否为 0、方向置信度和相对Face尺寸的风险;能推拉时直接执行,不能推拉时会提示阻止原因。 +- 调整圆柱孔径也会先显示半透明预览体:红色表示切削 cutter,绿色表示缩小孔径时的补料范围;如果圆柱侧壁被拆成多段同域Face,工具范围会优先覆盖整段同域圆柱。 +- 当前版本支持盲孔/盲槽深度调整: + - 只对 `hole/groove candidate` 且端部类型为 `blind`、并识别到疑似底面 Face 的圆柱面候选开放。 - 目标深度大于当前估算深度时,会沿开口到疑似底面方向用有限长度 cutter 加深切削。 - 目标深度小于当前估算深度时,会从目标新底面到旧底面方向补料,让孔变浅。 - 调整盲孔深度会先显示半透明预览体:红色表示加深切削范围,绿色表示变浅补料范围。 - 孔深调整计划会记录当前深度、目标深度、变化比例、疑似底面、开口端和工具参数。 - - 当前深度会优先使用疑似底面 face 在圆柱轴线上的位置计算;如果相邻端面找不到底面,会额外扫描同一 solid 内轴线附近的平面圆盘候选。 -- 支持第一版槽/半孔候选结构化识别: + - 当前深度会优先使用疑似底面Face在圆柱轴线上的位置计算;如果相邻端面找不到底面,会额外扫描同一Solid内轴线附近的平面圆盘候选。 +- 当前版本支持槽/半孔候选结构化识别: - 对局部圆柱形 `hole/groove candidate`,会标记为 `partial-cylindrical-groove`。 - 会估算槽宽、圆弧长度和槽深。 - - 会识别槽圆柱 face 和槽边界相邻 face,并在 Feature 模式下一起高亮。 + - 会识别槽圆柱Face和槽边界相邻Face,并在 Feature 模式下一起高亮。 - 这些仍是 B-Rep 几何估算,不是 CAD 历史里的槽特征参数。 -- 支持第一版槽/半孔宽度调整: +- 当前版本支持槽/半孔宽度调整: - 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。 - 输入目标槽宽后,会按当前圆弧角度把槽宽换算为目标圆柱直径,再复用圆柱孔/槽重建路线执行。 - 这不是完整 CAD 槽参数编辑;复杂槽、非圆柱槽或圆弧角度不稳定时仍可能失败并回滚。 -- 支持第一版已有圆角/倒圆候选结构化识别: +- 当前版本支持薄壁/壳体局部厚度调整: + - 对已找到投影重叠相对平面的平面候选开放。 + - 输入目标厚度后,程序会把厚度变化换算成当前平面区域的推拉距离,保留相对平面不动。 + - 会先显示半透明推拉预览,并记录当前厚度、目标厚度、相对平面Face、重叠率和推拉距离。 + - 这是局部 B-Rep 平面推拉近似,不是 CAD 壳命令或完整壳体参数编辑。 +- 当前版本支持已有圆角/倒圆候选结构化识别: - 对局部小半径圆柱面,会标记为 `round/fillet candidate`。 - 会估算已有圆角半径、圆弧角度和圆弧长度。 - - 会识别已有圆角 face 和相邻支撑 face,并在 Feature 模式下一起高亮。 - - `第一版可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径`,点击后会选中该圆角 face,并把圆角半径输入框预填为当前估算半径的参考目标值。 + - 会识别已有圆角Face和相邻支撑Face,并在 Feature 模式下一起高亮。 + - `可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径`,点击后会选中该圆角 Face,并把圆角半径输入框预填为当前估算半径的参考目标值。 - 选中已有圆角候选时,不会再误把它填入 `孔直径`,避免把圆角当孔径修改。 - - 第一版已有圆角半径修改会先尝试移除圆角 face,再在恢复出的锐边上重新倒圆;只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。 -- 支持第一版圆柱孔封堵: + - 当前版本的已有圆角半径修改会先尝试移除圆角Face,再在恢复出的锐边上重新倒圆;只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。 +- 当前版本支持圆柱孔封堵: - 只对 `hole/groove candidate` 且角度跨度接近完整圆柱的孔开放。 - 不对半孔/槽开放,避免用完整圆柱补料时把不该补的位置也补上。 - 封堵会用略带半径重叠的圆柱补料体执行 Fuse。 - 绿色半透明预览表示即将补上的材料范围。 - 当前默认模型可能不会在默认可编辑对象列表里显示封堵候选;其他包含完整圆柱孔的 STEP 文件可以使用这个能力。 -- 支持第一版圆柱凸台直径调整: +- 当前版本支持圆柱凸台直径调整: - 只对 `boss/outer-round candidate` 且角度跨度接近完整圆柱的凸台开放。 - 不处理局部外圆角、圆角面或未明确圆柱面,避免把圆角当凸台修改。 - 目标直径变大时,会在同域圆柱侧壁范围内生成目标直径圆柱并 Fuse。 - 目标直径变小时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建,避免环形 cutter 在部分场景下把实体切空。 - 会先显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。 - 当前默认模型可能没有明确凸台候选;包含完整圆柱凸台的 STEP 文件可以使用这个能力。 -- 支持第一版直线边添加圆角: - - 只对直线 edge 开放。 - - 会先检查边长、相邻 face 数量和目标半径相对边长的比例。 - - 蓝色半透明预览表示目标 edge 和输入半径的大致范围。 +- 当前版本支持直线Edge添加圆角: + - 只对直线Edge开放。 + - 会先检查边长、相邻Face数量和目标半径相对边长的比例。 + - 蓝色半透明预览表示目标Edge和输入半径的大致范围。 - 真实编辑使用 OCCT `BRepFilletAPI_MakeFillet` 在后台执行。 - 这一步是“给直线边添加新圆角”,还不是“修改已有圆角半径”。 - STEP 没有建模历史,某些看起来像直线边的对象仍可能被 OCCT 拒绝;失败时会自动尝试恢复编辑前状态。 -- 支持第一版直线边添加倒角: - - 只对直线 edge 开放。 - - 会先检查边长、相邻 face 数量和倒角距离相对边长的比例。 - - 橙色半透明预览表示目标 edge 和输入倒角距离的大致范围。 +- 当前版本支持直线Edge添加倒角: + - 只对直线Edge开放。 + - 会先检查边长、相邻Face数量和倒角距离相对边长的比例。 + - 橙色半透明预览表示目标Edge和输入倒角距离的大致范围。 - 真实编辑使用 OCCT `BRepFilletAPI_MakeChamfer` 在后台执行。 - STEP 没有建模历史,某些边仍可能被 OCCT 拒绝;失败时会自动尝试恢复编辑前状态。 -- 支持第一版直接边长调整: - - 选中 edge 后,`边目标长度` 会自动填入当前边长。 - - 可以选择边长基准:自动、中心、固定起点、固定终点。 - - 直线 edge 会优先尝试寻找端点附近、方向匹配的平面端面,并把“改边长”转换成该端面的平面推拉操作。 - - `自动` 会优先选择可局部推拉的端面;`固定起点` / `固定终点` 会优先尝试移动另一端端面。 - - 圆形/圆弧 edge 会优先检查相邻圆柱面;如果能复用孔/槽或凸台直径编辑,就把目标边长按比例换算成目标圆柱直径再执行局部编辑。 - - `中心`、没有明确端面、或圆边没有可复用圆柱面时,会尝试整体/轴向几何缩放 fallback,以覆盖更多任意 edge 的最小可用场景。 - - 该 fallback 会影响所属 part/solid 的其它尺寸,因此会按高风险确认,并依赖失败回滚/撤销兜底。 +- 当前版本支持修改Edge长度: + - 选中Edge后,`Edge目标长度` 会自动填入当前Edge长度。 + - 可以选择Edge长度基准:自动、中心、固定起点、固定终点。 + - 对正方体这类全平面多面体,直线Edge会优先走“局部边形变”:只移动被选Edge的一个端点或两个端点,并重建周边平面。 + - 局部边形变不会把整个端面拉走,因此正方体不会默认变成长方体;相邻面会按被移动的边自然变成梯形或拆成三角面。 + - `自动` 默认固定起点、移动终点;`固定起点` / `固定终点` 会明确控制哪一端不动;`中心` 会让两端各移动一半。 + - 如果模型不是简单全平面多面体,直线Edge会继续尝试寻找端点附近、方向匹配的平面端面,并把“改边长”转换成该端面的平面推拉操作。 + - 圆形/圆弧Edge会优先检查相邻圆柱面;如果能复用孔/槽或凸台直径编辑,就把Edge目标长度按比例换算成目标圆柱直径再执行局部编辑。 + - 圆边没有可复用圆柱面时,会优先围绕圆边轴线做径向缩放,尽量只改半径方向并保留轴向尺寸;这类一般仿射可能把解析圆/圆锥/圆柱几何转换为 B-spline。 + - 椭圆边和可稳定采样为平面的 B-spline / Bezier 等非直线边,也会尝试围绕曲线所在平面法向做径向缩放;如果采样点不够平面,会退回更保守的缩放 fallback。 + - 几何缩放 fallback 会先只预变换目标Edge来估算结果长度,并在必要时微调缩放比例,让实际边长更接近输入值。 + - `中心`、没有明确端面、或非圆边没有更明确局部路径时,会尝试整体/轴向几何缩放 fallback,以覆盖更多任意Edge的最小可用场景。 + - 该 fallback 会影响所属Part/Solid的其它尺寸,因此会按高风险确认,并依赖失败回滚/撤销兜底。 - 这仍不是通用参数化边长编辑;复杂拓扑、斜面过渡、圆角链或布尔失败场景仍可能不能稳定修改。 -- 支持 part / solid 级平移: +- 支持 Part / Solid 级平移: - 输入 X/Y/Z 平移量后,可以平移当前选中零件。 - - 选中 solid、或选中属于某个 solid 的 face/edge 后,可以平移当前 solid。 - - 单 solid 零件中平移 solid 实际等同于移动整个 part shape,程序会按中风险提示。 + - 选中Solid、或选中属于某个Solid的Face/Edge后,可以平移当前Solid。 + - 单Solid零件中平移Solid实际等同于移动整个Part shape,程序会按中风险提示。 - 平移操作会写入历史记录,并支持撤销/重做。 -- 支持 part / solid 级旋转: - - 选择 X/Y/Z 旋转轴并输入角度后,可以旋转当前选中零件或 solid。 - - 第一版旋转中心取目标对象的包围盒中心。 - - 单 solid 零件中旋转 solid 实际等同于旋转整个 part shape,程序会按中风险提示。 +- 支持 Part / Solid 级旋转: + - 选择 X/Y/Z 旋转轴并输入角度后,可以旋转当前选中零件或Solid。 + - 当前版本的旋转中心取目标对象的包围盒中心。 + - 单Solid零件中旋转Solid实际等同于旋转整个Part shape,程序会按中风险提示。 - 旋转操作会写入历史记录,并支持撤销/重做。 - 后台编辑计算时不再弹出模态进度框,也不再把主界面整体置灰;UI 线程只负责渲染和响应基础视图交互,真实 B-Rep 编辑在后台线程执行。 - 编辑计算期间,半透明预览会继续在 3D 视图里渲染,用户可以旋转或缩放查看;会改变模型状态的操作仍会暂时禁用,避免同一模型被多个后台编辑同时修改。 +- 首次加载会先在后台生成粗显示数据并快速显示,随后再后台生成精细显示数据并替换到视图里。 +- 鼠标悬停高亮做了节流和移动阈值,减少复杂模型上连续拾取造成的卡顿。 +- 后台编辑成功后会尽量在后台一并生成刷新用的模型/边线显示数据,减少编辑完成瞬间的主线程冻结。 - 真实 B-Rep 结果会在布尔计算完成后一次性刷新;半透明预览不等于最终几何结果。 - 如果后台编辑失败,程序会自动尝试恢复到编辑前快照,避免出现“提示失败但模型已经被部分改动”的状态。 -- 实验性支持圆柱孔/圆柱面扩大切削。 -- 实验性支持圆柱孔/圆柱面缩小孔径:先补料,再用目标直径重切。 +- 当前版本支持圆柱孔/圆柱面扩大切削。 +- 当前版本支持圆柱孔/圆柱面缩小孔径:先补料,再用目标直径重切。 - 圆柱孔/圆柱面孔径调整现在会先生成切削计划: - 目标直径必须大于 0;如果目标直径与当前直径几乎相同,会直接阻止。 - 会计算直径变化比例;变化超过当前直径 35% 会提高到中风险,超过 100% 会提高到高风险。 - 会比较目标直径和圆柱面估算高度;比例异常时会提高风险等级。 - 候选不是 `hole/groove candidate` 时会标为高风险并要求确认。 - 圆角/倒圆候选会标为高风险,避免误切圆角。 - - 缩小孔径第一版只对 `hole/groove candidate` 开放,其他圆柱面会直接阻止。 - - 选中 face 或实际切削计划会使用多点材料采样,比候选表里的快速分类更谨慎。 + - 缩小孔径当前版本只对 `hole/groove candidate` 开放,其他圆柱面会直接阻止。 + - 选中Face或实际切削计划会使用多点材料采样,比候选表里的快速分类更谨慎。 - 实际切削优先使用有限长度 cutter,按选中圆柱面的 V 参数范围加少量余量生成,不再默认贯穿整个零件。 - 会沿圆柱轴线采样两端材料状态,初步区分通孔/开口端、盲孔和封闭端。 - 会把圆柱相邻平面按轴线位置分成起点端/终点端,并结合端部采样标记疑似盲孔底面;相邻端面缺失时,会尝试扫描轴线附近的平面圆盘候选作为兜底。 - - 孔径修改计划会记录疑似底面 face、开口端相邻 face 和 cutter 底面保护状态。 + - 孔径修改计划会记录疑似底面Face、开口端相邻Face和 cutter 底面保护状态。 - 对盲孔/封闭端,有限长度 cutter 会使用更小的端部余量,降低切深变长的风险。 -- 支持撤销/重做实验性编辑。 +- 支持撤销/重做编辑。 - 撤销/重做会先确认模型恢复成功,再移动历史栈;如果恢复失败,会尽量回到操作前状态。 - 支持操作历史面板,显示当前已经成功执行的编辑。 - 操作历史详情会显示编辑类型、目标对象、参数、执行结果和拓扑数量变化。 -- 操作历史详情会额外显示目标零件自身的 solid / face / edge 数量变化。 -- 如果一次局部编辑导致目标零件 solid 数异常变化,历史详情会显示质量警告,状态栏也会提示先检查结果。 +- 操作历史详情会额外显示目标零件自身的 Solid / Face / Edge 数量变化。 +- 如果一次局部编辑导致目标零件 Solid 数异常变化,历史详情会显示质量警告,状态栏也会提示先检查结果。 - 操作历史详情会显示编辑前后的几何差异摘要: - 体积变化。 - 表面积变化。 @@ -254,13 +275,13 @@ - 可以点击 `清除差异预览` 关闭叠加显示。 - 支持导出选中操作历史的差异报告,报告会包含操作参数、拓扑变化、几何变化和热力图统计。 - 鼠标拾取模型时会显示三维拾取点坐标。 -- 实验性编辑历史会记录目标对象和当时的拾取点。 -- 支持把本软件加载后的编辑历史导出为 JSON;这不是 STEP 原始 CAD 建模历史,而是后续操作留档。 +- 编辑历史会记录目标对象和当时的拾取点。 +- 支持把本软件加载后的编辑历史导出为 JSON v2;除摘要和详情外,也会保存 `operation_name`、`target`、`parameters` 和 `result_message`,为后续安全复放做准备。 - 点击操作历史记录时,程序会尝试重新高亮目标对象,并显示当时的拾取点标记。 - 对非 `hole/groove candidate` 的圆柱切削会先弹出确认,避免误切外圆角或凸柱。 - 提供 `--smoke-test` 启动检查,用于验证 Qt + VTK 窗口组件能正常初始化。 -当前 `geom_extract.step` 的读取结果是: +当前 `assets/models/geom_extract.step` 的读取结果是: ```text part: 2001-LOWER_HOUSING @@ -277,12 +298,12 @@ vertices: 3262 这里分成两类看,不然容易误解为“完全没有推进”。 -已经有第一版,但还需要继续稳定和完善: +已经有当前版本的初步实现,但还需要继续稳定和完善: - 复杂特征识别:已经有平面、圆柱、边、孔/槽候选、槽/半孔候选、已有圆角候选、圆柱凸台候选等基础候选列表,但还不是完整 CAD 语义级识别。 -- 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面 face 的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。 -- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度调整、完整圆柱凸台直径修改和平面薄壁/壳体相对面厚度估算;复杂槽、复杂凸台和完整壳体区域仍需要继续做。 -- 已有圆角/倒圆面半径:已经有第一版受限修改实现,使用 defeature + refillet 路线,但复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。 +- 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面Face的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。 +- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度调整、完整圆柱凸台直径修改,以及平面薄壁/壳体相对面厚度估算和当前版本厚度调整;复杂槽、复杂凸台和完整壳体区域仍需要继续做。 +- 已有圆角/倒圆面半径:已经有当前版本的受限修改实现,使用 defeature + refillet 路线,但复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。 - 局部偏差分析:已经有编辑前后体积、面积、包围盒、距离热力图和差异报告,但还不是工程公差级的局部区域偏差报告。 - 错误恢复和模型修复:已经有编辑前快照、失败回滚、基础 B-Rep 校验、同域面合并、布尔结果自动修复,以及一键修复当前模型或选中零件/solid,但还不是完整的模型修复流水线。 - 多零件装配:已经有装配/零件树和单独导出入口,但还没有做大规模、多来源 STEP 装配测试。 @@ -296,12 +317,12 @@ vertices: 3262 建议下一步按这个顺序推进: -1. 继续补齐更多最小可用特征入口,优先覆盖复杂槽、复杂凸台、局部薄壁/壳体区域和装配用例。 -2. 继续验证平面推拉、孔径调整、槽/半孔宽度调整、盲孔/盲槽深度调整、圆柱凸台直径调整和直接边长调整,记录哪些对象稳定、哪些对象会失败。 -3. 改进盲孔/槽底面识别,减少“看起来像 blind 但找不到可靠底面 face”的候选。 +1. 继续补齐更多最小可用特征入口,优先覆盖复杂槽、复杂凸台、完整壳体区域和装配用例。 +2. 继续验证平面推拉、孔径调整、槽/半孔宽度调整、薄壁厚度调整、盲孔/盲槽深度调整、圆柱凸台直径调整和直接边长调整,记录哪些对象稳定、哪些对象会失败。 +3. 改进盲孔/槽底面识别,减少“看起来像 blind 但找不到可靠底面Face”的候选。 4. 继续加固已有圆角半径修改:扩大可成功场景,记录哪些圆角能 defeature + refillet,哪些需要更复杂的重建策略。 -5. 完善本软件编辑历史的导入/复放能力,让后续编辑不只是导出 JSON 留档。 -6. 最后集中做性能和稳定性优化,包括首次加载后台精细化、扫描节流和高风险 OCC 操作隔离。 +5. 完善本软件编辑历史的导入/复放能力;当前 JSON v2 已保存机器可读的动作名和参数,但自动复放还需要按操作类型逐步加安全规则。 +6. 继续做性能和稳定性优化,重点是扫描分批刷新、局部显示重建缓存,以及高风险 OCC 操作隔离。 ## 怎么运行 @@ -336,6 +357,18 @@ python main.py python main.py path\to\model.step ``` +打开简单立方体测试模型: + +```powershell +python main.py assets\models\cube_10mm.step +``` + +重新生成简单立方体测试模型: + +```powershell +python scripts\generate_cube_step.py +``` + 如果当前 PowerShell 没识别 `conda`,可以直接使用完整路径运行: ```powershell @@ -348,6 +381,12 @@ C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python main.py python main.py --smoke-test ``` +验证立方体上的“只改一条 Edge 长度”基线: + +```powershell +python scripts\verify_edge_length_resize.py +``` + ## 怎么使用 打开程序后,右侧是 3D 模型窗口,左侧是操作面板。 @@ -358,7 +397,7 @@ python main.py --smoke-test - 鼠标滚轮:缩放模型。 - 鼠标中键或组合拖动:平移模型,具体取决于 VTK 默认交互方式。 -左侧操作面板按功能分区排列,每个模块都有四边同色的独立边框和颜色提示,方便快速区分文件、模型结构树、选择、显示、导出、编辑、候选列表、历史记录和对象信息。 +左侧操作面板按功能分区排列,每个模块都有四边同色的独立边框和颜色提示,顺序大致按工作流排列:先打开和选择模型,再做编辑或导出,然后进行显示控制和测量,后面继续放可编辑对象、圆柱面候选、操作历史和对象信息。 左侧 `鼠标选择模式` 用来切换选择模式: @@ -367,10 +406,10 @@ python main.py --smoke-test - `Face`:选择面。 - `Edge`:选择边。 - `Feature`:按局部特征候选方式选择。平面会识别为可推拉平面候选;圆柱面会进一步识别为圆柱孔、槽/半孔、圆角/倒圆、凸台/外圆等候选。 - 圆柱候选会额外显示侧壁 face、端面 face、疑似底面 face、开口端相邻 face 和边界 edge。 + 圆柱面候选会额外显示侧壁 Face、端面 Face、疑似底面 Face、开口端相邻 Face 和边界 Edge。 - 鼠标悬停时,当前模式下将要选中的对象会显示为红色;真正选中后会显示为黄色。 -- 如果当前模式是 `Edge`,即使鼠标点到的是面,程序也会从这个面的边界里选取距离鼠标拾取点最近的 edge。 -- `鼠标选择模式` 下拉框不响应鼠标滚轮,鼠标停在上面滚动时不会自动切换选项;需要点击下拉框后手动选择。 +- 如果当前模式是 `Edge`,即使鼠标点到的是面,程序也会从这个面的边界里选取距离鼠标拾取点最近的Edge。 +- `鼠标选择模式` 下拉框关闭时不会吃掉滚轮,鼠标停在上面滚动会继续滚动左侧面板;点击打开下拉列表后,滚轮才用于滚动选项。 左侧 `模型结构树` 会显示 STEP 里的装配、零件和实体层级;如果某个零件下有实体,会展开显示 `实体 0`、`实体 1` 这样的子节点。点击实体子节点会自动切换到 `Solid` 选择,并高亮对应实体。 @@ -388,18 +427,18 @@ python main.py --smoke-test - 按 ID 选择会复用上方 `鼠标选择模式` 的当前类型;先切换模式,再输入 ID,点击 `选择`。 - 输入框右侧会同步显示当前类型,例如 `Face`、`Edge` 或 `Solid`,用于确认这个 ID 会按哪类对象解释。 - `Part` 的 ID 从 1 开始。 -- `Solid`、`Face`、`Edge`、`Feature` 的 ID 从 0 开始;`Feature` 使用来源 face 的 ID。 -- 这个功能适合配合圆柱候选表格、操作历史和属性面板使用。 +- `Solid`、`Face`、`Edge`、`Feature` 的 ID 从 0 开始;`Feature` 使用来源Face的 ID。 +- 这个功能适合配合圆柱面候选表格、操作历史和属性面板使用。 左侧 `显示` 用来临时缩小三维视图范围: -- `只显示选中`:只显示当前选中的 part、solid、face、edge 或 feature 区域。 -- Face 模式下 `只显示选中` 会隔离当前黄色同域面区域,并显示这片区域的外边界 edge。 -- `对准选中`:不改变显示范围,只把相机对准当前选中的 part、solid、face、edge 或 feature,适合快速查看小孔、小边和局部特征。 +- `只显示选中`:只显示当前选中的 Part、Solid、Face、Edge 或 Feature 区域。 +- Face 模式下 `只显示选中` 会隔离当前黄色同域面区域,并显示这片区域的外边界Edge。 +- `对准选中`:不改变显示范围,只把相机对准当前选中的 Part、Solid、Face、Edge 或 Feature,适合快速查看小孔、小边和局部特征。 - `显示全部`:恢复显示完整模型。 - `显示同域内部边`:默认关闭,用来隐藏同一平面/同一圆柱面内部的拓扑分割边和几何重复拼接边;打开后可以查看完整 B-Rep 拓扑边线,适合调试布尔推拉后的分面情况。 -- `导出选中面区域` 会导出当前 Face 模式黄色高亮的同域面区域,而不是只导出鼠标点到的单个拓扑 face。 -- solid / edge 的显示查询已经使用拓扑索引,避免每次隔离或高亮时反复全模型查找。 +- `导出选中面区域` 会导出当前 Face 模式黄色高亮的同域面区域,而不是只导出鼠标点到的单个拓扑Face。 +- Solid / Edge 的显示查询已经使用拓扑索引,避免每次隔离或高亮时反复全模型查找。 - 这是视图操作,不会修改 STEP 几何,也不会写入操作历史。 左侧 `测量` 用来做最基础的点到点距离检查: @@ -412,29 +451,29 @@ python main.py --smoke-test - `复制测量` 会复制当前测量结果;`清除测量` 会清空 A/B 和测量线。 - 测量不会修改模型,也不会写入操作历史。 -左侧 `第一版可编辑对象` 是当前 MVP 的主要入口: +左侧 `可编辑对象` 是当前 MVP 的主要入口: -- 点击 `扫描可编辑对象` 会列出当前第一版能尝试编辑的对象。 +- 点击 `扫描对象` 会列出当前版本能尝试编辑的对象。 - 点击 `深度扫描` 会用更高上限重新扫描,可能需要等待更久。 - 为了避免打开大模型时界面卡住,程序不会在加载 STEP 时自动扫描;需要编辑时再手动点击扫描。 -- 当前默认显示一批代表性的可编辑候选,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。 -- `推拉平面` 行表示这个 face 是平面,可以配合 `面偏移` 和 `推拉平面` 使用。 -- `调整圆柱孔径` 行表示这个 face 是圆柱候选,可以配合 `孔直径` 和 `调整圆柱孔径` 使用。 -- `调整槽/半孔宽度` 行表示这个 face 是槽/半孔候选,可以配合 `槽/半孔宽度` 和 `调整槽/半孔宽度` 使用。 -- `调整圆柱凸台直径` 行表示这个 face 是较明确的完整圆柱凸台候选,可以配合 `凸台直径` 和 `调整圆柱凸台直径` 使用。 -- `封堵圆柱孔` 行表示这个 face 是接近完整圆柱的孔候选,可以点击后直接使用 `封堵圆柱孔`。 -- `调整盲孔深度` 行表示这个 face 是较明确的盲孔/盲槽候选,可以配合 `孔深度` 和 `调整盲孔深度` 使用。 -- `给边添加圆角` 行表示这个对象是直线 edge,可以配合 `圆角半径` 和 `给边添加圆角` 使用。 -- `给边添加倒角` 行表示这个对象是直线 edge,可以配合 `倒角距离` 和 `给边添加倒角` 使用。 -- `修改已有圆角半径` 行表示这个 face 是已有圆角/倒圆候选,点击后会切到 `Feature` 模式并预填圆角半径参考值;真正执行需要再点击编辑区里的 `修改已有圆角半径` 按钮并确认。 +- 当前默认显示一批代表性的可编辑对象,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。 +- `推拉平面` 行表示这个Face是平面,可以配合 `Face偏移` 和 `推拉平面` 使用。 +- `调整圆柱孔径` 行表示这个 Face 是圆柱面候选,可以配合 `孔直径` 和 `调整圆柱孔径` 使用。 +- `调整槽/半孔宽度` 行表示这个Face是槽/半孔候选,可以配合 `槽/半孔宽度` 和 `调整槽/半孔宽度` 使用。 +- `调整圆柱凸台直径` 行表示这个Face是较明确的完整圆柱凸台候选,可以配合 `凸台直径` 和 `调整圆柱凸台直径` 使用。 +- `封堵圆柱孔` 行表示这个Face是接近完整圆柱的孔候选,可以点击后直接使用 `封堵圆柱孔`。 +- `调整盲孔深度` 行表示这个Face是较明确的盲孔/盲槽候选,可以配合 `孔深度` 和 `调整盲孔深度` 使用。 +- `给Edge添加圆角` 行表示这个对象是直线Edge,可以配合 `圆角半径` 和 `给Edge添加圆角` 使用。 +- `给Edge添加倒角` 行表示这个对象是直线Edge,可以配合 `倒角距离` 和 `给Edge添加倒角` 使用。 +- `修改已有圆角半径` 行表示这个Face是已有圆角/倒圆候选,点击后会切到 `Feature` 模式并预填圆角半径参考值;真正执行需要再点击编辑区里的 `修改已有圆角半径` 按钮并确认。 - `状态` 为 `ready` 表示比较适合尝试,`caution` 表示可以尝试但风险更高,`blocked` 表示当前参数或对象不适合执行。 - `风险` 越高越应该谨慎,尤其是圆角、凸柱或未明确圆柱面,不要直接当孔修改。 -- 点击任意一行会自动选中并高亮对应 face 或 edge。 +- 点击任意一行会自动选中并高亮对应Face或Edge。 选择对象后: - 鼠标经过对象时会出现红色预高亮;被选对象会显示为黄色高亮。 -- 在 `Face` 模式下,如果相邻 face 属于同一几何面,并且投影范围或轴向范围相接/重叠,程序会把它们作为同域区域一起红色预高亮和黄色选中高亮;这主要用于缓解平面/圆柱推拉后视觉上被拓扑边分成多段的问题。 +- 在 `Face` 模式下,如果相邻Face属于同一几何面,并且投影范围或轴向范围相接/重叠,程序会把它们作为同域区域一起红色预高亮和黄色选中高亮;这主要用于缓解平面/圆柱推拉后视觉上被拓扑边分成多段的问题。 - 如果处于 `Feature` 模式,程序会尽量高亮这个特征相关的局部区域,例如圆柱侧壁、相邻端面和槽边界相邻面。 - 对槽/半孔候选,属性表会显示 `槽宽估算`、`槽圆弧长度估算`、`槽深估算` 和 `槽边界相邻 Face`。 - 对圆角/倒圆候选,属性表会显示 `已有圆角半径估算`、`已有圆角圆弧角度`、`已有圆角圆弧长度估算` 和 `已有圆角支撑 Face`。 @@ -447,54 +486,40 @@ python main.py --smoke-test - `复制信息` 会复制当前信息面板里的完整文本。 - 用鼠标在模型上选择时,`选中对象信息` 和窗口底部状态栏会显示 `pick_position`,也就是拾取点的三维坐标。 - 如果选中圆柱面,会自动给 `Hole diameter` 填一个略大的建议直径。 -- 如果选中直线 edge,会自动给 `圆角半径` 和 `倒角距离` 填一个保守的建议值,并把 `边目标长度` 填成当前边长。 +- 如果选中直线Edge,会自动给 `圆角半径` 和 `倒角距离` 填一个保守的建议值,并把 `Edge目标长度` 填成当前Edge长度。 -圆柱候选: +圆柱面候选: -- 点击 `列出圆柱候选` 会刷新候选表格。 -- 圆柱候选也是手动扫描,避免加载模型和普通选择操作被候选识别拖慢。 +- 点击 `扫描圆柱面` 会刷新候选表格。 +- 圆柱面候选也是手动扫描,避免加载模型和普通选择操作被候选识别拖慢。 - 可以通过候选表格上方的类型下拉框筛选候选;筛选只过滤已经扫描出的缓存结果,不会重新做几何识别。 -- 类型下拉框不响应鼠标滚轮,避免鼠标经过时误切换筛选条件。 -- 当前默认显示前 60 个圆柱候选;如果模型很复杂,扫描仍可能需要等待一会儿,但扫描过程中界面会尽量保持响应。 +- 类型下拉框关闭时不会吃掉滚轮,鼠标停在上面滚动会继续滚动左侧面板;点击打开下拉列表后,滚轮才用于滚动选项。 +- 当前默认显示前 60 个圆柱面候选;如果模型很复杂,扫描仍可能需要等待一会儿,但扫描过程中界面会尽量保持响应。 - 表格里的每一行对应一个圆柱面候选。 -- 点击候选行会自动切到 `Feature` 模式,并选中对应 face。 +- 点击候选行会自动切到 `Feature` 模式,并选中对应Face。 - `guess` 列是程序通过几何采样得到的初步判断,不是 STEP 文件里自带的 CAD 历史特征。 -- `round/fillet candidate` 是圆角/倒圆候选;部分规整圆柱圆角可以通过 `修改已有圆角半径` 进行第一版受限修改。 +- `round/fillet candidate` 是圆角/倒圆候选;部分规整圆柱圆角可以通过 `修改已有圆角半径` 进行当前版本受限修改。 - `diameter` 是圆柱直径。 - `span` 是圆柱面的角度跨度,接近 `6.283` 表示接近完整圆柱,接近 `3.142` 表示半圆柱。 - `height` 是根据圆柱面积估算出来的高度。 - `confidence` 是当前猜测置信度。 - 这些候选可能是孔、柱、圆角或其他圆柱面,目前还不是稳定的“孔识别”。 -导出: +编辑: -- `导出当前完整 STEP`:导出当前完整模型。 -- `导出选中零件`:导出当前选中的零件。 -- `导出选中 solid`:先选中一个 solid,再导出该 solid。 -- `导出选中 face`:先切换到 `Face` 选择模式并选中一个 face,再导出该 face。 -- `导出选中特征区域`:先切换到 `Feature` 选择模式并选中局部特征,再导出当前特征高亮的 face 集合。 -- `导出选中 edge`:先切换到 `Edge` 选择模式并选中一个 edge,再导出该 edge。 -- `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。 -- `修复当前模型`:对当前完整模型执行 ShapeFix 和同域面/边合并,完成后会刷新显示并写入操作历史;如果修复失败,会尝试回滚到修复前状态。 -- `修复选中零件/solid`:优先修复当前选中对象所属 solid;如果当前对象没有 solid,则修复所属零件。face 或 edge 被选中时,也会按其所属 solid/零件执行局部修复。 -- 导出按钮会根据当前选中对象自动启用或禁用,避免选中 face 时误点 `导出选中 edge` 这类不匹配的操作。 -- 所有导出按钮都会先做质量检查。发现 B-Rep 无效、没有 solid、零件包含多个 solid、体积接近 0 等风险时,会先弹窗确认是否继续导出。 - -实验性编辑: - -- 编辑按钮会根据当前选择自动启用或禁用。例如未选中平面时不能点击 `推拉平面`,未选中直线 edge 时不能点击 `给边添加圆角` / `给边添加倒角`,未选中明确孔/凸台/盲孔候选时对应圆柱编辑按钮会禁用。 -- `面偏移` + `推拉平面`: - - 先选择一个平面 face。 +- 编辑按钮会根据当前选择自动启用或禁用。例如未选中平面时不能点击 `推拉平面`,未选中直线Edge时不能点击 `给Edge添加圆角` / `给Edge添加倒角`,未选中明确孔/凸台/盲孔候选时对应圆柱编辑按钮会禁用。 +- `Face偏移` + `推拉平面`: + - 先选择一个平面Face。 - 输入偏移距离。 - 正数表示沿程序判断的外法向加料。 - 负数表示沿内侧方向切削。 - 点击 `推拉平面` 后,会先出现半透明预览体;绿色表示向外加料方向,红色表示向内切削方向。 - - 如果距离为 0 会直接阻止;如果方向置信度低或距离相对 face 尺寸偏大,会先弹窗确认。 - - 如果选中 face 周围有共面且相接/重叠的碎面,程序会自动把这些 face 合成同一片推拉区域,属性表和历史记录会显示 `推拉共面区域 Face 数`。 + - 如果距离为 0 会直接阻止;如果方向置信度低或距离相对Face尺寸偏大,会先弹窗确认。 + - 如果选中Face周围有共面且相接/重叠的碎面,程序会自动把这些Face合成同一片推拉区域,属性表和历史记录会显示 `推拉共面区域 Face 数`。 - 加料时程序会让拉伸体和原实体产生极小重叠,并在布尔后尝试合并同域面/边,使导出的 STEP 更像一个整体实体。 - 程序随后在后台执行真实布尔运算,完成后再刷新为真正修改后的模型。 - - 选中平面 face 后,可以在 `属性表` 的方向/轴线分组里查看 `推拉向外方向`、`推拉向内方向` 和 `推拉方向置信度`。 - - 当前方向判断通过 solid 的 inside / outside 采样得到;如果采样不明确,会退回到拓扑方向法向,并在属性里显示低置信度说明。 + - 选中平面Face后,可以在 `属性表` 的方向/轴线分组里查看 `推拉向外方向`、`推拉向内方向` 和 `推拉方向置信度`。 + - 当前方向判断通过Solid的 inside / outside 采样得到;如果采样不明确,会退回到拓扑方向法向,并在属性里显示低置信度说明。 - 当前实现仍使用布尔加/减验证推拉路线。 - `孔直径` + `调整圆柱孔径`: - 先选择一个圆柱面。 @@ -505,38 +530,45 @@ python main.py --smoke-test - 程序会先生成切削计划,检查当前直径、目标直径、候选类型、材料投票和风险。 - 切削计划会显示直径变化量、直径变化比例,以及目标直径相对圆柱面估算高度的比例。 - 切削计划会显示端部类型、深度估算、起点端状态和终点端状态。 - - 切削计划会显示疑似底面 face、开口端相邻 face 和底面保护说明;这些信息会写入操作历史。 + - 切削计划会显示疑似底面Face、开口端相邻Face和底面保护说明;这些信息会写入操作历史。 - 如果目标直径小于等于 0,或几乎等于当前直径,程序会直接阻止。 - 如果目标直径变化过大,或者目标直径相对圆柱估算高度明显异常,程序会提高风险并要求确认。 - - 缩小孔径第一版只对 `hole/groove candidate` 开放;圆角、凸柱或未明确圆柱面会被直接阻止。 + - 缩小孔径当前版本只对 `hole/groove candidate` 开放;圆角、凸柱或未明确圆柱面会被直接阻止。 - 缩小孔径属于高风险实验功能,因为它依赖“补料 + 重切”的布尔近似,不是 CAD 历史参数修改。 - - 如果当前 face 的猜测类型不是 `hole/groove candidate`,或风险不是低风险,程序会先弹窗确认。 + - 如果当前Face的猜测类型不是 `hole/groove candidate`,或风险不是低风险,程序会先弹窗确认。 - 切削计划会显示 cutter 策略、高度和余量;当前优先使用有限长度 cutter,减少误切其他区域。 - 缩小孔径时,操作历史会额外记录补料策略、补料半径和补料高度。 - 端部类型是通过轴线端部 inside / outside 采样得到的估算,不等于 CAD 原始建模历史里的“孔深”参数。 - 如果选到的圆柱面不是孔,而是柱或圆角,结果可能不是你想要的,所以它目前仍是实验功能。 - `槽/半孔宽度` + `调整槽/半孔宽度`: - - 先切换到 `Feature` 选择模式并选择一个槽/半孔候选 face,或从圆柱候选表中选择局部圆柱形 `hole/groove candidate`。 + - 先切换到 `Feature` 选择模式并选择一个槽/半孔候选 Face,或从圆柱面候选表中选择局部圆柱形 `hole/groove candidate`。 - 选中符合条件的槽/半孔后,`槽/半孔宽度` 会自动填入一个比当前槽宽估算略大的建议值。 - 输入目标槽宽后点击 `调整槽/半孔宽度`。 - 程序会用当前圆弧角度把目标槽宽换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 - 这一步仍是 B-Rep 几何近似;复杂槽、非圆柱槽或圆弧角度不稳定时可能失败并回滚。 +- `薄壁厚度` + `调整薄壁厚度`: + - 先选择一个平面 Face,建议从 `可编辑对象` 中点击 `调整薄壁厚度` 行进入。 + - 选中符合条件的薄壁/壳体平面后,`薄壁厚度` 会自动填入一个比当前估算厚度略大的建议值。 + - 输入目标厚度后点击 `调整薄壁厚度`。 + - 程序会移动当前平面区域,让它和相对平面的距离接近目标厚度;相对平面保持不动。 + - 如果相对平面重叠率低、厚度变化过大或推拉方向不稳定,会提高风险或阻止执行。 + - 这一步仍是局部 B-Rep 平面推拉近似,不是完整 CAD 壳体/薄壁特征参数编辑。 - `凸台直径` + `调整圆柱凸台直径`: - - 先选择一个完整圆柱凸台 face,建议从 `第一版可编辑对象` 中点击 `调整圆柱凸台直径` 行进入。 + - 先选择一个完整圆柱凸台 Face,建议从 `可编辑对象` 中点击 `调整圆柱凸台直径` 行进入。 - 只有接近完整圆柱的 `boss/outer-round candidate` 会放行;局部外圆角、圆角面、孔/槽和未明确圆柱面会被阻止。 - 目标直径大于当前直径时,会在同域圆柱侧壁范围内生成目标直径圆柱并 Fuse。 - 目标直径小于当前直径时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建。 - 点击后会显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。 - 这不是 CAD 历史里的“修改凸台特征参数”,而是基于当前 B-Rep 的受限几何布尔编辑。 - `封堵圆柱孔`: - - 先选择一个完整圆柱孔 face,建议从 `第一版可编辑对象` 中点击 `封堵圆柱孔` 行进入。 + - 先选择一个完整圆柱孔 Face,建议从 `可编辑对象` 中点击 `封堵圆柱孔` 行进入。 - 点击 `封堵圆柱孔` 后,程序会先生成补料计划。 - 只有接近完整圆柱的 `hole/groove candidate` 会放行;半孔/槽、圆角、凸柱、未明确圆柱面会被阻止。 - 通过检查后,会显示绿色半透明补料体预览。 - 真实编辑会把补料体和当前零件 Fuse,并尝试合并同域面/边。 - - 这个功能适合封堵通孔或盲孔,但仍是基于 B-Rep 几何的实验性补料,不是 CAD 历史里的“删除孔特征”。 + - 这个功能适合封堵通孔或盲孔,但仍是基于 B-Rep 几何的受限补料,不是 CAD 历史里的“删除孔特征”。 - `孔深度` + `调整盲孔深度`: - - 先选择一个盲孔或盲槽的圆柱面,建议从 `第一版可编辑对象` 中点击 `调整盲孔深度` 行进入。 + - 先选择一个盲孔或盲槽的圆柱面,建议从 `可编辑对象` 中点击 `调整盲孔深度` 行进入。 - 选中符合条件的盲孔/盲槽后,`孔深度` 会自动填入一个比当前估算深度略大的建议值。 - 输入目标深度后点击 `调整盲孔深度`。 - 目标深度大于当前估算深度时,程序会用有限长度 cutter 沿孔方向加深切削,预览显示为红色。 @@ -545,67 +577,73 @@ python main.py --smoke-test - 当前只支持 `hole/groove candidate`、端部类型为 `blind`、并且已经识别到 `疑似底面 Face` 的对象。 - 通孔、封闭内腔、圆角、凸柱和未明确圆柱面会被阻止,避免误切。 - 这个深度来自圆柱面 V 参数范围和端部 inside / outside 采样,是几何估算,不是 CAD 历史参数。 - - 操作历史会记录当前深度、目标深度、深度变化比例、底面 face、开口端 face 和工具参数。 -- `圆角半径` + `给边添加圆角`: - - 先切换到 `Edge` 选择模式并选择一条直线 edge,或从 `第一版可编辑对象` 中点击 `给边添加圆角` 行进入。 - - 选中直线 edge 后,`圆角半径` 会自动填入一个较小的建议半径。 - - 输入目标圆角半径后点击 `给边添加圆角`。 - - 程序会先检查半径是否大于 0、是否相对边长过大,以及该 edge 是否检测到至少两个相邻 face。 + - 操作历史会记录当前深度、目标深度、深度变化比例、底面Face、开口端Face和工具参数。 +- `圆角半径` + `给Edge添加圆角`: + - 先切换到 `Edge` 选择模式并选择一条直线Edge,或从 `可编辑对象` 中点击 `给Edge添加圆角` 行进入。 + - 选中直线Edge后,`圆角半径` 会自动填入一个较小的建议半径。 + - 输入目标圆角半径后点击 `给Edge添加圆角`。 + - 程序会先检查半径是否大于 0、是否相对边长过大,以及该Edge是否检测到至少两个相邻Face。 - 点击后会显示蓝色半透明圆管预览,用于提示目标边和半径范围。 - 真实编辑会在后台调用 OCCT 倒圆 API,完成后刷新模型。 - 这是“给直线边添加新圆角”,不是修改已有圆角面。 - 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒圆;失败时程序会提示并尝试恢复编辑前状态。 - `圆角半径` + `修改已有圆角半径`: - - 先切换到 `Feature` 选择模式并选择一个已有圆角/倒圆候选 face,或从 `第一版可编辑对象` 中点击 `修改已有圆角半径` 行进入。 + - 先切换到 `Feature` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。 - 选中已有圆角候选后,`圆角半径` 会自动填入一个基于当前估算半径的参考目标值。 - 输入目标半径后点击 `修改已有圆角半径`。 - - 程序会先检查该 face 是否为 `round/fillet candidate`、是否识别到至少两个支撑 face、目标半径是否有效,以及当前零件是否为单 solid。 + - 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。 - 通过检查后,会显示蓝色半透明预览,表示将尝试移除并重建的已有圆角面。 - 真实编辑会先用 OCCT defeaturing 移除已有圆角面,再从恢复出的候选锐边中寻找可重新倒圆的边并调用 OCCT 倒圆 API。 - 这是 B-Rep 几何重建,不是 CAD 历史圆角参数编辑;复杂 blend、支撑面不明确或恢复锐边失败时会失败并回滚。 -- `倒角距离` + `给边添加倒角`: - - 先切换到 `Edge` 选择模式并选择一条直线 edge,或从 `第一版可编辑对象` 中点击 `给边添加倒角` 行进入。 - - 选中直线 edge 后,`倒角距离` 会自动填入一个较小的建议距离。 - - 输入目标倒角距离后点击 `给边添加倒角`。 - - 程序会先检查倒角距离是否大于 0、是否相对边长过大,以及该 edge 是否检测到至少两个相邻 face。 +- `倒角距离` + `给Edge添加倒角`: + - 先切换到 `Edge` 选择模式并选择一条直线Edge,或从 `可编辑对象` 中点击 `给Edge添加倒角` 行进入。 + - 选中直线Edge后,`倒角距离` 会自动填入一个较小的建议距离。 + - 输入目标倒角距离后点击 `给Edge添加倒角`。 + - 程序会先检查倒角距离是否大于 0、是否相对边长过大,以及该Edge是否检测到至少两个相邻Face。 - 点击后会显示橙色半透明圆管预览,用于提示目标边和倒角距离范围。 - 真实编辑会在后台调用 OCCT 倒角 API,完成后刷新模型。 - 当前只支持对称距离倒角,不支持两侧不同距离或距离+角度倒角。 - 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒角;失败时程序会提示并尝试恢复编辑前状态。 -- `边目标长度` + `直接修改边长`: - - 先切换到 `Edge` 选择模式并选择一条 edge,或从 `第一版可编辑对象` 中点击边长相关行进入。 - - 选中 edge 后,`边目标长度` 会自动填入当前边长。 - - 右侧边长基准下拉框可以选择 `自动`、`中心`、`固定起点` 或 `固定终点`。 - - 把它改成目标长度后点击 `直接修改边长`。 - - 直线 edge 会优先查找端点附近是否有可推拉的平面端面,并计算需要移动的端面 face 和推拉距离。 - - 圆形/圆弧 edge 会优先尝试复用相邻圆柱面的孔/槽或凸台直径编辑,并在确认窗口显示换算出来的目标直径。 - - `固定起点` 会尽量让起点不动、移动终点侧;`固定终点` 相反;`中心` 会让缩放 fallback 以 edge 中心为基准。 - - 如果没有明确端面或可复用圆柱面,程序会按所选基准尝试整体/轴向几何缩放 fallback;这会影响所属 part/solid 的其它尺寸,因此会弹出高风险确认。 +- `Edge目标长度` + `修改Edge长度`: + - 先切换到 `Edge` 选择模式并选择一条Edge,或从 `可编辑对象` 中点击Edge长度相关行进入。 + - 选中Edge后,`Edge目标长度` 会自动填入当前Edge长度。 + - 右侧Edge长度基准下拉框可以选择 `自动`、`中心`、`固定起点` 或 `固定终点`。 + - 把它改成目标长度后点击 `修改Edge长度`。 + - 对 `assets/models/cube_10mm.step` 这类全平面多面体,直线Edge会优先只移动被选Edge的端点并重建周边平面。 + - 这时正方体不会默认变成长方体;相邻面会跟着被移动的边自然变成梯形,必要时非共面的面会拆成三角面。 + - 如果确认窗口里的策略是 `local-edge-only-deform`,说明走的就是“只变这条边”的局部形变路径。 + - 如果模型不是简单全平面多面体,直线Edge会继续尝试端面推拉后备路径,并计算需要移动的端面Face和推拉距离。 + - 圆形/圆弧Edge会优先尝试复用相邻圆柱面的孔/槽或凸台直径编辑,并在确认窗口显示换算出来的目标直径。 + - 如果圆边没有可复用圆柱面,但能读取圆心和轴线,会走 `围绕圆边轴线径向缩放` 后备策略,尽量不改变轴向厚度/高度;执行后部分解析几何可能变成 B-spline。 + - 椭圆边、平面 B-spline / Bezier 边会尝试走 `围绕平面曲线法向径向缩放`;确认窗口会显示平面采样偏差,偏差不稳定时会回退到更保守的缩放 fallback。 + - `固定起点` 会让起点不动、移动终点;`固定终点` 相反;`中心` 会让两端各移动一半,只有进入缩放 fallback 时才会变成以Edge中心为基准缩放。 + - 如果没有明确端面或可复用圆柱面,程序会按所选基准尝试整体/轴向几何缩放 fallback;这会影响所属Part/Solid的其它尺寸,因此会弹出高风险确认。 + - 执行成功后会做后置边长校验:在刷新后的Part/Solid范围内寻找最接近目标长度的Edge,并把实际最近边长、误差和相对误差写入操作结果。 - 通过检查后,会显示蓝绿色半透明预览,表示将要移动或缩放的范围。 - 真实编辑在后台执行,失败时会回滚到编辑前状态。 - - 这是第一版 B-Rep 结果几何修改,不是任意边参数化改长。 -- `平移 X/Y/Z` + `平移选中零件` / `平移选中 solid`: + - 这是当前版本的 B-Rep 结果几何修改,不是任意边参数化改长。 +- `平移 X/Y/Z` + `平移选中零件` / `平移选中Solid`: - 输入 X、Y、Z 三个方向的平移量。 - - 选择 `Part` 后点击 `平移选中零件`,会移动整个 part。 - - 选择 `Solid`,或选择属于某个 solid 的 face/edge 后,点击 `平移选中 solid`,会移动当前 solid。 - - 如果当前 part 只有一个 solid,平移 solid 实际会移动整个 part shape,程序会提示中风险。 + - 选择 `Part` 后点击 `平移选中零件`,会移动整个Part。 + - 选择 `Solid`,或选择属于某个Solid的Face/Edge后,点击 `平移选中Solid`,会移动当前Solid。 + - 如果当前Part只有一个Solid,平移Solid实际会移动整个Part shape,程序会提示中风险。 - 平移向量为 0 会直接阻止。 - 如果平移距离相对对象包围盒过大,程序会提高风险并要求确认。 - 平移属于真实 B-Rep 变换,会写入操作历史,可以撤销/重做。 -- `旋转轴/角度` + `旋转选中零件` / `旋转选中 solid`: +- `旋转轴/角度` + `旋转选中零件` / `旋转选中Solid`: - 选择 X、Y 或 Z 旋转轴。 - 输入旋转角度,单位是度。 - - 选择 `Part` 后点击 `旋转选中零件`,会绕该 part 的包围盒中心旋转整个 part。 - - 选择 `Solid`,或选择属于某个 solid 的 face/edge 后,点击 `旋转选中 solid`,会绕该 solid 的包围盒中心旋转当前 solid。 - - 如果当前 part 只有一个 solid,旋转 solid 实际会旋转整个 part shape,程序会提示中风险。 + - 选择 `Part` 后点击 `旋转选中零件`,会绕该Part的包围盒中心旋转整个Part。 + - 选择 `Solid`,或选择属于某个Solid的Face/Edge后,点击 `旋转选中Solid`,会绕该Solid的包围盒中心旋转当前Solid。 + - 如果当前Part只有一个Solid,旋转Solid实际会旋转整个Part shape,程序会提示中风险。 - 旋转角度为 0 会直接阻止;角度超过 360 度会提高风险并要求确认。 - 旋转属于真实 B-Rep 变换,会写入操作历史,可以撤销/重做。 - `撤销`: - - 撤销上一次成功的实验性编辑。 + - 撤销上一次成功的编辑。 - `重做`: - - 重做刚刚撤销的实验性编辑。 + - 重做刚刚撤销的编辑。 - `操作历史`: - - 显示当前模型已经成功执行的实验性编辑。 + - 显示当前模型已经成功执行的编辑。 - 点击某条历史记录,会在 `选中对象信息` 中显示该次编辑的目标、参数、执行结果和拓扑数量变化。 - 历史详情也会显示几何差异摘要,包括体积、表面积、包围盒尺寸和包围盒对角线的前后变化。 - 点击某条历史记录时,3D 窗口会显示红/绿半透明差异叠加预览:红色表示编辑前,绿色表示编辑后。 @@ -613,13 +651,27 @@ python main.py --smoke-test - 历史详情会显示热力图统计,包括最大距离、平均距离和发生明显变化的采样点比例。 - `清除差异预览` 可以关闭红/绿叠加显示。 - `导出差异报告` 会把当前选中的历史记录导出为 `.txt`,里面包含操作详情、拓扑变化、几何变化和热力图统计。 - - `导出编辑历史` 会把当前会话中的本软件编辑记录导出为 `.json`,用于留档和后续复盘。 + - `导出编辑历史` 会把当前会话中的本软件编辑记录导出为 `.json`,用于留档和后续复盘;当前格式是 `step-editor-operation-history-v2`,包含机器可读的动作名、目标和参数。 - 当前热力图是“编辑后模型顶点到编辑前模型表面”的距离估算,不是完整 CAD 公差报告。 - - 点击某条历史记录时,程序也会尝试重新高亮当时编辑的目标 face,并用青色小点标出当时的拾取位置。 - - 由于布尔编辑后拓扑 ID 可能重新分配,历史里的 face ID 只能作为定位参考,不能视为稳定的 CAD 建模历史 ID。 + - 点击某条历史记录时,程序也会尝试重新高亮当时编辑的目标Face,并用青色小点标出当时的拾取位置。 + - 由于布尔编辑后拓扑 ID 可能重新分配,历史里的Face ID只能作为定位参考,不能视为稳定的 CAD 建模历史 ID。 - 撤销后对应记录会从列表里退回。 - 重做后对应记录会重新显示。 +导出: + +- `导出当前完整 STEP`:导出当前完整模型。 +- `导出选中零件`:导出当前选中的零件。 +- `导出选中Solid`:先选中一个Solid,再导出该Solid。 +- `导出选中面区域`:先切换到 `Face` 选择模式并选中一个Face,再导出该Face或同域面区域。 +- `导出选中特征区域`:先切换到 `Feature` 选择模式并选中局部特征,再导出当前特征高亮的Face集合。 +- `导出选中Edge`:先切换到 `Edge` 选择模式并选中一个Edge,再导出该Edge。 +- `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。 +- `修复当前模型`:对当前完整模型执行 ShapeFix 和同域面/边合并,完成后会刷新显示并写入操作历史;如果修复失败,会尝试回滚到修复前状态。 +- `修复选中零件/Solid`:优先修复当前选中对象所属Solid;如果当前对象没有Solid,则修复所属零件。Face或Edge被选中时,也会按其所属Solid/零件执行局部修复。 +- 导出按钮会根据当前选中对象自动启用或禁用,避免选中Face时误点 `导出选中Edge` 这类不匹配的操作。 +- 所有导出按钮都会先做质量检查。发现 B-Rep 无效、没有Solid、零件包含多个Solid、体积接近 0 等风险时,会先弹窗确认是否继续导出。 + ## 目录结构 当前整理后的项目结构是: @@ -627,8 +679,14 @@ python main.py --smoke-test ```text pythonocc-step-editor/ main.py # 程序入口,保持 python main.py 启动 - docs/ - screenshots/ # 调试截图和问题截图 + assets/ + models/ + geom_extract.step # 当前默认测试模型 + cube_10mm.step # 简单立方体测试模型,用于验证边长修改等基础操作 + screenshots/ # 调试截图和问题截图,默认不提交 + scripts/ + generate_cube_step.py # 生成 assets/models/cube_10mm.step 的小工具 + verify_edge_length_resize.py # 验证立方体 Edge 长度局部形变基线 step_editor/ __init__.py # 包导出 app.py # 主窗口初始化、UI 搭建和程序启动 @@ -640,10 +698,10 @@ pythonocc-step-editor/ model.py # StepModel 核心状态、拓扑索引、基础属性和 mixin 组合 model_types.py # PartNode、TopologyStats 等模型数据结构 operations.py # 推拉、孔径、孔深、圆角、倒角、边长等实际编辑操作 - polydata.py # OCC shape / edge / face 到 VTK polydata 的显示数据生成 + polydata.py # OCC Shape / Edge / Face 到 VTK polydata 的显示数据生成 records.py # 操作历史记录数据结构 step_io.py # STEP/XCAF 读取、产品名解析和 STEP 写出 - transforms.py # part / solid 平移和旋转 + transforms.py # Part / Solid 平移和旋转 ui_helpers.py # UI 常量、格式化函数和 VTK 显示小工具 widgets.py # 自定义 Qt 小组件 window_actions.py # 导出、编辑按钮、扫描、后台编辑调度等窗口动作 @@ -651,11 +709,10 @@ pythonocc-step-editor/ window_state.py # 选择状态、按钮状态、撤销/重做和历史定位 workers.py # 后台编辑和后台扫描 worker environment.yml # Conda 环境说明,用于安装 pythonocc-core、VTK、PySide6 等依赖 - geom_extract.step # 当前默认测试模型 README.md # 项目说明和使用说明 ``` -这次整理把根目录收窄到入口、环境配置、默认模型和文档,主要代码放到 `step_editor/` 包里;问题截图和调试截图放到 `docs/screenshots/`。后端和窗口层都按 mixin / 工具模块拆开,尽量不改变功能行为。 +这次整理把根目录收窄到入口、环境配置和说明文档;仓库自带 STEP 测试模型放到 `assets/models/`,问题截图和调试截图放到 `assets/screenshots/`。`environment.yml` 保留在根目录,方便 Conda 用户直接发现并运行 `conda env create -f environment.yml`。后端和窗口层都按 mixin / 工具模块拆开,尽量不改变功能行为。 `step_editor/model.py` 现在主要保留 StepModel 的核心状态、拓扑缓存、基础属性查询和 mixin 组合;具体能力分散到 `features.py`、`operations.py`、`export.py`、`transforms.py`、`polydata.py`、`step_io.py` 和 `geometry_utils.py`。 @@ -665,10 +722,117 @@ pythonocc-step-editor/ - `.gitignore`:Git 忽略规则,避免提交 Python 缓存、临时文件和导出的 STEP 文件。 - `*_edited.step`、`*_export.step`、`*_diff_*.txt`、`*_operation_history.json`:运行或导出时产生的结果文件,默认不提交。 -- `docs/screenshots/`:调试截图和问题截图,例如第三方软件显示效果、模型结构树截图等。 +- `assets/models/`:仓库自带的 STEP 测试模型,可以提交;运行时导出的 STEP 结果仍然默认忽略。 +- `assets/screenshots/`:调试截图和问题截图,例如第三方软件显示效果、模型结构树截图等,默认不提交。 ## 当前版本边界 这一版重点是验证整体链路,不是最终 CAD 编辑器。 -模型显示、选择、属性查看、撤销/重做和导出是当前主要能力。局部编辑已经接入,但仍属于实验性能力。复杂 STEP 几何上的布尔操作可能失败;失败时程序会弹窗提示,不会静默覆盖原始文件。 +模型显示、选择、属性查看、撤销/重做和导出是当前主要能力。局部编辑已经接入,但仍属于受限 B-Rep 能力。复杂 STEP 几何上的布尔操作可能失败;失败时程序会弹窗提示,不会静默覆盖原始文件。 + +## Codex 项目记忆(开发者可忽略) + +这一节只给后续重新打开的 Codex 聊天框使用,普通开发者不需要阅读。Codex 接手时先读这里,再读本 README 的“目录结构”和“当前版本边界”。 + +### 项目目标 + +本项目是一个基于 Python、pythonocc-core/OCCT、VTK 和 PySide6/Qt 的 STEP 查看与局部编辑原型。它处理的是 STEP 里的 B-Rep 结果几何,不是原 CAD 软件的完整参数化建模历史。 + +最小系统目标是打通:加载 STEP、显示模型、选择 Part/Solid/Face/Edge/Feature、查看属性、识别初步候选特征、执行受限局部编辑、撤销/重做、质量检查和导出。 + +### 运行方式 + +常用命令: + +```powershell +conda activate pyocc +python main.py +python main.py assets\models\cube_10mm.step +python scripts\generate_cube_step.py +python scripts\verify_edge_length_resize.py +python main.py --smoke-test +``` + +如果 shell 里没有 `conda`,用户机器上之前可用的方式是: + +```powershell +C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python main.py +``` + +当前 Codex 沙盒环境可能没有 VTK/pythonocc,因此 GUI 和 smoke test 可能报 `ModuleNotFoundError: vtk`。能做的本地验证至少包括: + +```powershell +python -m compileall -q main.py step_editor scripts +git diff --check +``` + +### 项目结构提示 + +- `main.py`:入口,保持 `python main.py` 可启动。 +- `assets/models/`:仓库自带 STEP 测试模型,可以提交。默认模型是 `geom_extract.step`,简单边长测试模型是 `cube_10mm.step`。 +- `assets/screenshots/`:调试截图和问题截图,默认忽略,不提交。 +- `scripts/generate_cube_step.py`:生成 `assets/models/cube_10mm.step`。 +- `scripts/verify_edge_length_resize.py`:加载 `cube_10mm.step`,把一条 10mm 直线 Edge 改到 15mm,并断言策略是 `local-edge-only-deform`、目标边长误差在容差内。 +- `environment.yml`:保留在根目录,方便 Conda 用户直接创建环境。 +- `step_editor/app.py`:主窗口初始化、左侧操作面板和入口。 +- `step_editor/window_core.py`:文件加载、结构树、VTK、拾取、高亮、预览和首次/精细加载。 +- `step_editor/window_actions.py`:导出按钮、编辑动作、后台任务、扫描、操作历史记录生成。 +- `step_editor/window_state.py`:选择状态、按钮启用/禁用、撤销/重做和历史定位。 +- `step_editor/model.py`:`StepModel` 核心状态和 mixin 组合。 +- `step_editor/features.py`:可编辑对象和圆柱面/孔/槽/凸台/圆角等识别计划。 +- `step_editor/operations.py`:推拉、孔径、孔深、圆角、倒角、薄壁、边长等真实几何编辑。 +- `step_editor/export.py`:STEP 导出和质量检查。 +- `step_editor/polydata.py`:OCCT shape/face/edge 到 VTK polydata。 +- `step_editor/widgets.py`:Qt 自定义小控件,例如不误吃滚轮的下拉框。 + +### 左侧面板工作流 + +左侧面板前半段按用户指定顺序排列:STEP 文件、模型结构树、鼠标选择模式、按 ID 选择、编辑、导出、显示、测量。后半段继续放可编辑对象、圆柱面候选、操作历史和对象信息。 + +下拉框关闭时不能吃掉滚轮,鼠标停在下拉框上滚动应继续滚动左侧面板;只有打开下拉列表后,滚轮才用于滚动选项。 + +控件悬停提示必须面向普通用户说明“这个控件是干什么的、什么时候可用、失败后会怎样”,不要只写内部名词。 + +### 当前关键能力 + +- STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。 +- 选择:支持 Part、Solid、Face、Edge、Feature。鼠标悬停红色预高亮,选中黄色高亮。 +- 同域面:Face/Feature 会尽量把共面或同轴同半径的连续碎面合成逻辑区域。 +- 编辑:平面推拉、孔径、槽/半孔宽度、盲孔/盲槽深度、凸台直径、已有圆角半径当前版本修改、Edge圆角、Edge倒角、薄壁厚度、修改Edge长度、Part/Solid 平移和旋转。 +- 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。 +- 导出:完整模型、Part、Solid、Face、Feature 区域和 Edge,导出前做基础质量检查。 + +### 修改Edge长度 + +这是当前继续深入的功能,但仍是 B-Rep 结果几何编辑,不是参数化历史恢复。 + +当前策略顺序: + +1. `local-edge-only-deform`:直线Edge位于简单全平面多面体时,优先只移动被选Edge的端点并重建所属Solid/Part。对立方体测试模型,默认固定起点移动终点;相邻面会自然变成梯形,必要时非共面四边面拆三角面,而不是把整个正方体缩放成长方体。 +2. `move-edge-end-plane-by-push-pull`:如果不适合局部形变,尝试找端点附近、方向匹配的平面端面,把改边长转成端面推拉。 +3. `resize-adjacent-cylinder-from-circular-edge-length`:圆形/圆弧Edge尝试复用相邻圆柱孔/槽或凸台直径编辑。 +4. `scale-owning-shape-from-edge`:圆边、椭圆边和可采样为平面的非直线Edge会优先用 `radial-affine` 围绕曲线平面法向做径向缩放;其他无明确局部路径的Edge才按Edge轴向或整体做几何缩放 fallback,风险高,会影响其它尺寸。 + +确认窗口和操作历史里要尽量记录策略、目标范围、目标Edge长度、基准、移动端点、重建面数和风险,方便用户判断结果是否符合预期。 + +最近一次基线验证:在 `assets/models/cube_10mm.step` 上选第一条直线Edge,将 10mm 改为 15mm,计划策略为 `local-edge-only-deform`,内存执行成功。执行后边长分布为 `10:9, 11.1803:2, 14.1421:1, 15:1`,Face数从 6 变 7,说明没有整体拉成长方体,而是局部重建并拆分了一个非共面面。 + +新增基线验证:内存构造椭圆Edge,将长度放大 1.25 倍,计划策略为 `radial-affine`,通过预变换Edge长度把缩放比例从 `1.25` 微调到约 `1.25354`,结果边长贴合目标。另一个圆锥圆边先径向缩放后会变成 B-spline,再选这个平面 B-spline Edge 放大 1.1 倍,计划会走 `sampled edge best-fit plane`,采样偏差约 `3.6e-15`,结果边长贴合目标。 + +后置校验基线:修改Edge长度完成后,结果消息会包含 `match`、`nearest_edge`、`nearest_length`、`target_error`、`relative_error`、`endpoint_error` 和校验范围;局部边形变、端面推拉和几何缩放会优先按预期端点匹配目标Edge,长度匹配只作为兜底。操作历史摘要也会带上 `actual_length` 和 `target_error`,用于提醒用户真实 B-Rep 刷新后的最近Edge长度是否贴近目标值。 + +### 当前边界 + +- STEP 通常不包含可稳定恢复的 CAD 建模历史;不要承诺“任意场景都能像原 CAD 参数一样修改”。 +- 复杂槽、复杂凸台、复杂圆角链、带内孔Face、曲面混合拓扑和大装配仍可能失败。 +- 布尔和 ShapeFix 可能耗时或失败,编辑动作应继续走后台 worker、预览和失败回滚。 +- 操作历史是本软件加载后的编辑留档,不是 STEP 原始建模历史。 + +### 提交与忽略 + +- `assets/models/*.step` 中用于测试的模型可以提交。 +- `assets/screenshots/` 默认忽略。 +- 运行导出的 `*_edited.step`、`*_export.step`、`*_diff_*.txt`、`*_operation_history.json` 默认不提交。 +- 不要把 `environment.yml` 移进子目录,它留在根目录更符合 Conda 用户预期。 +- 工作区可能有用户改动,不能随意 `git checkout --`、`git reset --hard` 或回滚不相关文件。 diff --git a/assets/models/cube_10mm.step b/assets/models/cube_10mm.step new file mode 100644 index 0000000..ab750b3 --- /dev/null +++ b/assets/models/cube_10mm.step @@ -0,0 +1,140 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('Simple 10 mm cube generated by Python for edge edit tests'),'2;1'); +FILE_NAME('cube_10mm.step','2026-07-28T10:25:56',('Codex'),('OpenAI'),'Python ASCII STEP generator','python-occt',''); +FILE_SCHEMA(('AUTOMOTIVE_DESIGN_CC2')); +ENDSEC; +DATA; +#1=APPLICATION_CONTEXT('automotive_design'); +#2=APPLICATION_PROTOCOL_DEFINITION('international standard','automotive_design',2000,#1); +#3=PRODUCT_CONTEXT('',#1,'mechanical'); +#4=PRODUCT('CUBE_10MM','CUBE_10MM','Simple 10 mm cube generated for edge editing tests',(#3)); +#5=PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE('1','generated',#4,.NOT_KNOWN.); +#6=PRODUCT_DEFINITION_CONTEXT('part definition',#1,'design'); +#7=PRODUCT_DEFINITION('design','',#5,#6); +#8=PRODUCT_DEFINITION_SHAPE('','',#7); +#9=(LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.)); +#10=(NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.)); +#11=(NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT()); +#12=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#9,'distance_accuracy_value',''); +#13=(GEOMETRIC_REPRESENTATION_CONTEXT(3) GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#12)) GLOBAL_UNIT_ASSIGNED_CONTEXT((#9,#10,#11)) REPRESENTATION_CONTEXT('3D Context','')); +#14=CARTESIAN_POINT('',(0.000000,0.000000,0.000000)); +#15=CARTESIAN_POINT('',(10.000000,0.000000,0.000000)); +#16=CARTESIAN_POINT('',(10.000000,10.000000,0.000000)); +#17=CARTESIAN_POINT('',(0.000000,10.000000,0.000000)); +#18=CARTESIAN_POINT('',(0.000000,0.000000,10.000000)); +#19=CARTESIAN_POINT('',(10.000000,0.000000,10.000000)); +#20=CARTESIAN_POINT('',(10.000000,10.000000,10.000000)); +#21=CARTESIAN_POINT('',(0.000000,10.000000,10.000000)); +#22=DIRECTION('',(1.000000,0.000000,0.000000)); +#23=DIRECTION('',(-1.000000,0.000000,0.000000)); +#24=DIRECTION('',(0.000000,1.000000,0.000000)); +#25=DIRECTION('',(0.000000,-1.000000,0.000000)); +#26=DIRECTION('',(0.000000,0.000000,1.000000)); +#27=DIRECTION('',(0.000000,0.000000,-1.000000)); +#28=AXIS2_PLACEMENT_3D('',#14,#26,#22); +#29=VERTEX_POINT('',#14); +#30=VERTEX_POINT('',#15); +#31=VERTEX_POINT('',#16); +#32=VERTEX_POINT('',#17); +#33=VERTEX_POINT('',#18); +#34=VERTEX_POINT('',#19); +#35=VERTEX_POINT('',#20); +#36=VERTEX_POINT('',#21); +#37=VECTOR('',#22,1.000000); +#38=LINE('',#14,#37); +#39=EDGE_CURVE('E1',#29,#30,#38,.T.); +#40=VECTOR('',#24,1.000000); +#41=LINE('',#15,#40); +#42=EDGE_CURVE('E2',#30,#31,#41,.T.); +#43=VECTOR('',#23,1.000000); +#44=LINE('',#16,#43); +#45=EDGE_CURVE('E3',#31,#32,#44,.T.); +#46=VECTOR('',#25,1.000000); +#47=LINE('',#17,#46); +#48=EDGE_CURVE('E4',#32,#29,#47,.T.); +#49=VECTOR('',#22,1.000000); +#50=LINE('',#18,#49); +#51=EDGE_CURVE('E5',#33,#34,#50,.T.); +#52=VECTOR('',#24,1.000000); +#53=LINE('',#19,#52); +#54=EDGE_CURVE('E6',#34,#35,#53,.T.); +#55=VECTOR('',#23,1.000000); +#56=LINE('',#20,#55); +#57=EDGE_CURVE('E7',#35,#36,#56,.T.); +#58=VECTOR('',#25,1.000000); +#59=LINE('',#21,#58); +#60=EDGE_CURVE('E8',#36,#33,#59,.T.); +#61=VECTOR('',#26,1.000000); +#62=LINE('',#14,#61); +#63=EDGE_CURVE('E9',#29,#33,#62,.T.); +#64=VECTOR('',#26,1.000000); +#65=LINE('',#15,#64); +#66=EDGE_CURVE('E10',#30,#34,#65,.T.); +#67=VECTOR('',#26,1.000000); +#68=LINE('',#16,#67); +#69=EDGE_CURVE('E11',#31,#35,#68,.T.); +#70=VECTOR('',#26,1.000000); +#71=LINE('',#17,#70); +#72=EDGE_CURVE('E12',#32,#36,#71,.T.); +#73=ORIENTED_EDGE('',*,*,#48,.F.); +#74=ORIENTED_EDGE('',*,*,#45,.F.); +#75=ORIENTED_EDGE('',*,*,#42,.F.); +#76=ORIENTED_EDGE('',*,*,#39,.F.); +#77=EDGE_LOOP('',(#73,#74,#75,#76)); +#78=FACE_OUTER_BOUND('',#77,.T.); +#79=AXIS2_PLACEMENT_3D('',#14,#27,#22); +#80=PLANE('',#79); +#81=ADVANCED_FACE('BOTTOM_Z0',(#78),#80,.T.); +#82=ORIENTED_EDGE('',*,*,#51,.T.); +#83=ORIENTED_EDGE('',*,*,#54,.T.); +#84=ORIENTED_EDGE('',*,*,#57,.T.); +#85=ORIENTED_EDGE('',*,*,#60,.T.); +#86=EDGE_LOOP('',(#82,#83,#84,#85)); +#87=FACE_OUTER_BOUND('',#86,.T.); +#88=AXIS2_PLACEMENT_3D('',#18,#26,#22); +#89=PLANE('',#88); +#90=ADVANCED_FACE('TOP_Z10',(#87),#89,.T.); +#91=ORIENTED_EDGE('',*,*,#39,.T.); +#92=ORIENTED_EDGE('',*,*,#66,.T.); +#93=ORIENTED_EDGE('',*,*,#51,.F.); +#94=ORIENTED_EDGE('',*,*,#63,.F.); +#95=EDGE_LOOP('',(#91,#92,#93,#94)); +#96=FACE_OUTER_BOUND('',#95,.T.); +#97=AXIS2_PLACEMENT_3D('',#14,#25,#22); +#98=PLANE('',#97); +#99=ADVANCED_FACE('FRONT_Y0',(#96),#98,.T.); +#100=ORIENTED_EDGE('',*,*,#72,.T.); +#101=ORIENTED_EDGE('',*,*,#57,.F.); +#102=ORIENTED_EDGE('',*,*,#69,.F.); +#103=ORIENTED_EDGE('',*,*,#45,.T.); +#104=EDGE_LOOP('',(#100,#101,#102,#103)); +#105=FACE_OUTER_BOUND('',#104,.T.); +#106=AXIS2_PLACEMENT_3D('',#17,#24,#22); +#107=PLANE('',#106); +#108=ADVANCED_FACE('BACK_Y10',(#105),#107,.T.); +#109=ORIENTED_EDGE('',*,*,#63,.T.); +#110=ORIENTED_EDGE('',*,*,#60,.F.); +#111=ORIENTED_EDGE('',*,*,#72,.F.); +#112=ORIENTED_EDGE('',*,*,#48,.T.); +#113=EDGE_LOOP('',(#109,#110,#111,#112)); +#114=FACE_OUTER_BOUND('',#113,.T.); +#115=AXIS2_PLACEMENT_3D('',#14,#23,#24); +#116=PLANE('',#115); +#117=ADVANCED_FACE('LEFT_X0',(#114),#116,.T.); +#118=ORIENTED_EDGE('',*,*,#42,.T.); +#119=ORIENTED_EDGE('',*,*,#69,.T.); +#120=ORIENTED_EDGE('',*,*,#54,.F.); +#121=ORIENTED_EDGE('',*,*,#66,.F.); +#122=EDGE_LOOP('',(#118,#119,#120,#121)); +#123=FACE_OUTER_BOUND('',#122,.T.); +#124=AXIS2_PLACEMENT_3D('',#15,#22,#24); +#125=PLANE('',#124); +#126=ADVANCED_FACE('RIGHT_X10',(#123),#125,.T.); +#127=CLOSED_SHELL('',(#81,#90,#99,#108,#117,#126)); +#128=MANIFOLD_SOLID_BREP('CUBE_10MM',#127); +#129=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#28,#128),#13); +#130=SHAPE_DEFINITION_REPRESENTATION(#8,#129); +#131=PRODUCT_RELATED_PRODUCT_CATEGORY('part','',(#4)); +ENDSEC; +END-ISO-10303-21; diff --git a/geom_extract.step b/assets/models/geom_extract.step similarity index 100% rename from geom_extract.step rename to assets/models/geom_extract.step diff --git a/scripts/generate_cube_step.py b/scripts/generate_cube_step.py new file mode 100644 index 0000000..670c384 --- /dev/null +++ b/scripts/generate_cube_step.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import argparse +from datetime import datetime +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_OUTPUT = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step" + + +def write_cube_step(path: Path, size: float = 10.0) -> None: + entities: list[str] = [] + + def add(text: str) -> int: + index = len(entities) + 1 + entities.append(f"#{index}={text};") + return index + + def ref(index: int) -> str: + return f"#{index}" + + def refs(indices) -> str: + return ",".join(ref(index) for index in indices) + + app_ctx = add("APPLICATION_CONTEXT('automotive_design')") + add(f"APPLICATION_PROTOCOL_DEFINITION('international standard','automotive_design',2000,{ref(app_ctx)})") + prod_ctx = add(f"PRODUCT_CONTEXT('',{ref(app_ctx)},'mechanical')") + product = add( + "PRODUCT('CUBE_10MM','CUBE_10MM','Simple 10 mm cube generated for edge editing tests'," + f"({ref(prod_ctx)}))" + ) + formation = add( + f"PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE('1','generated',{ref(product)},.NOT_KNOWN.)" + ) + pd_ctx = add(f"PRODUCT_DEFINITION_CONTEXT('part definition',{ref(app_ctx)},'design')") + product_def = add(f"PRODUCT_DEFINITION('design','',{ref(formation)},{ref(pd_ctx)})") + product_shape = add(f"PRODUCT_DEFINITION_SHAPE('','',{ref(product_def)})") + length_unit = add("(LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.))") + angle_unit = add("(NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.))") + solid_angle_unit = add("(NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT())") + uncertainty = add( + f"UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),{ref(length_unit)}," + "'distance_accuracy_value','')" + ) + geom_context = add( + "(GEOMETRIC_REPRESENTATION_CONTEXT(3) " + f"GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT(({ref(uncertainty)})) " + f"GLOBAL_UNIT_ASSIGNED_CONTEXT(({refs([length_unit, angle_unit, solid_angle_unit])})) " + "REPRESENTATION_CONTEXT('3D Context',''))" + ) + + point_ids: dict[str, int] = {} + for name, xyz in { + "P1": (0.0, 0.0, 0.0), + "P2": (size, 0.0, 0.0), + "P3": (size, size, 0.0), + "P4": (0.0, size, 0.0), + "P5": (0.0, 0.0, size), + "P6": (size, 0.0, size), + "P7": (size, size, size), + "P8": (0.0, size, size), + }.items(): + point_ids[name] = add("CARTESIAN_POINT('',({:.6f},{:.6f},{:.6f}))".format(*xyz)) + + directions = { + "+X": add("DIRECTION('',(1.000000,0.000000,0.000000))"), + "-X": add("DIRECTION('',(-1.000000,0.000000,0.000000))"), + "+Y": add("DIRECTION('',(0.000000,1.000000,0.000000))"), + "-Y": add("DIRECTION('',(0.000000,-1.000000,0.000000))"), + "+Z": add("DIRECTION('',(0.000000,0.000000,1.000000))"), + "-Z": add("DIRECTION('',(0.000000,0.000000,-1.000000))"), + } + world_axis = add( + f"AXIS2_PLACEMENT_3D('',{ref(point_ids['P1'])},{ref(directions['+Z'])},{ref(directions['+X'])})" + ) + + vertex_ids = { + name.replace("P", "V"): add(f"VERTEX_POINT('',{ref(point_id)})") + for name, point_id in point_ids.items() + } + + edge_specs = { + "E1": ("V1", "V2", "P1", "+X"), + "E2": ("V2", "V3", "P2", "+Y"), + "E3": ("V3", "V4", "P3", "-X"), + "E4": ("V4", "V1", "P4", "-Y"), + "E5": ("V5", "V6", "P5", "+X"), + "E6": ("V6", "V7", "P6", "+Y"), + "E7": ("V7", "V8", "P7", "-X"), + "E8": ("V8", "V5", "P8", "-Y"), + "E9": ("V1", "V5", "P1", "+Z"), + "E10": ("V2", "V6", "P2", "+Z"), + "E11": ("V3", "V7", "P3", "+Z"), + "E12": ("V4", "V8", "P4", "+Z"), + } + edge_ids: dict[str, int] = {} + for name, (start, end, start_point, direction) in edge_specs.items(): + vector = add(f"VECTOR('',{ref(directions[direction])},1.000000)") + line = add(f"LINE('',{ref(point_ids[start_point])},{ref(vector)})") + edge_ids[name] = add( + f"EDGE_CURVE('{name}',{ref(vertex_ids[start])},{ref(vertex_ids[end])},{ref(line)},.T.)" + ) + + def oriented(edge_name: str, same: bool) -> int: + orientation = ".T." if same else ".F." + return add(f"ORIENTED_EDGE('',*,*,{ref(edge_ids[edge_name])},{orientation})") + + def make_plane(origin_point: str, normal: str, ref_dir: str) -> int: + axis = add( + f"AXIS2_PLACEMENT_3D('',{ref(point_ids[origin_point])}," + f"{ref(directions[normal])},{ref(directions[ref_dir])})" + ) + return add(f"PLANE('',{ref(axis)})") + + faces: list[int] = [] + face_specs = [ + ("BOTTOM_Z0", [("E4", False), ("E3", False), ("E2", False), ("E1", False)], "P1", "-Z", "+X"), + ("TOP_Z10", [("E5", True), ("E6", True), ("E7", True), ("E8", True)], "P5", "+Z", "+X"), + ("FRONT_Y0", [("E1", True), ("E10", True), ("E5", False), ("E9", False)], "P1", "-Y", "+X"), + ("BACK_Y10", [("E12", True), ("E7", False), ("E11", False), ("E3", True)], "P4", "+Y", "+X"), + ("LEFT_X0", [("E9", True), ("E8", False), ("E12", False), ("E4", True)], "P1", "-X", "+Y"), + ("RIGHT_X10", [("E2", True), ("E11", True), ("E6", False), ("E10", False)], "P2", "+X", "+Y"), + ] + for name, loop_edges, plane_origin, normal, ref_dir in face_specs: + loop = add(f"EDGE_LOOP('',({refs(oriented(edge, same) for edge, same in loop_edges)}))") + bound = add(f"FACE_OUTER_BOUND('',{ref(loop)},.T.)") + plane = make_plane(plane_origin, normal, ref_dir) + faces.append(add(f"ADVANCED_FACE('{name}',({ref(bound)}),{ref(plane)},.T.)")) + + closed_shell = add(f"CLOSED_SHELL('',({refs(faces)}))") + solid = add(f"MANIFOLD_SOLID_BREP('CUBE_10MM',{ref(closed_shell)})") + shape_rep = add( + f"ADVANCED_BREP_SHAPE_REPRESENTATION('',({refs([world_axis, solid])}),{ref(geom_context)})" + ) + add(f"SHAPE_DEFINITION_REPRESENTATION({ref(product_shape)},{ref(shape_rep)})") + add(f"PRODUCT_RELATED_PRODUCT_CATEGORY('part','',({ref(product)}))") + + header = "\n".join( + [ + "ISO-10303-21;", + "HEADER;", + "FILE_DESCRIPTION(('Simple 10 mm cube generated by Python for edge edit tests'),'2;1');", + ( + f"FILE_NAME('{path.name}','{datetime.now().isoformat(timespec='seconds')}'," + "('Codex'),('OpenAI'),'Python ASCII STEP generator','python-occt','');" + ), + "FILE_SCHEMA(('AUTOMOTIVE_DESIGN_CC2'));", + "ENDSEC;", + "DATA;", + ] + ) + path.write_text(header + "\n" + "\n".join(entities) + "\nENDSEC;\nEND-ISO-10303-21;\n", encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate a simple STEP cube for editor tests.") + parser.add_argument("output", nargs="?", default=str(DEFAULT_OUTPUT), help="Output STEP file path.") + parser.add_argument("--size", type=float, default=10.0, help="Cube edge length in millimeters.") + args = parser.parse_args() + + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + write_cube_step(output, args.size) + print(f"wrote {output.resolve()} ({output.stat().st_size} bytes)") + print(f"cube bounds: 0,0,0 to {args.size:g},{args.size:g},{args.size:g} mm") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_edge_length_resize.py b/scripts/verify_edge_length_resize.py new file mode 100644 index 0000000..7ff6e7b --- /dev/null +++ b/scripts/verify_edge_length_resize.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import argparse +from collections import Counter +from pathlib import Path +import sys + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from step_editor.model import StepModel + +DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step" + + +def _edge_length(model: StepModel, edge_id: int) -> float: + return float(model.edge_info(edge_id).get("length", 0.0)) + + +def _line_edge_ids_near_length(model: StepModel, length: float, tolerance: float) -> list[int]: + edge_ids: list[int] = [] + for edge_id in range(len(model.edges)): + info = model.edge_info(edge_id) + if info.get("curve") != "line": + continue + if abs(float(info.get("length", 0.0)) - length) <= tolerance: + edge_ids.append(edge_id) + return edge_ids + + +def _length_distribution(model: StepModel) -> dict[float, int]: + counts = Counter(round(_edge_length(model, edge_id), 6) for edge_id in range(len(model.edges))) + return dict(sorted(counts.items())) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify cube edge-length local deformation.") + parser.add_argument("model", nargs="?", default=str(DEFAULT_MODEL), help="STEP model path.") + parser.add_argument("--source-length", type=float, default=10.0, help="Current line edge length to search for.") + parser.add_argument("--target-length", type=float, default=15.0, help="Target edge length to apply.") + parser.add_argument("--anchor", default="keep-start", choices=["auto", "center", "keep-start", "keep-end"]) + parser.add_argument("--tolerance", type=float, default=1e-5, help="Allowed target length error.") + args = parser.parse_args() + + model = StepModel.load(Path(args.model)) + edge_ids = _line_edge_ids_near_length(model, args.source_length, args.tolerance) + if not edge_ids: + raise SystemExit(f"no line edge near source length {args.source_length:g}") + + edge_id = edge_ids[0] + before = model.stats() + plan = model.general_edge_length_plan(edge_id, args.target_length, anchor_mode=args.anchor) + strategy = str(plan.get("resize_strategy", "")) + if strategy != "local-edge-only-deform": + raise SystemExit(f"expected local-edge-only-deform, got {strategy or ''}") + + result = model.resize_general_edge_length(edge_id, args.target_length, anchor_mode=args.anchor) + after = model.stats() + lengths = [_edge_length(model, item) for item in range(len(model.edges))] + nearest = min(lengths, key=lambda value: abs(value - args.target_length)) + error = abs(nearest - args.target_length) + if error > args.tolerance: + raise SystemExit(f"target length check failed: nearest={nearest:g}, error={error:g}") + + print(f"model={Path(args.model)}") + print(f"edge_id={edge_id}") + print(f"strategy={strategy}") + print(f"anchor_mode={args.anchor}") + print(f"start_move={plan.get('local_edge_deform_start_move')}") + print(f"end_move={plan.get('local_edge_deform_end_move')}") + print(f"before_faces={before.faces} before_edges={before.edges}") + print(f"after_faces={after.faces} after_edges={after.edges}") + print(f"nearest_length={nearest:.6f} target_error={error:.6g}") + print(f"length_distribution={_length_distribution(model)}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/step_editor/app.py b/step_editor/app.py index cb59971..873f4f0 100644 --- a/step_editor/app.py +++ b/step_editor/app.py @@ -49,6 +49,8 @@ from .window_state import WindowStateMixin _CRASH_LOG_HANDLE = None +PROJECT_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "geom_extract.step" def _enable_crash_log() -> None: @@ -85,8 +87,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.hover_face_actor = None self.hover_edge_actor = None self.hover_signature: tuple[str, int] | None = None - self.hover_interval_ms = 45 - self.hover_move_threshold_px = 0 + self.hover_interval_ms = 85 + self.hover_move_threshold_px = 5 self.pending_hover_position: tuple[int, int] | None = None self.last_hover_pick_position: tuple[int, int] | None = None self.pointer_button_down = False @@ -132,13 +134,14 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.scan_thread: QThread | None = None self.scan_worker: ScanWorker | None = None self.pending_scan_kind: str | None = None + self.pending_scan_context: dict[str, object] | None = None self.load_in_progress = False self.load_thread: QThread | None = None self.load_worker: LoadWorker | None = None self.load_refine_thread: QThread | None = None self.load_refine_worker: LoadWorker | None = None self.pending_load_path: Path | None = None - self.initial_load_deflection = 0.8 + self.initial_load_deflection = 2.0 self.last_id_kind = "Face" self._build_ui() @@ -169,56 +172,154 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf panel_scroll.setStyleSheet( """ QScrollArea { - background: #f4f7fb; + background: #edf2f8; border: none; } QScrollArea > QWidget > QWidget { - background: #f4f7fb; + background: #edf2f8; + } + QScrollBar:vertical { + background: #e3eaf2; + border: none; + border-radius: 5px; + margin: 0; + width: 10px; + } + QScrollBar::handle:vertical { + background: #aab8c8; + border-radius: 5px; + min-height: 36px; + } + QScrollBar::handle:vertical:hover { + background: #8798ad; + } + QScrollBar::add-line:vertical, + QScrollBar::sub-line:vertical { + height: 0; + width: 0; } QGroupBox { background: #ffffff; - border: 2px solid #6b7cff; + border: 1px solid #d4dde8; + border-left: 5px solid #6b7cff; border-radius: 8px; color: #1f2937; - margin-top: 12px; - padding: 12px 10px 10px 10px; + margin-top: 14px; + padding: 15px 10px 10px 12px; } QGroupBox::title { subcontrol-origin: margin; - left: 12px; - padding: 0 6px; + left: 14px; + padding: 2px 8px; color: #172033; - background: #f4f7fb; + background: #ffffff; + border: 1px solid #d4dde8; + border-radius: 7px; font-weight: 700; } QGroupBox#fileSection { - border-color: #3478f6; + background: #f7fbff; + border: 1px solid #bdd3ff; + border-left: 5px solid #3478f6; + } + QGroupBox#fileSection::title { + background: #eaf2ff; + border-color: #bdd3ff; + color: #1d4ed8; } QGroupBox#treeSection { - border-color: #0f9f8f; + background: #f2fbf8; + border: 1px solid #b8dfd6; + border-left: 5px solid #0f9f8f; + } + QGroupBox#treeSection::title { + background: #e2f5ef; + border-color: #b8dfd6; + color: #0f766e; } QGroupBox#modeSection, QGroupBox#idSection { - border-color: #6b5cff; + background: #f7f5ff; + border: 1px solid #cbc4ff; + border-left: 5px solid #6b5cff; + } + QGroupBox#modeSection::title, + QGroupBox#idSection::title { + background: #eeeaff; + border-color: #cbc4ff; + color: #5642c7; } QGroupBox#viewSection { - border-color: #0e9bd8; + background: #f1faff; + border: 1px solid #b6dff2; + border-left: 5px solid #0e9bd8; + } + QGroupBox#viewSection::title { + background: #e3f4fd; + border-color: #b6dff2; + color: #0369a1; + } + QGroupBox#measureSection { + background: #f1fbfb; + border: 1px solid #b6e0e1; + border-left: 5px solid #14a3a8; + } + QGroupBox#measureSection::title { + background: #e0f5f6; + border-color: #b6e0e1; + color: #0f777b; } QGroupBox#exportSection { - border-color: #1f9d55; + background: #f4fbf3; + border: 1px solid #bfdcbd; + border-left: 5px solid #1f9d55; + } + QGroupBox#exportSection::title { + background: #e8f5e7; + border-color: #bfdcbd; + color: #1f7a3d; } QGroupBox#editSection { - border-color: #d97706; + background: #fff8ef; + border: 1px solid #edc98e; + border-left: 5px solid #d97706; + } + QGroupBox#editSection::title { + background: #fff0d8; + border-color: #edc98e; + color: #a45303; } QGroupBox#editableSection, QGroupBox#candidateSection { - border-color: #b45309; + background: #fff7ed; + border: 1px solid #e6c297; + border-left: 5px solid #b45309; + } + QGroupBox#editableSection::title, + QGroupBox#candidateSection::title { + background: #ffefd7; + border-color: #e6c297; + color: #92400e; } QGroupBox#historySection { - border-color: #be3b6b; + background: #fff5f8; + border: 1px solid #edb8cb; + border-left: 5px solid #be3b6b; + } + QGroupBox#historySection::title { + background: #fde7ef; + border-color: #edb8cb; + color: #9d2854; } QGroupBox#infoSection { - border-color: #64748b; + background: #f8fafc; + border: 1px solid #cbd5e1; + border-left: 5px solid #64748b; + } + QGroupBox#infoSection::title { + background: #eef2f7; + border-color: #cbd5e1; + color: #475569; } QLabel { color: #2f3846; @@ -243,7 +344,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf font-weight: 700; } QPushButton { - background: #eef3f8; + background: #f8fafc; border: 1px solid #c8d2df; border-radius: 6px; color: #172033; @@ -252,16 +353,31 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf padding: 5px 8px; } QPushButton:hover { - background: #e3edf9; - border-color: #8fb0dc; + background: #eef6ff; + border-color: #77a7dd; } QPushButton:pressed { background: #d7e6f6; } QPushButton:disabled { - background: #eef0f3; - border-color: #dfe3ea; - color: #7a7f86; + background: #e3e8ef; + border: 1px dashed #b6c0cd; + color: #8f99a8; + font-weight: 500; + } + QLineEdit:disabled, + QComboBox:disabled, + QPlainTextEdit:disabled { + background: #eef2f6; + border: 1px dashed #bcc7d4; + color: #8f99a8; + } + QCheckBox:disabled, + QTabWidget:disabled, + QTreeWidget:disabled, + QTableWidget:disabled, + QListWidget:disabled { + color: #8f99a8; } QTreeWidget, QTableWidget, @@ -271,11 +387,12 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf border: 1px solid #d8e0eb; border-radius: 6px; color: #172033; - selection-background-color: #dceafe; + alternate-background-color: #f7fafd; + selection-background-color: #d7ecff; selection-color: #0f172a; } QHeaderView::section { - background: #edf2f7; + background: #e8eef6; border: 0; border-bottom: 1px solid #d8e0eb; color: #334155; @@ -315,14 +432,14 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf file_layout.addWidget(self.path_label) file_buttons = QHBoxLayout() - open_button = QPushButton("打开") - help_tip(open_button, "选择并打开一个 .step 或 .stp 文件。打开失败时会保留当前模型。") - open_button.clicked.connect(self.open_step) - reload_button = QPushButton("重新加载") - help_tip(reload_button, "从磁盘重新读取当前 STEP 文件,用于放弃本次会话里的临时查看状态。") - reload_button.clicked.connect(self.reload_step) - file_buttons.addWidget(open_button) - file_buttons.addWidget(reload_button) + self.open_button = QPushButton("打开") + help_tip(self.open_button, "选择并打开一个 .step 或 .stp 文件。打开失败时会保留当前模型。") + self.open_button.clicked.connect(self.open_step) + self.reload_button = QPushButton("重新加载") + help_tip(self.reload_button, "从磁盘重新读取当前 STEP 文件,用于放弃本次会话里的临时查看状态。") + self.reload_button.clicked.connect(self.reload_step) + file_buttons.addWidget(self.open_button) + file_buttons.addWidget(self.reload_button) file_layout.addLayout(file_buttons) panel_layout.addWidget(file_box) @@ -333,21 +450,22 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.part_tree = QTreeWidget() self.part_tree.setHeaderLabels(["对象", "内容"]) self.part_tree.setMinimumHeight(180) - help_tip(self.part_tree, "模型里的装配、零件和 solid 列表。点击一行可以选中并高亮对应对象。") + self.part_tree.setAlternatingRowColors(True) + help_tip(self.part_tree, "模型里的装配、零件和Solid列表。点击一行可以选中并高亮对应对象。") self.part_tree.currentItemChanged.connect(self.on_part_tree_select) tree_layout.addWidget(self.part_tree) panel_layout.addWidget(tree_box) mode_box = QGroupBox("鼠标选择模式") mode_box.setObjectName("modeSection") - help_tip(mode_box, "决定鼠标点模型时选中零件、solid、面、边,还是识别局部特征。") + help_tip(mode_box, "决定鼠标点模型时选中 Part、Solid、Face、Edge,还是识别局部特征。") mode_layout = QVBoxLayout(mode_box) self.mode_combo = NoWheelComboBox() self.mode_combo.addItems(["Part", "Solid", "Face", "Edge", "Feature"]) self.mode_combo.setCurrentText("Face") help_tip( self.mode_combo, - "选择鼠标点击模型时要选什么:零件、solid、面、边,或把面解释成孔/槽/圆角等特征候选。", + "选择鼠标点击模型时要选什么:Part、Solid、Face、Edge,或把 Face 解释成孔/槽/圆角等特征候选。", ) self.mode_combo.currentTextChanged.connect(self._on_mode_changed) mode_layout.addWidget(self.mode_combo) @@ -367,14 +485,15 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.id_mode_display.setAlignment(Qt.AlignmentFlag.AlignCenter) self.id_mode_display.setMinimumWidth(70) help_tip(self.id_mode_display, "当前按这个鼠标选择模式解释输入的 ID。它会跟随鼠标选择模式自动变化。") - select_button = QPushButton("选择") - help_tip(select_button, "按当前选择模式跳转到输入的 ID,并在 3D 视图中高亮它。") - select_button.clicked.connect(lambda _checked=False: self.select_by_id(self.mode_combo.currentText())) + self.select_id_button = QPushButton("选择") + help_tip(self.select_id_button, "按当前选择模式跳转到输入的 ID,并在 3D 视图中高亮它。") + self.select_id_button.clicked.connect(lambda _checked=False: self.select_by_id(self.mode_combo.currentText())) + self.id_input.textChanged.connect(lambda _text: self._update_action_states()) self.id_input.returnPressed.connect(lambda: self.select_by_id(self.mode_combo.currentText())) select_layout.addWidget(QLabel("ID"), 0, 0) select_layout.addWidget(self.id_input, 0, 1) select_layout.addWidget(self.id_mode_display, 0, 2) - select_layout.addWidget(select_button, 0, 3) + select_layout.addWidget(self.select_id_button, 0, 3) panel_layout.addWidget(select_box) view_box = QGroupBox("显示") @@ -382,18 +501,18 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf help_tip(view_box, "只改变视图显示方式,不会修改 STEP 几何。") view_layout = QVBoxLayout(view_box) view_buttons = QHBoxLayout() - isolate_button = QPushButton("只显示选中") - help_tip(isolate_button, "把视图临时隔离到当前选中的零件、solid、面、边或特征区域。不会修改模型。") - isolate_button.clicked.connect(self.isolate_selected) - fit_button = QPushButton("对准选中") - help_tip(fit_button, "把相机移动到当前选中对象附近,方便看清细节。不会修改模型。") - fit_button.clicked.connect(self.fit_selected) - show_all_button = QPushButton("显示全部") - help_tip(show_all_button, "取消隔离显示,恢复查看完整模型。不会修改模型。") - show_all_button.clicked.connect(self.show_all_geometry) - view_buttons.addWidget(isolate_button) - view_buttons.addWidget(fit_button) - view_buttons.addWidget(show_all_button) + self.isolate_button = QPushButton("只显示选中") + help_tip(self.isolate_button, "把视图临时隔离到当前选中的 Part、Solid、Face、Edge 或特征区域。不会修改模型。") + self.isolate_button.clicked.connect(self.isolate_selected) + self.fit_button = QPushButton("对准选中") + help_tip(self.fit_button, "把相机移动到当前选中对象附近,方便看清细节。不会修改模型。") + self.fit_button.clicked.connect(self.fit_selected) + self.show_all_button = QPushButton("显示全部") + help_tip(self.show_all_button, "取消隔离显示,恢复查看完整模型。不会修改模型。") + self.show_all_button.clicked.connect(self.show_all_geometry) + view_buttons.addWidget(self.isolate_button) + view_buttons.addWidget(self.fit_button) + view_buttons.addWidget(self.show_all_button) view_layout.addLayout(view_buttons) self.show_internal_edges_checkbox = QCheckBox("显示同域内部边") help_tip( @@ -402,10 +521,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf ) self.show_internal_edges_checkbox.toggled.connect(self._on_internal_edges_toggled) view_layout.addWidget(self.show_internal_edges_checkbox) - panel_layout.addWidget(view_box) measure_box = QGroupBox("测量") - measure_box.setObjectName("viewSection") + measure_box.setObjectName("measureSection") help_tip(measure_box, "把当前选中对象的拾取点或中心设为 A/B,计算两点距离和 X/Y/Z 差值。不会修改模型。") measure_layout = QVBoxLayout(measure_box) self.measure_text = QPlainTextEdit() @@ -413,25 +531,24 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.measure_text.setMaximumHeight(90) help_tip(self.measure_text, "显示 A 点、B 点、两点距离和各方向差值。") measure_buttons = QGridLayout() - set_measure_a_button = QPushButton("设为 A") - help_tip(set_measure_a_button, "把当前选中对象的拾取点设为测量点 A;没有拾取点时使用对象中心。") - set_measure_a_button.clicked.connect(lambda _checked=False: self.set_measure_point("A")) - set_measure_b_button = QPushButton("设为 B") - help_tip(set_measure_b_button, "把当前选中对象的拾取点设为测量点 B;没有拾取点时使用对象中心。") - set_measure_b_button.clicked.connect(lambda _checked=False: self.set_measure_point("B")) - copy_measure_button = QPushButton("复制测量") - help_tip(copy_measure_button, "复制当前测量结果文本,方便记录尺寸。") - copy_measure_button.clicked.connect(self.copy_measurement) - clear_measure_button = QPushButton("清除测量") - help_tip(clear_measure_button, "清除 A/B 测量点和 3D 测量线。不会影响模型。") - clear_measure_button.clicked.connect(self.clear_measurement) - measure_buttons.addWidget(set_measure_a_button, 0, 0) - measure_buttons.addWidget(set_measure_b_button, 0, 1) - measure_buttons.addWidget(copy_measure_button, 1, 0) - measure_buttons.addWidget(clear_measure_button, 1, 1) + self.set_measure_a_button = QPushButton("设为 A") + help_tip(self.set_measure_a_button, "把当前选中对象的拾取点设为测量点 A;没有拾取点时使用对象中心。") + self.set_measure_a_button.clicked.connect(lambda _checked=False: self.set_measure_point("A")) + self.set_measure_b_button = QPushButton("设为 B") + help_tip(self.set_measure_b_button, "把当前选中对象的拾取点设为测量点 B;没有拾取点时使用对象中心。") + self.set_measure_b_button.clicked.connect(lambda _checked=False: self.set_measure_point("B")) + self.copy_measure_button = QPushButton("复制测量") + help_tip(self.copy_measure_button, "复制当前测量结果文本,方便记录尺寸。") + self.copy_measure_button.clicked.connect(self.copy_measurement) + self.clear_measure_button = QPushButton("清除测量") + help_tip(self.clear_measure_button, "清除 A/B 测量点和 3D 测量线。不会影响模型。") + self.clear_measure_button.clicked.connect(self.clear_measurement) + measure_buttons.addWidget(self.set_measure_a_button, 0, 0) + measure_buttons.addWidget(self.set_measure_b_button, 0, 1) + measure_buttons.addWidget(self.copy_measure_button, 1, 0) + measure_buttons.addWidget(self.clear_measure_button, 1, 1) measure_layout.addWidget(self.measure_text) measure_layout.addLayout(measure_buttons) - panel_layout.addWidget(measure_box) self._refresh_measurement_panel() export_box = QGroupBox("导出") @@ -442,10 +559,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf help_tip(self.export_all_button, "把当前编辑后的整个模型导出为 STEP 文件。导出前会做基础质量检查。") self.export_all_button.clicked.connect(self.export_all) self.export_part_button = QPushButton("导出选中零件") - help_tip(self.export_part_button, "只导出当前选中的零件。适合从装配里拆出一个 part。") + help_tip(self.export_part_button, "只导出当前选中的零件。适合从装配里拆出一个单独零件。") self.export_part_button.clicked.connect(self.export_selected_part) - self.export_solid_button = QPushButton("导出选中 solid") - help_tip(self.export_solid_button, "只导出当前选中的实体 solid。适合检查或单独保存某个实体。") + self.export_solid_button = QPushButton("导出选中Solid") + help_tip(self.export_solid_button, "只导出当前选中的实体Solid。适合检查或单独保存某个实体。") self.export_solid_button.clicked.connect(self.export_selected_solid) self.export_face_button = QPushButton("导出选中面区域") help_tip(self.export_face_button, "导出当前选中的面区域;同域高亮的共面/同圆柱区域也会一起导出。") @@ -453,17 +570,17 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.export_feature_button = QPushButton("导出选中特征区域") help_tip(self.export_feature_button, "导出 Feature 模式识别到的局部特征区域,例如孔、槽、圆角或凸台候选。") self.export_feature_button.clicked.connect(self.export_selected_feature) - self.export_edge_button = QPushButton("导出选中 edge") - help_tip(self.export_edge_button, "导出当前选中的边。主要用于调试、定位或把边界单独拿出去检查。") + self.export_edge_button = QPushButton("导出选中Edge") + help_tip(self.export_edge_button, "导出当前选中的Edge。主要用于调试、定位或把边界单独拿出去检查。") self.export_edge_button.clicked.connect(self.export_selected_edge) self.export_check_button = QPushButton("检查导出质量") - help_tip(self.export_check_button, "检查当前导出对象是否像有效实体:B-Rep、face/edge 数量、体积和包围盒等。") + help_tip(self.export_check_button, "检查当前导出对象是否像有效实体:B-Rep、Face/Edge 数量、体积和包围盒等。") self.export_check_button.clicked.connect(self.check_export_quality) self.repair_model_button = QPushButton("修复当前模型") help_tip(self.repair_model_button, "对完整模型尝试 ShapeFix 和同域面/边合并。会写入历史,可撤销。") self.repair_model_button.clicked.connect(self.repair_model) - self.repair_selected_button = QPushButton("修复选中零件/solid") - help_tip(self.repair_selected_button, "只修复当前选中的零件或 solid,范围比修复完整模型更小。会写入历史,可撤销。") + self.repair_selected_button = QPushButton("修复选中零件/Solid") + help_tip(self.repair_selected_button, "只修复当前选中的零件或Solid,范围比修复完整模型更小。会写入历史,可撤销。") self.repair_selected_button.clicked.connect(self.repair_selected_shape) export_layout.addWidget(self.export_all_button) export_layout.addWidget(self.export_part_button) @@ -474,13 +591,12 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf export_layout.addWidget(self.export_check_button) export_layout.addWidget(self.repair_model_button) export_layout.addWidget(self.repair_selected_button) - panel_layout.addWidget(export_box) - edit_box = QGroupBox("实验性编辑") + edit_box = QGroupBox("编辑") edit_box.setObjectName("editSection") - help_tip(edit_box, "这里的按钮会真实修改当前 B-Rep 模型;执行前通常会预览,成功后可撤销。") + help_tip(edit_box, "这里的按钮会真实修改当前 B-Rep 模型;执行前通常会预览,成功后可撤销。部分能力仍是当前版本的几何近似。") edit_layout = QGridLayout(edit_box) - edit_layout.addWidget(QLabel("面偏移"), 0, 0) + edit_layout.addWidget(QLabel("Face偏移"), 0, 0) self.offset_input = QLineEdit("5.0") help_tip(self.offset_input, "平面推拉距离。正数通常向外加料,负数通常向内切削;单位沿用 STEP 模型单位。") edit_layout.addWidget(self.offset_input, 0, 1) @@ -503,7 +619,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf help_tip(self.slot_width_input, "槽或半孔的目标开口宽度。程序会把它换算成对应圆柱直径来执行。") edit_layout.addWidget(self.slot_width_input, 4, 1) self.resize_slot_button = QPushButton("调整槽/半孔宽度") - help_tip(self.resize_slot_button, "修改已识别槽/半孔候选的宽度。第一版是几何近似,失败会回滚。") + help_tip(self.resize_slot_button, "修改已识别槽/半孔候选的宽度。当前版本是几何近似,失败会回滚。") self.resize_slot_button.clicked.connect(self.resize_slot_width) edit_layout.addWidget(self.resize_slot_button, 5, 0, 1, 2) @@ -541,10 +657,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf edit_layout.addWidget(QLabel("圆角半径"), 11, 0) self.edge_fillet_radius_input = QLineEdit("") - help_tip(self.edge_fillet_radius_input, "新圆角或已有圆角的目标半径。选中边/圆角候选后会自动填参考值。") + help_tip(self.edge_fillet_radius_input, "新圆角或已有圆角的目标半径。选中Edge/圆角候选后会自动填参考值。") edit_layout.addWidget(self.edge_fillet_radius_input, 11, 1) - self.fillet_edge_button = QPushButton("给边添加圆角") - help_tip(self.fillet_edge_button, "给当前直线边新增圆角。不是修改已有圆角;已有圆角请用下面那个按钮。") + self.fillet_edge_button = QPushButton("给Edge添加圆角") + help_tip(self.fillet_edge_button, "给当前直线Edge新增圆角。不是修改已有圆角;已有圆角请用下面那个按钮。") self.fillet_edge_button.clicked.connect(self.fillet_edge) edit_layout.addWidget(self.fillet_edge_button, 12, 0, 1, 2) @@ -555,20 +671,20 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf edit_layout.addWidget(QLabel("倒角距离"), 14, 0) self.edge_chamfer_distance_input = QLineEdit("") - help_tip(self.edge_chamfer_distance_input, "给边添加倒角时使用的距离。选中直线边后会填一个较小参考值。") + help_tip(self.edge_chamfer_distance_input, "给Edge添加倒角时使用的距离。选中直线Edge后会填一个较小参考值。") edit_layout.addWidget(self.edge_chamfer_distance_input, 14, 1) - self.chamfer_edge_button = QPushButton("给边添加倒角") - help_tip(self.chamfer_edge_button, "给当前直线边新增对称倒角。会先预览,再后台执行,失败会回滚。") + self.chamfer_edge_button = QPushButton("给Edge添加倒角") + help_tip(self.chamfer_edge_button, "给当前直线Edge新增对称倒角。会先预览,再后台执行,失败会回滚。") self.chamfer_edge_button.clicked.connect(self.chamfer_edge) edit_layout.addWidget(self.chamfer_edge_button, 15, 0, 1, 2) - edit_layout.addWidget(QLabel("边目标长度"), 16, 0) + edit_layout.addWidget(QLabel("Edge目标长度"), 16, 0) edge_length_inputs = QWidget() edge_length_layout = QHBoxLayout(edge_length_inputs) edge_length_layout.setContentsMargins(0, 0, 0, 0) self.edge_target_length_input = QLineEdit("") - help_tip(self.edge_target_length_input, "当前 edge 的目标长度。选中边后会自动填当前长度,改成新长度再执行。") - self.edge_length_anchor_combo = QComboBox() + help_tip(self.edge_target_length_input, "当前Edge的目标长度。选中Edge后会自动填当前长度,改成新长度再执行。") + self.edge_length_anchor_combo = NoWheelComboBox() self.edge_length_anchor_combo.addItem("自动", "auto") self.edge_length_anchor_combo.addItem("中心", "center") self.edge_length_anchor_combo.addItem("固定起点", "keep-start") @@ -577,20 +693,35 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.edge_length_anchor_combo.setMinimumWidth(90) help_tip( self.edge_length_anchor_combo, - "选择边长修改时尽量固定哪里:自动会优先找局部端面;中心/起点/终点会影响缩放 fallback 的基准点。", + "选择修改Edge长度时尽量固定哪里:自动默认固定起点并移动终点;中心会两端各动一半;固定起点/终点会明确控制局部形变端点。", ) edge_length_layout.addWidget(self.edge_target_length_input, stretch=2) edge_length_layout.addWidget(self.edge_length_anchor_combo, stretch=1) edit_layout.addWidget(edge_length_inputs, 16, 1) - self.resize_edge_length_button = QPushButton("直接修改边长") + self.resize_edge_length_button = QPushButton("修改Edge长度") help_tip( self.resize_edge_length_button, - "直接修改当前 edge 长度。直线边优先端面推拉,圆边优先换算相邻圆柱直径,必要时再用几何缩放 fallback。", + "修改当前Edge长度。直线Edge优先局部形变;圆、椭圆和平面曲线会优先用局部/径向策略,必要时再用后备缩放。", ) self.resize_edge_length_button.clicked.connect(self.resize_any_edge_length) edit_layout.addWidget(self.resize_edge_length_button, 17, 0, 1, 2) - edit_layout.addWidget(QLabel("平移 X/Y/Z"), 18, 0) + edit_layout.addWidget(QLabel("薄壁厚度"), 18, 0) + self.shell_thickness_input = QLineEdit("") + help_tip( + self.shell_thickness_input, + "薄壁/壳体局部区域的目标厚度。选中有相对平面的平面候选后会自动填一个参考值。", + ) + edit_layout.addWidget(self.shell_thickness_input, 18, 1) + self.resize_shell_thickness_button = QPushButton("调整薄壁厚度") + help_tip( + self.resize_shell_thickness_button, + "移动当前平面区域来达到目标厚度。当前版本基于相对平面估算,执行前会预览并可回滚。", + ) + self.resize_shell_thickness_button.clicked.connect(self.resize_shell_thickness) + edit_layout.addWidget(self.resize_shell_thickness_button, 19, 0, 1, 2) + + edit_layout.addWidget(QLabel("平移 X/Y/Z"), 20, 0) translate_inputs = QWidget() translate_layout = QHBoxLayout(translate_inputs) translate_layout.setContentsMargins(0, 0, 0, 0) @@ -606,21 +737,21 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf translate_layout.addWidget(self.translate_x_input) translate_layout.addWidget(self.translate_y_input) translate_layout.addWidget(self.translate_z_input) - edit_layout.addWidget(translate_inputs, 18, 1) + edit_layout.addWidget(translate_inputs, 20, 1) self.translate_part_button = QPushButton("平移选中零件") help_tip(self.translate_part_button, "按上面的 X/Y/Z 距离移动当前零件。会写入历史,可撤销。") self.translate_part_button.clicked.connect(self.translate_selected_part) - self.translate_solid_button = QPushButton("平移选中 solid") - help_tip(self.translate_solid_button, "按上面的 X/Y/Z 距离移动当前 solid。单 solid 零件中相当于移动整个零件。") + self.translate_solid_button = QPushButton("平移选中Solid") + help_tip(self.translate_solid_button, "按上面的 X/Y/Z 距离移动当前Solid。单Solid零件中相当于移动整个零件。") self.translate_solid_button.clicked.connect(self.translate_selected_solid) - edit_layout.addWidget(self.translate_part_button, 19, 0, 1, 2) - edit_layout.addWidget(self.translate_solid_button, 20, 0, 1, 2) + edit_layout.addWidget(self.translate_part_button, 21, 0, 1, 2) + edit_layout.addWidget(self.translate_solid_button, 22, 0, 1, 2) - edit_layout.addWidget(QLabel("旋转轴/角度"), 21, 0) + edit_layout.addWidget(QLabel("旋转轴/角度"), 23, 0) rotate_inputs = QWidget() rotate_layout = QHBoxLayout(rotate_inputs) rotate_layout.setContentsMargins(0, 0, 0, 0) - self.rotate_axis_combo = QComboBox() + self.rotate_axis_combo = NoWheelComboBox() self.rotate_axis_combo.addItems(["X", "Y", "Z"]) self.rotate_axis_combo.setCurrentText("Z") help_tip(self.rotate_axis_combo, "选择旋转轴。对象会绕自身包围盒中心旋转。") @@ -629,20 +760,20 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf help_tip(self.rotate_angle_input, "旋转角度,单位是度。正负号决定旋转方向。") rotate_layout.addWidget(self.rotate_axis_combo) rotate_layout.addWidget(self.rotate_angle_input) - edit_layout.addWidget(rotate_inputs, 21, 1) + edit_layout.addWidget(rotate_inputs, 23, 1) self.rotate_part_button = QPushButton("旋转选中零件") help_tip(self.rotate_part_button, "绕所选轴旋转当前零件。会写入历史,可撤销。") self.rotate_part_button.clicked.connect(self.rotate_selected_part) - self.rotate_solid_button = QPushButton("旋转选中 solid") - help_tip(self.rotate_solid_button, "绕所选轴旋转当前 solid。单 solid 零件中相当于旋转整个零件。") + self.rotate_solid_button = QPushButton("旋转选中Solid") + help_tip(self.rotate_solid_button, "绕所选轴旋转当前Solid。单Solid零件中相当于旋转整个零件。") self.rotate_solid_button.clicked.connect(self.rotate_selected_solid) - edit_layout.addWidget(self.rotate_part_button, 22, 0, 1, 2) - edit_layout.addWidget(self.rotate_solid_button, 23, 0, 1, 2) + edit_layout.addWidget(self.rotate_part_button, 24, 0, 1, 2) + edit_layout.addWidget(self.rotate_solid_button, 25, 0, 1, 2) - self.cylinders_button = QPushButton("列出圆柱候选") - help_tip(self.cylinders_button, "扫描模型里的圆柱面,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。") + self.cylinders_button = QPushButton("扫描圆柱面") + help_tip(self.cylinders_button, "扫描模型里的圆柱面候选,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。") self.cylinders_button.clicked.connect(self.list_cylinders) - edit_layout.addWidget(self.cylinders_button, 24, 0, 1, 2) + edit_layout.addWidget(self.cylinders_button, 26, 0, 1, 2) self.undo_button = QPushButton("撤销") help_tip(self.undo_button, "撤销上一次成功编辑,把模型恢复到编辑前快照。") @@ -650,16 +781,19 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.redo_button = QPushButton("重做") help_tip(self.redo_button, "重做刚刚撤销的编辑。") self.redo_button.clicked.connect(self.redo_edit) - edit_layout.addWidget(self.undo_button, 25, 0) - edit_layout.addWidget(self.redo_button, 25, 1) + edit_layout.addWidget(self.undo_button, 27, 0) + edit_layout.addWidget(self.redo_button, 27, 1) panel_layout.addWidget(edit_box) + panel_layout.addWidget(export_box) + panel_layout.addWidget(view_box) + panel_layout.addWidget(measure_box) - editable_box = QGroupBox("第一版可编辑对象") + editable_box = QGroupBox("可编辑对象") editable_box.setObjectName("editableSection") - help_tip(editable_box, "自动找出一批可以尝试编辑的 face 或 edge,方便不用手动到处点。") + help_tip(editable_box, "自动找出当前版本可以尝试编辑的Face或Edge,方便不用手动到处点。") editable_layout = QVBoxLayout(editable_box) editable_button_row = QHBoxLayout() - self.editable_refresh_button = QPushButton("扫描可编辑对象") + self.editable_refresh_button = QPushButton("扫描对象") help_tip(self.editable_refresh_button, "快速扫描一批可以尝试编辑的对象,并列在下面表格里。") self.editable_refresh_button.clicked.connect(lambda _checked=False: self.refresh_editable_candidates(show_info=True)) self.editable_deep_scan_button = QPushButton("深度扫描") @@ -678,14 +812,15 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.editable_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.editable_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.editable_table.setMinimumHeight(130) - help_tip(self.editable_table, "这里列出可尝试编辑的对象。点击一行会选中模型里的对应 face 或 edge,并自动填入相关输入框。") + self.editable_table.setAlternatingRowColors(True) + help_tip(self.editable_table, "这里列出可尝试编辑的对象。点击一行会选中模型里的对应Face或Edge,并自动填入相关输入框。") self.editable_table.cellClicked.connect(self.on_editable_row_clicked) editable_layout.addWidget(self.editable_table) panel_layout.addWidget(editable_box) - candidate_box = QGroupBox("圆柱候选") + candidate_box = QGroupBox("圆柱面候选") candidate_box.setObjectName("candidateSection") - help_tip(candidate_box, "列出圆柱面并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。") + help_tip(candidate_box, "列出圆柱面候选,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。") candidate_layout = QVBoxLayout(candidate_box) filter_layout = QHBoxLayout() filter_layout.addWidget(QLabel("类型")) @@ -701,7 +836,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf ) help_tip( self.candidate_filter_combo, - "筛选下面的圆柱候选:孔/槽、圆角、凸台/外圆,或暂时无法明确分类的圆柱面。", + "筛选下面的圆柱面候选:孔/槽、圆角、凸台/外圆,或暂时无法明确分类的圆柱面。", ) self.candidate_filter_combo.currentTextChanged.connect( lambda _text: self._filter_cached_cylinder_candidates(show_info=True) @@ -710,13 +845,14 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf candidate_layout.addLayout(filter_layout) self.cylinder_table = QTableWidget(0, 8) self.cylinder_table.setHorizontalHeaderLabels( - ["face", "guess", "diameter", "span", "height", "confidence", "risk", "part"] + ["Face", "Guess", "Diameter", "Span", "Height", "Confidence", "Risk", "Part"] ) self.cylinder_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.cylinder_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.cylinder_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.cylinder_table.setMinimumHeight(140) - help_tip(self.cylinder_table, "这里显示圆柱面候选。点击一行会选中对应 face,并在属性区显示可用操作和风险。") + self.cylinder_table.setAlternatingRowColors(True) + help_tip(self.cylinder_table, "这里显示圆柱面候选。点击一行会选中对应Face,并在属性区显示可用操作和风险。") self.cylinder_table.cellClicked.connect(self.on_cylinder_row_clicked) candidate_layout.addWidget(self.cylinder_table) panel_layout.addWidget(candidate_box) @@ -729,20 +865,21 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.history_list.setMinimumHeight(110) help_tip(self.history_list, "成功执行过的编辑会出现在这里。点击一条记录可查看参数、差异预览和目标位置。") self.history_list.currentRowChanged.connect(self.on_history_row_changed) + self.history_list.currentRowChanged.connect(lambda _row: self._update_action_states()) history_buttons = QHBoxLayout() - clear_diff_button = QPushButton("清除差异预览") - help_tip(clear_diff_button, "关闭历史记录产生的红/绿差异叠加和热力图显示。不会修改模型。") - clear_diff_button.clicked.connect(lambda _checked=False: self.clear_diff_preview()) - export_diff_button = QPushButton("导出差异报告") - help_tip(export_diff_button, "把当前选中的历史记录导出成文本报告,包含参数、拓扑变化和几何差异统计。") - export_diff_button.clicked.connect(self.export_diff_report) - export_history_button = QPushButton("导出编辑历史") - help_tip(export_history_button, "把本次会话里的所有编辑记录导出为 JSON,方便留档或后续复盘。") - export_history_button.clicked.connect(self.export_operation_history) + self.clear_diff_button = QPushButton("清除差异预览") + help_tip(self.clear_diff_button, "关闭历史记录产生的红/绿差异叠加和热力图显示。不会修改模型。") + self.clear_diff_button.clicked.connect(lambda _checked=False: self.clear_diff_preview()) + self.export_diff_button = QPushButton("导出差异报告") + help_tip(self.export_diff_button, "把当前选中的历史记录导出成文本报告,包含参数、拓扑变化和几何差异统计。") + self.export_diff_button.clicked.connect(self.export_diff_report) + self.export_history_button = QPushButton("导出编辑历史") + help_tip(self.export_history_button, "把本次会话里的所有编辑记录导出为 JSON,方便留档或后续复盘。") + self.export_history_button.clicked.connect(self.export_operation_history) history_layout.addWidget(self.history_list) - history_buttons.addWidget(clear_diff_button) - history_buttons.addWidget(export_diff_button) - history_buttons.addWidget(export_history_button) + history_buttons.addWidget(self.clear_diff_button) + history_buttons.addWidget(self.export_diff_button) + history_buttons.addWidget(self.export_history_button) history_layout.addLayout(history_buttons) panel_layout.addWidget(history_box) @@ -751,18 +888,18 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf help_tip(info_box, "显示当前选中对象的几何、拓扑、特征判断和可用操作信息。") info_layout = QVBoxLayout(info_box) info_buttons = QHBoxLayout() - copy_id_button = QPushButton("复制 ID") - help_tip(copy_id_button, "复制当前选中对象的 ID。Face/Feature 会优先复制逻辑 Face ID。") - copy_id_button.clicked.connect(self.copy_selected_id) - copy_pick_button = QPushButton("复制坐标") - help_tip(copy_pick_button, "复制最近一次鼠标点到模型上的三维坐标。") - copy_pick_button.clicked.connect(self.copy_pick_position) - copy_info_button = QPushButton("复制信息") - help_tip(copy_info_button, "复制当前属性区里的完整文本,方便发给别人或做问题记录。") - copy_info_button.clicked.connect(self.copy_current_info) - info_buttons.addWidget(copy_id_button) - info_buttons.addWidget(copy_pick_button) - info_buttons.addWidget(copy_info_button) + self.copy_id_button = QPushButton("复制 ID") + help_tip(self.copy_id_button, "复制当前选中对象的 ID。Face/Feature 会优先复制逻辑 Face ID。") + self.copy_id_button.clicked.connect(self.copy_selected_id) + self.copy_pick_button = QPushButton("复制坐标") + help_tip(self.copy_pick_button, "复制最近一次鼠标点到模型上的三维坐标。") + self.copy_pick_button.clicked.connect(self.copy_pick_position) + self.copy_info_button = QPushButton("复制信息") + help_tip(self.copy_info_button, "复制当前属性区里的完整文本,方便发给别人或做问题记录。") + self.copy_info_button.clicked.connect(self.copy_current_info) + info_buttons.addWidget(self.copy_id_button) + info_buttons.addWidget(self.copy_pick_button) + info_buttons.addWidget(self.copy_info_button) info_layout.addLayout(info_buttons) self.info_tabs = QTabWidget() @@ -803,7 +940,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf def _parse_args(argv: list[str]) -> tuple[Path, bool]: smoke_test = "--smoke-test" in argv paths = [arg for arg in argv[1:] if not arg.startswith("--")] - path = Path(paths[0]) if paths else Path("geom_extract.step") + path = Path(paths[0]) if paths else DEFAULT_MODEL_PATH return path, smoke_test diff --git a/step_editor/features.py b/step_editor/features.py index cba82fc..dc54f87 100644 --- a/step_editor/features.py +++ b/step_editor/features.py @@ -271,48 +271,79 @@ class FeatureMixin: break plane_count = 0 + shell_thickness_count = 0 + shell_thickness_limit = max(2, min(per_type_limit, limit // 10)) face_scan_limit = len(self.faces) if max_scan_faces is None else min(len(self.faces), max(0, int(max_scan_faces))) for face_id, face in enumerate(self.faces[:face_scan_limit]): if progress_callback is not None and face_id % 30 == 0: progress_callback() - if plane_count >= per_type_limit: + if plane_count >= per_type_limit and shell_thickness_count >= shell_thickness_limit: break surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: continue props = GProp_GProps() brepgprop.SurfaceProperties(face, props) - if detailed: - direction_info = self._plane_push_pull_direction(face_id, surf) - confidence = str(direction_info["confidence"]) - risk = "low" if confidence == "high" else "medium" - status = "ready" if confidence == "high" else "caution" - note = str(direction_info["note"]) - else: - confidence = "pending" - risk = "medium" - status = "caution" - note = "快速扫描:推拉方向会在选中 face 或执行编辑前再详细判断。" - candidates.append( - { - "operation_key": "push_pull_plane", - "operation": "推拉平面", - "target_kind": "face", - "target_id": face_id, - "face_id": face_id, - "part_id": self.face_part_ids[face_id], - "solid_id": self.face_solid_ids[face_id], - "surface": "plane", - "feature_guess": "planar push/pull candidate", - "current_value": props.Mass(), - "current_value_label": "area", - "status": status, - "risk": risk, - "confidence": confidence, - "note": note, - } - ) - plane_count += 1 + if plane_count < per_type_limit: + if detailed: + direction_info = self._plane_push_pull_direction(face_id, surf) + confidence = str(direction_info["confidence"]) + risk = "low" if confidence == "high" else "medium" + status = "ready" if confidence == "high" else "caution" + note = str(direction_info["note"]) + else: + confidence = "pending" + risk = "medium" + status = "caution" + note = "快速扫描:推拉方向会在选中Face或执行编辑前再详细判断。" + candidates.append( + { + "operation_key": "push_pull_plane", + "operation": "推拉平面", + "target_kind": "face", + "target_id": face_id, + "face_id": face_id, + "part_id": self.face_part_ids[face_id], + "solid_id": self.face_solid_ids[face_id], + "surface": "plane", + "feature_guess": "planar push/pull candidate", + "current_value": props.Mass(), + "current_value_label": "area", + "status": status, + "risk": risk, + "confidence": confidence, + "note": note, + } + ) + plane_count += 1 + if shell_thickness_count < shell_thickness_limit: + feature = self.feature_info(face_id) + if feature.get("shell_region_status") == "candidate": + shell_confidence = str(feature.get("shell_confidence", "low")) + shell_risk = "low" if shell_confidence == "high" else "medium" if shell_confidence == "medium" else "high" + candidates.append( + { + "operation_key": "resize_shell_thickness", + "operation": "调整薄壁厚度", + "target_kind": "face", + "target_id": face_id, + "face_id": face_id, + "part_id": self.face_part_ids[face_id], + "solid_id": self.face_solid_ids[face_id], + "surface": "plane", + "feature_guess": "thin-wall opposite plane candidate", + "current_value": feature.get("shell_thickness_estimate"), + "current_value_label": "shell_thickness", + "status": "ready" if shell_confidence == "high" else "caution", + "risk": shell_risk, + "confidence": shell_confidence, + "note": ( + "快速扫描:已找到投影重叠的相对平面;点击后会填入参考目标厚度," + "执行时会移动当前平面区域来改变局部薄壁/壳体厚度。" + ), + } + ) + shell_thickness_count += 1 fillet_edge_count = 0 chamfer_edge_count = 0 @@ -342,7 +373,7 @@ class FeatureMixin: candidates.append( { "operation_key": "fillet_edge", - "operation": "给边添加圆角", + "operation": "给Edge添加圆角", "target_kind": "edge", "target_id": edge_id, "edge_id": edge_id, @@ -363,7 +394,7 @@ class FeatureMixin: candidates.append( { "operation_key": "chamfer_edge", - "operation": "给边添加倒角", + "operation": "给Edge添加倒角", "target_kind": "edge", "target_id": edge_id, "edge_id": edge_id, @@ -384,7 +415,7 @@ class FeatureMixin: candidates.append( { "operation_key": "resize_edge_length", - "operation": "直接修改边长", + "operation": "修改Edge长度", "target_kind": "edge", "target_id": edge_id, "edge_id": edge_id, @@ -397,7 +428,7 @@ class FeatureMixin: "status": "caution", "risk": "medium" if is_line_edge or is_circle_edge else "high", "confidence": "pending", - "note": "快速扫描:直线边会优先尝试端面推拉;圆边会尝试换算相邻圆柱直径;其他边会使用几何缩放 fallback。", + "note": "快速扫描:直线边优先局部形变;圆边优先换算相邻圆柱直径;椭圆/平面曲线会尝试径向缩放;其他边使用后备策略。", } ) edge_length_count += 1 @@ -411,10 +442,11 @@ class FeatureMixin: "suppress_cylinder": 3, "resize_depth": 4, "inspect_existing_fillet": 5, - "push_pull_plane": 6, - "fillet_edge": 7, - "chamfer_edge": 8, - "resize_edge_length": 9, + "resize_shell_thickness": 6, + "push_pull_plane": 7, + "fillet_edge": 8, + "chamfer_edge": 9, + "resize_edge_length": 10, } candidates.sort( key=lambda item: ( @@ -488,7 +520,7 @@ class FeatureMixin: return { "status": "blocked", "risk": "blocked", - "message": "当前选中的 face 不是圆柱面,不能执行圆柱切削。", + "message": "当前选中的 Face 不是圆柱面,不能执行圆柱切削。", } current_diameter = float(info["diameter"]) feature = self.feature_info(face_id) @@ -550,7 +582,7 @@ class FeatureMixin: return { "status": "blocked", "risk": "blocked", - "message": "当前选中的 face 不是圆柱面,不能调整圆柱凸台直径。", + "message": "当前选中的 Face 不是圆柱面,不能调整圆柱凸台直径。", } current_diameter = float(info["diameter"]) @@ -609,7 +641,7 @@ class FeatureMixin: return { "status": "blocked", "risk": "blocked", - "message": "当前选中的 face 不是圆柱面,不能封堵圆柱孔。", + "message": "当前选中的 Face 不是圆柱面,不能封堵圆柱孔。", } feature = self.feature_info(face_id) axis_range = self._cylindrical_axis_range( @@ -664,7 +696,7 @@ class FeatureMixin: return { "status": "blocked", "risk": "blocked", - "message": "当前选中的 face 不是圆柱面,不能调整盲孔深度。", + "message": "当前选中的 Face 不是圆柱面,不能调整盲孔深度。", } feature = self.feature_info(face_id) @@ -742,7 +774,7 @@ class FeatureMixin: if surf.GetType() != GeomAbs_Cylinder: return { "context_status": "blocked", - "context_message": "当前选中的 face 不是圆柱面。", + "context_message": "当前选中的 Face 不是圆柱面。", } manual_bottom_face_id = None @@ -775,7 +807,7 @@ class FeatureMixin: if not bottom_face_ids: return { "context_status": "blocked", - "context_message": "第一版孔深调整需要疑似底面;如果自动识别失败,请手动填写底面 Face ID。", + "context_message": "当前版本的孔深调整需要疑似底面;如果自动识别失败,请手动填写底面Face ID。", } cyl = surf.Cylinder() @@ -912,7 +944,7 @@ class FeatureMixin: if surf.GetType() != GeomAbs_Cylinder: return { "cutter_strategy": "unavailable", - "cutter_note": "selected face is not cylindrical", + "cutter_note": "选中Face不是圆柱面", } cyl = surf.Cylinder() @@ -954,7 +986,7 @@ class FeatureMixin: "cutter_strategy": "bounded-to-selected-cylinder-v-range", "cutter_note": ( "有限长度切削:优先按同域圆柱侧壁整体 V 范围生成 cutter," - "如果没有同域拆分则退回选中 face 范围,减少贯穿整个零件的误切风险。" + "如果没有同域拆分则退回选中Face范围,减少贯穿整个零件的误切风险。" ), "cutter_scope_face_ids": axis_range["same_domain_face_ids"], "cutter_scope_face_count": axis_range["same_domain_face_count"], @@ -982,7 +1014,7 @@ class FeatureMixin: if surf.GetType() != GeomAbs_Cylinder: return { "fill_strategy": "unavailable", - "fill_note": "selected face is not cylindrical", + "fill_note": "选中Face不是圆柱面", } cyl = surf.Cylinder() @@ -1019,7 +1051,7 @@ class FeatureMixin: if surf.GetType() != GeomAbs_Cylinder: return { "boss_tool_strategy": "unavailable", - "boss_tool_note": "selected face is not cylindrical", + "boss_tool_note": "选中Face不是圆柱面", } cyl = surf.Cylinder() @@ -1055,7 +1087,7 @@ class FeatureMixin: "boss_tool_note": ( "扩大凸台会在同域圆柱侧壁整体 V 范围内生成目标半径圆柱并 Fuse;" "缩小凸台会先用旧半径包络体移除原凸台范围,再 Fuse 目标半径圆柱重建。" - "第一版会给轴向两端保留少量重叠,让布尔结果更容易和原实体合并。" + "当前版本会给轴向两端保留少量重叠,让布尔结果更容易和原实体合并。" ), "boss_tool_scope_face_ids": axis_range["same_domain_face_ids"], "boss_tool_scope_face_count": axis_range["same_domain_face_count"], diff --git a/step_editor/geometry_utils.py b/step_editor/geometry_utils.py index 5b562b4..30ba942 100644 --- a/step_editor/geometry_utils.py +++ b/step_editor/geometry_utils.py @@ -191,12 +191,12 @@ def _shape_quality_info(label: str, shape: TopoDS_Shape, expect_solid: bool) -> if not brep_valid: warnings.append("B-Rep 校验未通过,导出后其他 CAD 软件可能无法正常识别。") if faces == 0: - warnings.append("没有检测到 face,导出结果可能不可用。") + warnings.append("没有检测到Face,导出结果可能不可用。") if expect_solid and solids == 0: - warnings.append("没有检测到 solid,导出后可能不是实体。") + warnings.append("没有检测到Solid,导出后可能不是实体。") elif expect_solid and solids > 1: warnings.append( - f"检测到 {solids} 个 solid。" + f"检测到 {solids} 个Solid。" "如果这不是有意的多实体零件,导出后可能看起来像多个体叠在一起或彼此分离。" ) @@ -782,7 +782,7 @@ def _cylinder_resize_readiness( if guess != "hole/groove candidate": status = "blocked" risk = "blocked" - blockers.append("缩小孔径第一版只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。") + blockers.append("缩小孔径当前版本只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。") else: risk = _max_risk(risk, "high") warnings.append("缩小孔径会先补料再重切,属于高风险实验功能。") @@ -817,9 +817,9 @@ def _cylinder_boss_resize_readiness( height_estimate = float(info.get("height_estimate", 0.0)) if guess != "boss/outer-round candidate": - blockers.append("凸台直径调整第一版只支持明确的凸台/外圆柱候选。") + blockers.append("凸台直径调整当前版本只支持明确的凸台/外圆柱候选。") if angular_span < math.tau * 0.92: - blockers.append("凸台直径调整第一版只支持接近完整圆柱的凸台,不处理局部外圆角或圆角面。") + blockers.append("凸台直径调整当前版本只支持接近完整圆柱的凸台,不处理局部外圆角或圆角面。") if current_diameter <= 1e-9: blockers.append("当前圆柱面的直径估算无效。") @@ -885,9 +885,9 @@ def _cylinder_depth_readiness( manual_bottom_face_used = bool(info.get("manual_bottom_face_used")) if guess != "hole/groove candidate": - blockers.append("孔深调整第一版只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。") + blockers.append("孔深调整当前版本只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。") if end_type != "blind" and not manual_bottom_face_used: - blockers.append("孔深调整第一版只支持端部类型为 blind 的盲孔/盲槽。") + blockers.append("孔深调整当前版本只支持端部类型为 blind 的盲孔/盲槽。") elif end_type != "blind" and manual_bottom_face_used: risk = _max_risk(risk, "medium") warnings.append("端部类型不是明确 blind,当前按手动底面 Face ID 推断孔深方向。") @@ -951,9 +951,9 @@ def _cylinder_suppress_readiness(info: dict[str, object]) -> dict[str, object]: diameter = float(info.get("diameter", 0.0)) if guess != "hole/groove candidate": - blockers.append("封堵圆柱孔第一版只支持孔候选,不支持圆角、凸柱或未明确圆柱面。") + blockers.append("封堵圆柱孔当前版本只支持孔候选,不支持圆角、凸柱或未明确圆柱面。") if angular_span < math.tau * 0.92: - blockers.append("封堵圆柱孔第一版只支持接近完整圆柱的孔,不支持半孔/槽。") + blockers.append("封堵圆柱孔当前版本只支持接近完整圆柱的孔,不支持半孔/槽。") if end_type == "closed/internal": blockers.append("当前圆柱两端都像在材料内部,不像可封堵的外部孔。") if height <= 1e-9 or diameter <= 1e-9: @@ -998,14 +998,14 @@ def _edge_fillet_readiness( adjacent_count = int(info.get("adjacent_face_count", 0)) if curve != "line": - blockers.append("添加圆角第一版只支持直线 edge。") + blockers.append("添加圆角当前版本只支持直线Edge。") if length <= 1e-9: - blockers.append("当前 edge 长度无效。") + blockers.append("当前Edge长度无效。") if adjacent_count < 2: - blockers.append("当前 edge 没有检测到至少两个相邻 face,不能可靠添加圆角。") + blockers.append("当前 Edge 没有检测到至少两个相邻 Face,不能可靠添加圆角。") elif adjacent_count > 2: risk = _max_risk(risk, "medium") - warnings.append(f"当前 edge 相邻 face 数为 {adjacent_count},可能是复杂交汇边。") + warnings.append(f"当前 Edge 相邻 Face 数为 {adjacent_count},可能是复杂交汇边。") if radius is not None: if radius <= 0: @@ -1013,13 +1013,13 @@ def _edge_fillet_readiness( elif length > 1e-9: ratio = radius / length if ratio >= 0.45: - blockers.append("圆角半径接近或超过 edge 长度的一半,第一版直接阻止。") + blockers.append("圆角半径接近或超过Edge长度的一半,当前版本会直接阻止。") elif ratio > 0.25: risk = _max_risk(risk, "high") - warnings.append("圆角半径超过 edge 长度的 25%,很容易导致倒圆失败。") + warnings.append("圆角半径超过Edge长度的 25%,很容易导致倒圆失败。") elif ratio > 0.12: risk = _max_risk(risk, "medium") - warnings.append("圆角半径相对 edge 长度偏大,请确认预览范围。") + warnings.append("圆角半径相对Edge长度偏大,请确认预览范围。") if blockers: status = "blocked" @@ -1053,14 +1053,14 @@ def _edge_chamfer_readiness( adjacent_count = int(info.get("adjacent_face_count", 0)) if curve != "line": - blockers.append("添加倒角第一版只支持直线 edge。") + blockers.append("添加倒角当前版本只支持直线Edge。") if length <= 1e-9: - blockers.append("当前 edge 长度无效。") + blockers.append("当前Edge长度无效。") if adjacent_count < 2: - blockers.append("当前 edge 没有检测到至少两个相邻 face,不能可靠添加倒角。") + blockers.append("当前 Edge 没有检测到至少两个相邻 Face,不能可靠添加倒角。") elif adjacent_count > 2: risk = _max_risk(risk, "medium") - warnings.append(f"当前 edge 相邻 face 数为 {adjacent_count},可能是复杂交汇边。") + warnings.append(f"当前 Edge 相邻 Face 数为 {adjacent_count},可能是复杂交汇边。") if distance is not None: if distance <= 0: @@ -1068,13 +1068,13 @@ def _edge_chamfer_readiness( elif length > 1e-9: ratio = distance / length if ratio >= 0.45: - blockers.append("倒角距离接近或超过 edge 长度的一半,第一版直接阻止。") + blockers.append("倒角距离接近或超过Edge长度的一半,当前版本会直接阻止。") elif ratio > 0.25: risk = _max_risk(risk, "high") - warnings.append("倒角距离超过 edge 长度的 25%,很容易导致倒角失败。") + warnings.append("倒角距离超过Edge长度的 25%,很容易导致倒角失败。") elif ratio > 0.12: risk = _max_risk(risk, "medium") - warnings.append("倒角距离相对 edge 长度偏大,请确认预览范围。") + warnings.append("倒角距离相对Edge长度偏大,请确认预览范围。") if blockers: status = "blocked" diff --git a/step_editor/info_panel.py b/step_editor/info_panel.py index f91fe9f..45283e4 100644 --- a/step_editor/info_panel.py +++ b/step_editor/info_panel.py @@ -27,6 +27,8 @@ class InfoPanelMixin: self.info_text.setPlainText(self.current_info_text) self._populate_info_tree(info) self.info_tabs.setCurrentWidget(self.info_tree) + if hasattr(self, "export_all_button"): + self._update_action_states() def set_plain_info(self, text: str) -> None: self.current_info_values = {} @@ -34,6 +36,8 @@ class InfoPanelMixin: self.info_tree.clear() self.info_text.setPlainText(text) self.info_tabs.setCurrentWidget(self.info_text) + if hasattr(self, "export_all_button"): + self._update_action_states() def _populate_info_tree(self, info: dict[str, object]) -> None: self.info_tree.clear() @@ -106,6 +110,7 @@ class InfoPanelMixin: self.measure_label_b = source self.statusBar().showMessage(f"已设置测量点 B: {_format_value(point)}") self._refresh_measurement_panel() + self._update_action_states() def clear_measurement(self) -> None: self.measure_point_a = None @@ -115,6 +120,7 @@ class InfoPanelMixin: self._clear_measure_actor() self._refresh_measurement_panel() self.statusBar().showMessage("已清除测量") + self._update_action_states() def copy_measurement(self) -> None: text = self.measure_text.toPlainText() if hasattr(self, "measure_text") else "" diff --git a/step_editor/model.py b/step_editor/model.py index e881600..d575432 100644 --- a/step_editor/model.py +++ b/step_editor/model.py @@ -374,8 +374,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "feature_face_ids": (face_id,), "feature_highlight_face_ids": (face_id,), "feature_boundary_edge_ids": tuple(boundary_edge_ids), - "feature_edit_actions": "当前第一版只能查看该局部曲面,暂不支持直接编辑。", - "feature_mode": "Feature 模式会把选中的 face 解释为局部几何特征候选。", + "feature_edit_actions": "当前版本只能查看该局部曲面,暂不支持直接编辑。", + "feature_mode": "Feature 模式会把选中的Face解释为局部几何特征候选。", } ) return result @@ -390,7 +390,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd scope_note = "当前 face 没有检测到可一起推拉的共面相接/重叠邻居。" edit_actions = "推拉平面" if shell_info.get("shell_region_status") == "candidate": - edit_actions += ";查看薄壁/壳体厚度估算" + edit_actions += ";调整薄壁/壳体厚度" result = dict(info) result.update( { @@ -493,6 +493,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd best = { "shell_opposite_face_id": candidate_id, "shell_thickness_estimate": thickness, + "shell_signed_thickness": _axis_parameter(plane.Location(), normal, candidate_plane.Location()), "shell_overlap_ratio_estimate": overlap_ratio, "shell_opposite_normal_dot": normal_dot, } @@ -557,7 +558,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd edit_actions += ";封堵圆柱孔" elif guess == "round/fillet candidate": feature_type = "圆角/倒圆候选" - edit_actions = "可尝试修改已有圆角半径;第一版会先移除圆角面,再在恢复出的锐边上重新倒圆。" + edit_actions = "可尝试修改已有圆角半径;当前版本会先移除圆角面,再在恢复出的锐边上重新倒圆。" elif guess == "boss/outer-round candidate": feature_type = "凸台/外圆候选" edit_actions = "调整圆柱凸台直径。" @@ -668,7 +669,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "feature_existing_fillet_support_face_ids": tuple(support_face_ids), "existing_fillet_note": ( "这是由局部小半径圆柱面推断出的已有圆角/倒圆候选;" - "第一版可尝试使用 defeature + 重新倒圆修改半径;" + "当前版本可尝试使用 defeature + 重新倒圆修改半径;" "复杂 blend 或支撑面不明确时可能失败并回滚。" ), } @@ -1302,6 +1303,12 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd info["axis"] = _dir_tuple(circle.Axis().Direction()) info["radius"] = circle.Radius() info["diameter"] = circle.Radius() * 2.0 + if curve_type == GeomAbs_Ellipse: + ellipse = curve.Ellipse() + info["center"] = _point_tuple(ellipse.Location()) + info["axis"] = _dir_tuple(ellipse.Axis().Direction()) + info["major_radius"] = ellipse.MajorRadius() + info["minor_radius"] = ellipse.MinorRadius() adjacent_face_ids = self._edge_adjacent_face_ids(edge_id) info["adjacent_face_ids"] = tuple(adjacent_face_ids) info["adjacent_face_count"] = len(adjacent_face_ids) diff --git a/step_editor/operations.py b/step_editor/operations.py index 469de18..f38367c 100644 --- a/step_editor/operations.py +++ b/step_editor/operations.py @@ -9,7 +9,14 @@ from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse from OCC.Core.BRepBndLib import brepbndlib from OCC.Core.BOPAlgo import BOPAlgo_GlueFull -from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_GTransform, BRepBuilderAPI_Transform +from OCC.Core.BRepBuilderAPI import ( + BRepBuilderAPI_GTransform, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakePolygon, + BRepBuilderAPI_MakeSolid, + BRepBuilderAPI_Sewing, + BRepBuilderAPI_Transform, +) from OCC.Core.BRepCheck import BRepCheck_Analyzer from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet @@ -50,7 +57,10 @@ from OCC.Core.TopAbs import ( TopAbs_INTERNAL, TopAbs_OUT, TopAbs_REVERSED, + TopAbs_SHELL, TopAbs_SOLID, + TopAbs_VERTEX, + TopAbs_WIRE, ) from OCC.Core.TopExp import TopExp_Explorer, topexp from OCC.Core.TopLoc import TopLoc_Location @@ -170,7 +180,7 @@ class OperationMixin: readiness["fillet_risk"] = _max_risk(str(readiness["fillet_risk"]), "high") readiness["fillet_warnings"] = _join_nonempty( readiness["fillet_warnings"], - f"当前零件包含 {part_stats.solids} 个 solid,边倒圆会作用在整个 part shape 上,请导出前检查结果。", + f"当前零件包含 {part_stats.solids} 个Solid,Edge倒圆会作用在整个Part shape 上,请导出前检查结果。", ) readiness["fillet_note"] = _join_nonempty(readiness["fillet_note"], readiness["fillet_warnings"]) @@ -214,7 +224,7 @@ class OperationMixin: readiness["chamfer_risk"] = _max_risk(str(readiness["chamfer_risk"]), "high") readiness["chamfer_warnings"] = _join_nonempty( readiness["chamfer_warnings"], - f"当前零件包含 {part_stats.solids} 个 solid,边倒角会作用在整个 part shape 上,请导出前检查结果。", + f"当前零件包含 {part_stats.solids} 个Solid,Edge倒角会作用在整个Part shape 上,请导出前检查结果。", ) readiness["chamfer_note"] = _join_nonempty(readiness["chamfer_note"], readiness["chamfer_warnings"]) @@ -270,29 +280,45 @@ class OperationMixin: "length_center": info.get("length_center"), } warnings: list[str] = [ - "边长直接修改基于当前 STEP/B-Rep 结果几何,不是 CAD 建模历史里的参数编辑。" + "Edge长度修改基于当前 STEP/B-Rep 结果几何,不是 CAD 建模历史里的参数编辑。" ] blockers: list[str] = [] risk = "low" status = "ready" if current_length <= 1e-9: - blockers.append("当前 edge 长度无效。") + blockers.append("当前Edge长度无效。") if target_length <= 1e-9: - blockers.append("目标边长必须大于 0。") + blockers.append("Edge目标长度必须大于 0。") if abs(delta_length) <= max(current_length * 1e-7, 1e-7): - blockers.append("目标边长与当前边长几乎相同,不需要修改。") + blockers.append("Edge目标长度与当前Edge长度几乎相同,不需要修改。") if not blockers: ratio = abs(delta_length) / max(current_length, 1e-9) if ratio > 0.5: risk = _max_risk(risk, "high") - warnings.append("长度变化超过当前边长的 50%,形状异常或修复失败的概率较高。") + warnings.append("长度变化超过当前Edge长度的 50%,形状异常或修复失败的概率较高。") elif ratio > 0.25: risk = _max_risk(risk, "medium") - warnings.append("长度变化超过当前边长的 25%,请确认预览范围。") + warnings.append("长度变化超过当前Edge长度的 25%,请确认预览范围。") - if not blockers and curve == "line" and anchor_mode != "center": + if not blockers and curve == "line": + local_candidate, local_skip_note = self._local_edge_length_deform_candidate( + info, + target_length, + anchor_mode=anchor_mode, + ) + if local_candidate is not None: + risk = _max_risk(risk, str(local_candidate.get("local_edge_deform_risk", "medium"))) + status = "caution" if risk != "low" else status + warnings.append( + "将优先只移动当前 Edge 的端点并重建相邻平面;非共面的四边面会拆成三角面。" + ) + base.update(local_candidate) + elif local_skip_note: + warnings.append(local_skip_note) + + if not blockers and "resize_strategy" not in base and curve == "line" and anchor_mode != "center": candidate = self._straight_edge_length_end_face_candidate(info, delta_length, anchor_mode=anchor_mode) if candidate is not None: push_plan = self.push_pull_plan(int(candidate["end_face_id"]), float(candidate["push_pull_distance"])) @@ -320,14 +346,14 @@ class OperationMixin: f"未找到可用于{self._edge_length_anchor_label(anchor_mode)}的端面推拉路径,将尝试按该基准缩放所属对象。" ) elif not blockers and curve == "line" and anchor_mode == "center": - warnings.append("边长基准为固定中心;将使用轴向仿射缩放,让 edge 中心尽量保持不动。") + warnings.append("Edge长度基准为固定中心;将使用轴向仿射缩放,让Edge中心尽量保持不动。") if not blockers and "resize_strategy" not in base and curve == "circle": cylinder_candidate, cylinder_notes = self._circular_edge_length_cylinder_candidate(info, target_length) if cylinder_candidate is not None: risk = _max_risk(risk, str(cylinder_candidate["cylinder_resize_risk"])) status = "caution" if risk != "low" else status - warnings.append("识别到相邻圆柱面;将优先把目标边长换算成圆柱直径做局部编辑。") + warnings.append("识别到相邻圆柱面;将优先把Edge目标长度换算成圆柱直径做局部编辑。") cylinder_warnings = str(cylinder_candidate.get("cylinder_resize_warnings", "")) if cylinder_warnings: warnings.append(cylinder_warnings) @@ -335,10 +361,23 @@ class OperationMixin: elif cylinder_notes: warnings.extend(cylinder_notes[:3]) + if not blockers and "resize_strategy" not in base and curve != "line": + planar_candidate, planar_note = self._planar_edge_length_scale_candidate(info, target_length) + if planar_candidate is not None: + risk = _max_risk(risk, str(planar_candidate.get("affine_transform_risk", "medium"))) + status = "caution" + warnings.append(str(planar_candidate.get("affine_transform_warning", ""))) + note = str(planar_candidate.get("affine_transform_note", "")) + if note: + warnings.append(note) + base.update(planar_candidate) + elif planar_note: + warnings.append(planar_note) + if not blockers and "resize_strategy" not in base: axis = self._edge_length_affine_axis(info, anchor_mode=anchor_mode) if axis is None: - blockers.append("无法为当前 edge 推断可靠的缩放方向。") + blockers.append("无法为当前 Edge 推断可靠的缩放方向。") else: part_id = int(info.get("part_id", -1)) solid_id = int(info.get("solid_id", -1)) @@ -347,27 +386,30 @@ class OperationMixin: target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" scale = target_length / max(current_length, 1e-9) transform_kind = "axis-affine" if curve == "line" else "uniform" + transform_label = "沿Edge方向仿射缩放" if transform_kind == "axis-affine" else "以Edge中心整体缩放" risk = _max_risk(risk, "medium") if curve != "line" or abs(scale - 1.0) > 0.25: risk = _max_risk(risk, "high") status = "caution" if transform_kind == "axis-affine": warnings.append( - "未找到可推拉端面;将沿该 edge 的几何方向对所属 " + "未找到可推拉端面;将沿该Edge的几何方向对所属 " f"{target_kind} 做仿射缩放。该 fallback 会影响同一 {target_kind} 上的其他尺寸。" ) else: warnings.append( - "当前 edge 不是直线;将以 edge 中心为基准对所属 " + "当前 Edge 不是直线;将以 Edge 中心为基准对所属 " f"{target_kind} 做均匀缩放。该 fallback 会影响同一 {target_kind} 上的其他尺寸。" ) if curve != "line": - warnings.append("非直线 edge 的目标长度通过整体比例缩放实现,执行后请复查周边尺寸。") + warnings.append("非直线Edge的目标长度通过整体比例缩放实现,执行后请复查周边尺寸。") base.update( { "resize_strategy": "scale-owning-shape-from-edge", "affine_scale": scale, "affine_transform_kind": transform_kind, + "affine_transform_label": transform_label, + "affine_transform_note": "", "affine_axis_point": axis["axis_point"], "affine_axis_direction": axis["axis_direction"], "affine_axis_source": axis["axis_source"], @@ -377,6 +419,11 @@ class OperationMixin: } ) + if not blockers and base.get("resize_strategy") == "scale-owning-shape-from-edge": + refine_note = self._refine_affine_edge_length_scale(base) + if refine_note: + warnings.append(refine_note) + if blockers: status = "blocked" risk = "blocked" @@ -386,14 +433,16 @@ class OperationMixin: message = " ".join(warnings) else: strategy = str(base.get("resize_strategy", "")) - if strategy == "move-edge-end-plane-by-push-pull": - message = "可以通过端面推拉调整这条直线 edge 长度。" + if strategy == "local-edge-only-deform": + message = "可以通过局部边形变只调整这条直线Edge,并重建周边平面。" + elif strategy == "move-edge-end-plane-by-push-pull": + message = "可以通过端面推拉调整这条直线Edge长度。" elif strategy == "resize-adjacent-cylinder-from-circular-edge-length": - message = "可以通过相邻圆柱直径编辑调整这条圆形/圆弧 edge 长度。" + message = "可以通过相邻圆柱直径编辑调整这条圆形/圆弧Edge长度。" elif strategy == "scale-owning-shape-from-edge": - message = "可以通过几何缩放 fallback 尝试调整该 edge 长度。" + message = "可以通过几何缩放 fallback 尝试调整该Edge长度。" else: - message = "可以尝试直接修改该 edge 长度。" + message = "可以尝试直接修改该Edge长度。" base.update( { @@ -406,6 +455,84 @@ class OperationMixin: ) return base + def _local_edge_length_deform_candidate( + self, + edge_info: dict[str, object], + target_length: float, + anchor_mode: str = "auto", + ) -> tuple[dict[str, object] | None, str]: + if str(edge_info.get("curve", "")) != "line": + return None, "" + start = _tuple_or_none(edge_info.get("start_point")) + end = _tuple_or_none(edge_info.get("end_point")) + if start is None or end is None: + return None, "局部边形变不可用:当前 Edge 缺少稳定起点或终点。" + current_length = float(edge_info.get("length", 0.0)) + if current_length <= 1e-9: + return None, "局部边形变不可用:当前Edge长度无效。" + axis = _tuple_normalized(_tuple_sub(end, start)) + if axis is None: + return None, "局部边形变不可用:当前 Edge 方向无效。" + solid_id = int(edge_info.get("solid_id", -1)) + if solid_id < 0 or solid_id >= len(self.solids): + return None, "局部边形变不可用:当前 Edge 没有关联到稳定 Solid。" + part_id = int(edge_info.get("part_id", -1)) + part = self.part_by_id(part_id) + if part is None: + return None, "局部边形变不可用:找不到所属Part。" + solid = self.solids[solid_id][1] + solid_faces = _explore(solid, TopAbs_FACE) + if not solid_faces: + return None, "局部边形变不可用:所属Solid没有可重建Face。" + if len(solid_faces) > 128: + return None, "局部边形变暂只对较简单的平面多面体开放,复杂模型将使用后备策略。" + for face in solid_faces: + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Plane: + return None, "局部边形变暂只支持全平面多面体;含曲面的模型将使用后备策略。" + if len(_explore(face, TopAbs_WIRE)) != 1: + return None, "局部边形变暂不处理带内孔的Face;将使用后备策略。" + if len(self._local_deform_face_vertex_points(face, max(_shape_diagonal(solid) * 1e-7, 1e-6))) < 3: + return None, "局部边形变不可用:部分Face顶点环无法稳定读取。" + + anchor_mode = self._edge_length_anchor_mode(anchor_mode) + target_length = float(target_length) + delta_length = target_length - current_length + if anchor_mode == "keep-end": + start_move = _tuple_scale(axis, -delta_length) + end_move = (0.0, 0.0, 0.0) + moved_label = "移动起点,固定终点" + elif anchor_mode == "center": + start_move = _tuple_scale(axis, -delta_length * 0.5) + end_move = _tuple_scale(axis, delta_length * 0.5) + moved_label = "两端各移动一半,保持中心" + else: + start_move = (0.0, 0.0, 0.0) + end_move = _tuple_scale(axis, delta_length) + moved_label = "固定起点,移动终点" + + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) + target_kind = "solid" if part_solid_count > 1 else "part" + ratio = abs(delta_length) / max(current_length, 1e-9) + local_risk = "high" if ratio > 0.5 else "medium" + return ( + { + "resize_strategy": "local-edge-only-deform", + "local_edge_deform_target_kind": target_kind, + "local_edge_deform_face_count": len(solid_faces), + "local_edge_deform_anchor": moved_label, + "local_edge_deform_start_move": start_move, + "local_edge_deform_end_move": end_move, + "local_edge_deform_moved_endpoint_count": 2 if anchor_mode == "center" else 1, + "local_edge_deform_risk": local_risk, + "local_edge_deform_note": ( + "只移动当前 Edge 的端点并重建所属平面多面体;非共面 Face 会被拆成三角面。" + ), + "part_solid_count": part_solid_count, + }, + "", + ) + def _edge_length_anchor_mode(self, anchor_mode: str | None) -> str: normalized = str(anchor_mode or "auto").strip().lower() aliases = { @@ -473,7 +600,7 @@ class OperationMixin: feature_guess = str(face_info.get("feature_guess", "cylindrical face")) if feature_guess == "round/fillet candidate": - notes.append(f"相邻圆柱 face {face_id} 更像已有圆角,未自动按孔/凸台直径改边长。") + notes.append(f"相邻圆柱Face {face_id} 更像已有圆角,未自动按孔/凸台直径改边长。") continue if feature_guess == "hole/groove candidate": @@ -481,7 +608,7 @@ class OperationMixin: elif feature_guess == "boss/outer-round candidate": mode_order = ("boss",) else: - notes.append(f"相邻圆柱 face {face_id} 尚未明确识别为孔/槽或凸台,未自动按圆柱直径改边长。") + notes.append(f"相邻圆柱Face {face_id} 尚未明确识别为孔/槽或凸台,未自动按圆柱直径改边长。") continue for mode_index, mode in enumerate(mode_order): mode_label = "圆柱凸台直径" if mode == "boss" else "圆柱孔/槽直径" @@ -492,13 +619,13 @@ class OperationMixin: else self.cylindrical_resize_plan(face_id, target_diameter) ) except Exception as exc: - notes.append(f"相邻圆柱 face {face_id} 的{mode_label}计划生成失败:{exc}") + notes.append(f"相邻圆柱Face {face_id} 的{mode_label}计划生成失败:{exc}") continue plan_status = str(cylinder_plan.get("status", "blocked")) plan_risk = str(cylinder_plan.get("risk", "blocked")) if plan_status == "blocked": - notes.append(f"相邻圆柱 face {face_id} 的{mode_label}不可用:{cylinder_plan.get('message', '')}") + notes.append(f"相邻圆柱Face {face_id} 的{mode_label}不可用:{cylinder_plan.get('message', '')}") continue candidate = { @@ -593,98 +720,170 @@ class OperationMixin: } return None - def straight_edge_length_plan(self, edge_id: int, target_length: float) -> dict[str, object]: - if edge_id < 0 or edge_id >= len(self.edges): - raise ValueError(f"Unknown edge id {edge_id}") - info = self.edge_info(edge_id) - current_length = float(info.get("length", 0.0)) - delta_length = float(target_length) - current_length - base: dict[str, object] = { - "edge_id": edge_id, - "part_id": info.get("part_id"), - "solid_id": info.get("solid_id", -1), - "curve": info.get("curve"), - "current_length": current_length, - "target_length": target_length, - "delta_length": delta_length, - "length_change_ratio": abs(delta_length) / max(current_length, 1e-9), - "start_point": info.get("start_point"), - "end_point": info.get("end_point"), - "direction": info.get("direction"), - "resize_strategy": "move-edge-end-plane-by-push-pull", - } - - warnings: list[str] = [ - "第一版边长调整是受限功能:只移动直线边端点附近的平面端面,不是通用参数化边长编辑。" - ] - blockers: list[str] = [] - risk = "low" - status = "ready" - - if info.get("curve") != "line": - blockers.append("当前 edge 不是直线,第一版不能调整长度。") + def _planar_edge_length_scale_candidate( + self, + edge_info: dict[str, object], + target_length: float, + ) -> tuple[dict[str, object] | None, str]: + curve = str(edge_info.get("curve", "")) + if curve == "line": + return None, "" + current_length = float(edge_info.get("length", 0.0)) if current_length <= 1e-9: - blockers.append("当前 edge 长度无效。") - if target_length <= 1e-9: - blockers.append("目标边长必须大于 0。") - if abs(delta_length) <= max(current_length * 1e-7, 1e-7): - blockers.append("目标边长与当前边长几乎相同,不需要修改。") + return None, "平面曲线径向缩放不可用:当前Edge长度无效。" - if not blockers: - ratio = abs(delta_length) / max(current_length, 1e-9) - if ratio > 0.5: - risk = _max_risk(risk, "high") - warnings.append("长度变化超过当前边长的 50%,布尔运算失败或形状异常的概率较高。") - elif ratio > 0.25: - risk = _max_risk(risk, "medium") - warnings.append("长度变化超过当前边长的 25%,请确认预览范围。") - - candidate: dict[str, object] | None = None - if not blockers: - candidate = self._straight_edge_length_end_face_candidate(info, delta_length) - if candidate is None: - blockers.append("没有找到可用于改变这条直线边长度的平面端面。") - else: - base.update(candidate) - push_plan = self.push_pull_plan(int(candidate["end_face_id"]), float(candidate["push_pull_distance"])) - if push_plan["status"] == "blocked": - blockers.append(str(push_plan["message"])) - else: - risk = _max_risk(risk, str(push_plan["risk"])) - push_warnings = str(push_plan.get("warnings", "")) - if push_warnings: - warnings.append(push_warnings) - base.update( - { - "push_pull_status": push_plan.get("status"), - "push_pull_risk": push_plan.get("risk"), - "push_pull_message": push_plan.get("message"), - "push_pull_scope_face_ids": push_plan.get("push_pull_scope_face_ids", ()), - "push_pull_scope_face_count": push_plan.get("push_pull_scope_face_count", 1), - "push_pull_scope_note": push_plan.get("push_pull_scope_note", ""), - } - ) - - if blockers: - status = "blocked" - risk = "blocked" - message = " ".join(blockers) - elif risk != "low": - status = "caution" - message = " ".join(warnings) + center = _tuple_or_none(edge_info.get("center")) + axis = _tuple_normalized(_tuple_or_none(edge_info.get("axis"))) + axis_source = "" + anchor_source = "" + label = "围绕平面曲线法向径向缩放" + sample_count: int | str = "" + plane_deviation: float | str = "" + if center is not None and axis is not None and curve in {"circle", "ellipse"}: + axis_source = f"{curve} center/axis" + anchor_source = f"{curve} center" + label = "围绕圆边轴线径向缩放" if curve == "circle" else "围绕椭圆边法向径向缩放" else: - message = "可以尝试通过端面推拉调整这条直线边长度。" + frame, note = self._sampled_planar_edge_frame(edge_info) + if frame is None: + return None, note + center = frame["center"] + axis = frame["axis"] + axis_source = "sampled edge best-fit plane" + anchor_source = "sampled edge center" + sample_count = int(frame["sample_count"]) + plane_deviation = float(frame["plane_deviation"]) - base.update( + if center is None or axis is None: + return None, "平面曲线径向缩放不可用:无法确定曲线中心或平面法向。" + part_id = int(edge_info.get("part_id", -1)) + solid_id = int(edge_info.get("solid_id", -1)) + part = self.part_by_id(part_id) if part_id >= 0 else None + if part is None: + return None, "平面曲线径向缩放不可用:找不到所属Part。" + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) + target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" + scale = float(target_length) / max(current_length, 1e-9) + transform_risk = "high" if abs(scale - 1.0) > 0.25 or axis_source.startswith("sampled") else "medium" + if curve == "circle": + warning = ( + "圆边没有可复用的相邻圆柱直径编辑路径;将围绕圆边轴线对所属 " + f"{target_kind} 做径向缩放,尽量保留轴向尺寸,但仍会影响同一 {target_kind} 上的其他径向尺寸。" + ) + elif curve == "ellipse": + warning = ( + "椭圆边将围绕自身平面法向对所属 " + f"{target_kind} 做径向缩放,尽量保留法向尺寸,但会影响同一 {target_kind} 上的其他平面内尺寸。" + ) + else: + warning = ( + "当前非直线Edge可近似为平面曲线;将按采样平面法向对所属 " + f"{target_kind} 做径向缩放。该路径依赖采样估算,执行后请复查周边尺寸。" + ) + return ( { - "status": status, - "risk": risk, - "message": message, - "warnings": ";".join(warnings), - "blockers": ";".join(blockers), - } + "resize_strategy": "scale-owning-shape-from-edge", + "affine_scale": scale, + "affine_transform_kind": "radial-affine", + "affine_transform_label": label, + "affine_transform_note": "可能把解析圆/圆锥/圆柱/椭圆边面转换为 B-spline 几何。", + "affine_transform_warning": warning, + "affine_transform_risk": transform_risk, + "affine_axis_point": center, + "affine_axis_direction": axis, + "affine_axis_source": axis_source, + "affine_anchor_source": anchor_source, + "affine_target_kind": target_kind, + "part_solid_count": part_solid_count, + "planar_edge_scale_sample_count": sample_count, + "planar_edge_scale_plane_deviation": plane_deviation, + }, + "", ) - return base + + def _sampled_planar_edge_frame(self, edge_info: dict[str, object]) -> tuple[dict[str, object] | None, str]: + edge_id = int(edge_info.get("edge_id", -1)) + if edge_id < 0 or edge_id >= len(self.edges): + return None, "平面曲线径向缩放不可用:Edge ID无效。" + curve = BRepAdaptor_Curve(self.edges[edge_id]) + first = float(curve.FirstParameter()) + last = float(curve.LastParameter()) + if not math.isfinite(first) or not math.isfinite(last) or abs(last - first) <= 1e-12: + return None, "平面曲线径向缩放不可用:Edge参数范围无效。" + sample_count = 17 + points = [ + _point_tuple(curve.Value(first + (last - first) * index / (sample_count - 1))) + for index in range(sample_count) + ] + distinct: list[tuple[float, float, float]] = [] + tolerance = max(float(edge_info.get("length", 0.0)) * 1e-7, _shape_diagonal(self.edges[edge_id]) * 1e-7, 1e-7) + for point in points: + if not any(_vector_length(_tuple_sub(point, existing)) <= tolerance for existing in distinct): + distinct.append(point) + if len(distinct) < 3: + return None, "平面曲线径向缩放不可用:采样点不足以确定平面。" + center = _tuple_or_none(edge_info.get("length_center")) + if center is None: + center = ( + sum(point[0] for point in distinct) / len(distinct), + sum(point[1] for point in distinct) / len(distinct), + sum(point[2] for point in distinct) / len(distinct), + ) + normal = self._sampled_edge_plane_normal(distinct, center) + if normal is None: + return None, "平面曲线径向缩放不可用:采样点近似共线,无法确定平面法向。" + plane_deviation = max(abs(_tuple_dot(_tuple_sub(point, center), normal)) for point in distinct) + max_radius = max(_vector_length(_tuple_sub(point, center)) for point in distinct) + allowed_deviation = max(max_radius * 1e-4, float(edge_info.get("length", 0.0)) * 1e-5, 1e-6) + if plane_deviation > allowed_deviation: + return None, ( + "平面曲线径向缩放不可用:Edge采样点不在稳定平面内,将使用更保守的缩放 fallback。" + ) + return ( + { + "center": center, + "axis": normal, + "sample_count": len(distinct), + "plane_deviation": plane_deviation, + }, + "", + ) + + def _sampled_edge_plane_normal( + self, + points: list[tuple[float, float, float]], + center: tuple[float, float, float], + ) -> tuple[float, float, float] | None: + normal = (0.0, 0.0, 0.0) + for index, point in enumerate(points): + next_point = points[(index + 1) % len(points)] + cross = _tuple_cross(_tuple_sub(point, center), _tuple_sub(next_point, center)) + normal = ( + normal[0] + cross[0], + normal[1] + cross[1], + normal[2] + cross[2], + ) + normalized = _tuple_normalized(normal) + if normalized is not None: + return normalized + best: tuple[float, float, float] | None = None + best_length = 0.0 + count = len(points) + for first_index in range(count - 2): + for second_index in range(first_index + 1, count - 1): + for third_index in range(second_index + 1, count): + candidate = _tuple_cross( + _tuple_sub(points[second_index], points[first_index]), + _tuple_sub(points[third_index], points[first_index]), + ) + candidate_length = _vector_length(candidate) + if candidate_length > best_length: + best = candidate + best_length = candidate_length + return _tuple_normalized(best) + + def straight_edge_length_plan(self, edge_id: int, target_length: float) -> dict[str, object]: + return self.general_edge_length_plan(edge_id, target_length, anchor_mode="auto") def _straight_edge_length_end_face_candidate( self, @@ -707,18 +906,18 @@ class OperationMixin: candidates: list[tuple[float, dict[str, object]]] = [] anchor_mode = self._edge_length_anchor_mode(anchor_mode) if anchor_mode == "keep-start": - endpoint_specs = [("终点端", end, _tuple_scale(axis, delta_length))] + endpoint_specs = [("end", "终点端", end, _tuple_scale(axis, delta_length))] elif anchor_mode == "keep-end": - endpoint_specs = [("起点端", start, _tuple_scale(axis, -delta_length))] + endpoint_specs = [("start", "起点端", start, _tuple_scale(axis, -delta_length))] elif anchor_mode == "center": endpoint_specs = [] else: endpoint_specs = [ - ("起点端", start, _tuple_scale(axis, -delta_length)), - ("终点端", end, _tuple_scale(axis, delta_length)), + ("start", "起点端", start, _tuple_scale(axis, -delta_length)), + ("end", "终点端", end, _tuple_scale(axis, delta_length)), ] - for endpoint_label, endpoint, desired_vector in endpoint_specs: + for endpoint_role, endpoint_label, endpoint, desired_vector in endpoint_specs: desired_unit = _tuple_normalized(desired_vector) if desired_unit is None: continue @@ -756,6 +955,7 @@ class OperationMixin: score, { "end_face_id": face_id, + "end_face_endpoint_role": endpoint_role, "end_face_label": endpoint_label, "end_face_plane_distance": plane_distance, "end_face_axis_alignment": axis_alignment, @@ -780,8 +980,8 @@ class OperationMixin: return { "status": "blocked", "risk": "blocked", - "message": "当前选中的 face 不是圆柱圆角面,不能修改已有圆角半径。", - "blockers": "当前选中的 face 不是圆柱圆角面。", + "message": "当前选中的 Face 不是圆柱圆角面,不能修改已有圆角半径。", + "blockers": "当前选中的 Face 不是圆柱圆角面。", "warnings": "", "face_id": face_id, } @@ -804,7 +1004,7 @@ class OperationMixin: if current_radius > 0 and abs(target_radius - current_radius) <= max(current_radius * 1e-5, 1e-6): blockers.append("目标圆角半径与当前估算半径几乎相同,不需要修改。") if len(support_face_ids) < 2: - blockers.append("第一版只对识别到至少两个支撑 face 的已有圆角候选开放。") + blockers.append("当前版本只对识别到至少两个支撑Face的已有圆角候选开放。") part_id = int(info.get("part_id", -1)) part_stats = None @@ -814,7 +1014,7 @@ class OperationMixin: part_stats = None if part_stats is not None and part_stats.solids != 1: blockers.append( - f"当前零件包含 {part_stats.solids} 个 solid;已有圆角半径修改第一版只对单 solid 零件开放。" + f"当前零件包含 {part_stats.solids} 个Solid;已有圆角半径修改当前版本只对单Solid零件开放。" ) height_estimate = float(info.get("height_estimate", 0.0)) @@ -870,8 +1070,8 @@ class OperationMixin: "feature_boundary_edge_ids": feature.get("feature_boundary_edge_ids"), "resize_strategy": "defeature-existing-fillet-face-then-refillet-axis-edge", "resize_note": ( - "第一版已有圆角半径修改只支持由圆柱面表示的直线边圆角。" - "执行后 face/edge ID 会重建,请重新选择对象确认结果。" + "当前版本的已有圆角半径修改只支持由圆柱面表示的直线边圆角。" + "执行后 Face/Edge ID 会重建,请重新选择对象确认结果。" ), } @@ -884,8 +1084,8 @@ class OperationMixin: return { "status": "blocked", "risk": "blocked", - "message": "当前选中的 face 不是平面,不能执行推拉平面。", - "blockers": "当前选中的 face 不是平面。", + "message": "当前选中的 Face 不是平面,不能执行推拉平面。", + "blockers": "当前选中的 Face 不是平面。", "warnings": "", "face_id": face_id, "part_id": self.face_part_ids[face_id], @@ -896,9 +1096,9 @@ class OperationMixin: info = self.face_info(face_id) scope_face_ids = self._connected_coplanar_planar_face_ids(face_id) if len(scope_face_ids) > 1: - scope_note = f"将一起推拉 {len(scope_face_ids)} 个共面且相接/重叠的 face,减少 STEP 碎面导致的贴块缝。" + scope_note = f"将一起推拉 {len(scope_face_ids)} 个共面且相接/重叠的Face,减少 STEP 碎面导致的贴块缝。" else: - scope_note = "只推拉当前 face。" + scope_note = "只推拉当前Face。" direction_confidence = str(info.get("push_pull_confidence", "low")) bbox_diagonal = float(info.get("bbox_diagonal", 0.0)) distance_abs = abs(distance) @@ -916,10 +1116,10 @@ class OperationMixin: warnings.append("推拉方向判断置信度较低,可能不是期望的内外方向。") if bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.2: risk = _max_risk(risk, "high") - warnings.append("推拉距离超过当前 face 包围盒对角线的 20%,容易导致布尔失败或大范围变形。") + warnings.append("推拉距离超过当前Face包围盒对角线的 20%,容易导致布尔失败或大范围变形。") elif bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.08: risk = _max_risk(risk, "medium") - warnings.append("推拉距离相对当前 face 尺寸偏大,请确认预览范围。") + warnings.append("推拉距离相对当前Face尺寸偏大,请确认预览范围。") if risk in {"medium", "high"} and status != "blocked": status = "caution" @@ -951,6 +1151,160 @@ class OperationMixin: "push_pull_scope_note": scope_note, } + def shell_thickness_plan(self, face_id: int, target_thickness: float) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + info = self.feature_info(face_id) + blockers: list[str] = [] + warnings: list[str] = [ + "薄壁/壳体厚度调整基于相对平面几何估算,会移动当前选中平面区域,保留相对平面不动。" + ] + risk = "low" + status = "ready" + + target_thickness = float(target_thickness) + current_thickness = float(info.get("shell_thickness_estimate", 0.0)) + signed_thickness = float(info.get("shell_signed_thickness", 0.0)) + delta_thickness = target_thickness - current_thickness + confidence = str(info.get("shell_confidence", "low")) + overlap_ratio = float(info.get("shell_overlap_ratio_estimate", 0.0)) + source_face_ids = tuple(_int_values(info.get("shell_source_face_ids")) or [face_id]) + opposite_face_id = int(info.get("shell_opposite_face_id", -1)) + + if info.get("surface") != "plane": + blockers.append("当前选中 Face 不是平面,不能调整薄壁/壳体厚度。") + if info.get("shell_region_status") != "candidate": + blockers.append(str(info.get("shell_region_note", "当前平面没有识别到相对薄壁/壳体平面。"))) + if current_thickness <= 1e-9 or abs(signed_thickness) <= 1e-9: + blockers.append("当前薄壁厚度估算无效。") + if target_thickness <= 1e-9: + blockers.append("目标薄壁厚度必须大于 0。") + if abs(delta_thickness) <= max(current_thickness * 1e-5, 1e-6): + blockers.append("目标厚度与当前估算厚度几乎相同,不需要修改。") + + normal = _tuple_normalized(_tuple_or_none(info.get("normal"))) + outward = _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction"))) + desired_movement = None + push_pull_distance = 0.0 + movement_alignment = 0.0 + if not blockers: + if normal is None or outward is None: + blockers.append("当前平面缺少稳定法向或推拉方向,不能换算厚度修改。") + else: + sign = 1.0 if signed_thickness >= 0.0 else -1.0 + toward_opposite = _tuple_scale(normal, sign) + desired_movement = _tuple_scale(toward_opposite, -delta_thickness) + desired_unit = _tuple_normalized(desired_movement) + if desired_unit is None: + blockers.append("目标厚度变化量无效。") + else: + movement_alignment = abs(_tuple_dot(desired_unit, outward)) + if movement_alignment < 0.82: + blockers.append("当前平面推拉方向与薄壁厚度方向不匹配,暂不执行自动厚度修改。") + else: + push_pull_distance = _tuple_dot(desired_movement, outward) + + if not blockers: + delta_ratio = abs(delta_thickness) / max(current_thickness, 1e-9) + if confidence == "low": + risk = _max_risk(risk, "high") + warnings.append("薄壁/壳体相对面识别置信度较低。") + elif confidence == "medium": + risk = _max_risk(risk, "medium") + warnings.append("薄壁/壳体相对面识别置信度为 medium,执行后请检查周边。") + if overlap_ratio < 0.25: + risk = _max_risk(risk, "high") + warnings.append("相对平面投影重叠率较低,可能不是稳定薄壁区域。") + elif overlap_ratio < 0.55: + risk = _max_risk(risk, "medium") + warnings.append("相对平面投影重叠率一般,厚度估算可能偏局部。") + if delta_ratio > 0.8: + risk = _max_risk(risk, "high") + warnings.append("目标厚度变化超过当前厚度的 80%,容易导致布尔失败或周边变形。") + elif delta_ratio > 0.35: + risk = _max_risk(risk, "medium") + warnings.append("目标厚度变化超过当前厚度的 35%,请确认预览。") + + push_plan = self.push_pull_plan(face_id, push_pull_distance) + if push_plan["status"] == "blocked": + blockers.append(str(push_plan["message"])) + else: + risk = _max_risk(risk, str(push_plan["risk"])) + push_warnings = str(push_plan.get("warnings", "")) + if push_warnings: + warnings.append(push_warnings) + + if blockers: + status = "blocked" + risk = "blocked" + message = " ".join(blockers) + push_plan = {} + elif risk != "low": + status = "caution" + message = " ".join(warnings) + else: + message = "可以通过推拉当前平面区域调整薄壁/壳体厚度。" + + return { + "status": status, + "risk": risk, + "message": message, + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": info.get("part_id"), + "solid_id": info.get("solid_id"), + "surface": info.get("surface"), + "shell_region_kind": info.get("shell_region_kind"), + "shell_confidence": confidence, + "shell_source_face_ids": source_face_ids, + "shell_opposite_face_id": opposite_face_id, + "shell_current_thickness": current_thickness, + "shell_target_thickness": target_thickness, + "shell_delta_thickness": delta_thickness, + "shell_delta_ratio": abs(delta_thickness) / max(current_thickness, 1e-9), + "shell_signed_thickness": signed_thickness, + "shell_overlap_ratio_estimate": overlap_ratio, + "shell_opposite_normal_dot": info.get("shell_opposite_normal_dot"), + "shell_desired_movement_vector": desired_movement, + "shell_movement_alignment": movement_alignment, + "push_pull_distance": push_pull_distance, + "outward_direction": info.get("push_pull_outward_direction"), + "push_pull_status": push_plan.get("status"), + "push_pull_risk": push_plan.get("risk"), + "push_pull_message": push_plan.get("message"), + "push_pull_scope_face_ids": push_plan.get("push_pull_scope_face_ids", source_face_ids), + "push_pull_scope_face_count": push_plan.get("push_pull_scope_face_count", len(source_face_ids)), + "push_pull_scope_note": push_plan.get("push_pull_scope_note", ""), + "resize_strategy": "push-pull-shell-source-plane-to-target-thickness", + } + + def shell_thickness_preview_polydata( + self, + face_id: int, + target_thickness: float, + deflection: float = 0.8, + ): + plan = self.shell_thickness_plan(face_id, target_thickness) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + return self.push_pull_preview_polydata(face_id, float(plan["push_pull_distance"]), deflection) + + def resize_shell_thickness(self, face_id: int, target_thickness: float) -> str: + plan = self.shell_thickness_plan(face_id, target_thickness) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + push_result = self.push_pull_face(face_id, float(plan["push_pull_distance"])) + return ( + "Shell thickness resize completed by planar push/pull: " + f"face {face_id}, current_thickness={float(plan['shell_current_thickness']):g}, " + f"target_thickness={float(plan['shell_target_thickness']):g}, " + f"delta={float(plan['shell_delta_thickness']):g}, " + f"opposite_face={plan.get('shell_opposite_face_id')}, " + f"push_pull_distance={float(plan['push_pull_distance']):g}, " + f"risk={plan['risk']}. {push_result}" + ) + def cylindrical_resize_preview_polydata( self, face_id: int, @@ -1157,6 +1511,10 @@ class OperationMixin: plan = self.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + if plan.get("resize_strategy") == "local-edge-only-deform": + preview_shape = self._local_edge_deform_shape(plan) + BRepMesh_IncrementalMesh(preview_shape, deflection) + return _shape_faces_polydata(preview_shape) if plan.get("resize_strategy") == "move-edge-end-plane-by-push-pull": return self.push_pull_preview_polydata(int(plan["end_face_id"]), float(plan["push_pull_distance"]), deflection) if plan.get("resize_strategy") == "resize-adjacent-cylinder-from-circular-edge-length": @@ -1176,8 +1534,22 @@ class OperationMixin: plan = self.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + if plan.get("resize_strategy") == "local-edge-only-deform": + self._apply_local_edge_deform(plan) + result_check = self._edge_length_result_summary(plan) + return ( + "Edge length resize completed by local edge-only deformation: " + f"edge {edge_id}, current_length={float(plan['current_length']):g}, " + f"target_length={float(plan['target_length']):g}, " + f"delta={float(plan['delta_length']):g}, " + f"anchor={plan.get('local_edge_deform_anchor')}, " + f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, " + f"target={plan.get('local_edge_deform_target_kind')}, " + f"risk={plan['risk']}. {result_check}" + ) if plan.get("resize_strategy") == "move-edge-end-plane-by-push-pull": push_result = self.push_pull_face(int(plan["end_face_id"]), float(plan["push_pull_distance"])) + result_check = self._edge_length_result_summary(plan) return ( "Edge length resize completed by end-face push/pull: " f"edge {edge_id}, current_length={float(plan['current_length']):g}, " @@ -1186,7 +1558,7 @@ class OperationMixin: f"end_face={int(plan['end_face_id'])}, " f"push_pull_distance={float(plan['push_pull_distance']):g}, " f"anchor={plan.get('edge_length_anchor_label')}, " - f"risk={plan['risk']}. {push_result}" + f"risk={plan['risk']}. {result_check} {push_result}" ) if plan.get("resize_strategy") == "resize-adjacent-cylinder-from-circular-edge-length": @@ -1196,6 +1568,7 @@ class OperationMixin: resize_result = self.resize_cylindrical_boss(face_id, target_diameter) else: resize_result = self.resize_cylindrical_hole(face_id, target_diameter) + result_check = self._edge_length_result_summary(plan) return ( "Edge length resize completed by adjacent cylinder diameter edit: " f"edge {edge_id}, current_length={float(plan['current_length']):g}, " @@ -1204,26 +1577,504 @@ class OperationMixin: f"cylinder_face={face_id}, " f"target_diameter={target_diameter:g}, " f"mode={plan.get('circular_edge_cylinder_mode_label')}, " - f"risk={plan['risk']}. {resize_result}" + f"risk={plan['risk']}. {result_check} {resize_result}" ) self._apply_edge_length_affine_transform(plan) + result_check = self._edge_length_result_summary(plan) return ( "Edge length resize completed by geometric scale fallback: " f"edge {edge_id}, current_length={float(plan['current_length']):g}, " f"target_length={float(plan['target_length']):g}, " f"delta={float(plan['delta_length']):g}, " f"scale={float(plan['affine_scale']):g}, " + f"transform={plan.get('affine_transform_label') or plan.get('affine_transform_kind')}, " + f"predicted_edge_length={float(plan.get('affine_predicted_edge_length') or 0.0):g}, " f"axis_source={plan.get('affine_axis_source')}, " f"anchor={plan.get('edge_length_anchor_label')}, " f"target={plan.get('affine_target_kind')}, " - f"risk={plan['risk']}." + f"risk={plan['risk']}. {result_check}" ) + def _edge_length_result_summary(self, plan: dict[str, object]) -> str: + check = self._edge_length_result_check(plan) + if check is None: + return "Result check unavailable." + return ( + "Result check: " + f"match={check['match_method']}, " + f"nearest_edge={check['edge_id']}, " + f"nearest_length={float(check['nearest_length']):g}, " + f"target_error={float(check['target_error']):g}, " + f"relative_error={float(check['relative_error']):g}, " + f"endpoint_error={float(check['endpoint_error']):g}, " + f"scope={check['scope']}." + ) + + def _edge_length_result_check(self, plan: dict[str, object]) -> dict[str, object] | None: + try: + target_length = float(plan.get("target_length", 0.0)) + except (TypeError, ValueError): + return None + if target_length <= 1e-9 or not self.edges: + return None + + try: + part_id = int(plan.get("part_id", -1)) + except (TypeError, ValueError): + part_id = -1 + try: + solid_id = int(plan.get("solid_id", -1)) + except (TypeError, ValueError): + solid_id = -1 + + target_kind = str( + plan.get("local_edge_deform_target_kind") + or plan.get("affine_target_kind") + or ("solid" if solid_id >= 0 else "part") + ) + edge_ids = [edge_id for edge_id in range(len(self.edges)) if part_id < 0 or self.edge_part_ids[edge_id] == part_id] + scope = f"Part {part_id}" if part_id >= 0 else "model" + if target_kind == "solid" and solid_id >= 0: + solid_edge_ids = [edge_id for edge_id in edge_ids if self.edge_solid_ids[edge_id] == solid_id] + if solid_edge_ids: + edge_ids = solid_edge_ids + scope = f"solid {solid_id}" + if not edge_ids: + edge_ids = list(range(len(self.edges))) + scope = "model" + + expected = self._edge_length_expected_endpoints(plan) + best_length: tuple[float, int, float, float | None] | None = None + best_endpoint: tuple[float, float, int, float] | None = None + for edge_id in edge_ids: + try: + length = float(self.edge_info(edge_id).get("length", 0.0)) + except Exception: + continue + if length <= 1e-9: + continue + error = abs(length - target_length) + endpoint_error = None + if expected is not None: + endpoint_error = self._edge_endpoint_pair_error(edge_id, expected[0], expected[1]) + if endpoint_error is not None and ( + best_endpoint is None + or endpoint_error < best_endpoint[0] + or ( + abs(endpoint_error - best_endpoint[0]) <= 1e-9 + and (error < best_endpoint[1] or (abs(error - best_endpoint[1]) <= 1e-9 and edge_id < best_endpoint[2])) + ) + ): + best_endpoint = (endpoint_error, error, edge_id, length) + if best_length is None or error < best_length[0] or ( + abs(error - best_length[0]) <= 1e-9 and edge_id < best_length[1] + ): + best_length = (error, edge_id, length, endpoint_error) + + if best_length is None: + return None + match_method = "length" + endpoint_error_value = best_length[3] + error, edge_id, length = best_length[0], best_length[1], best_length[2] + if expected is not None and best_endpoint is not None: + endpoint_tolerance = max(_shape_diagonal(self.shape) * 1e-4, target_length * 1e-3, 1e-4) + if best_endpoint[0] <= endpoint_tolerance: + endpoint_error_value, error, edge_id, length = best_endpoint + match_method = str(expected[2]) + return { + "edge_id": edge_id, + "nearest_length": length, + "target_error": error, + "relative_error": error / max(target_length, 1e-9), + "endpoint_error": endpoint_error_value if endpoint_error_value is not None else -1.0, + "match_method": match_method, + "scope": scope, + } + + def _edge_length_expected_endpoints( + self, + plan: dict[str, object], + ) -> tuple[tuple[float, float, float], tuple[float, float, float], str] | None: + start = _tuple_or_none(plan.get("start_point")) + end = _tuple_or_none(plan.get("end_point")) + if start is None or end is None: + return None + strategy = str(plan.get("resize_strategy", "")) + if strategy == "local-edge-only-deform": + start_move = _tuple_or_none(plan.get("local_edge_deform_start_move")) or (0.0, 0.0, 0.0) + end_move = _tuple_or_none(plan.get("local_edge_deform_end_move")) or (0.0, 0.0, 0.0) + return ( + (start[0] + start_move[0], start[1] + start_move[1], start[2] + start_move[2]), + (end[0] + end_move[0], end[1] + end_move[1], end[2] + end_move[2]), + "endpoint-local", + ) + if strategy == "move-edge-end-plane-by-push-pull": + movement = _tuple_or_none(plan.get("desired_movement_vector")) + endpoint_role = str(plan.get("end_face_endpoint_role", "")) + if movement is None or endpoint_role not in {"start", "end"}: + return None + if endpoint_role == "start": + start = (start[0] + movement[0], start[1] + movement[1], start[2] + movement[2]) + else: + end = (end[0] + movement[0], end[1] + movement[1], end[2] + movement[2]) + return start, end, "endpoint-push-pull" + if strategy == "scale-owning-shape-from-edge": + transformed_start = self._edge_length_affine_point(start, plan) + transformed_end = self._edge_length_affine_point(end, plan) + if transformed_start is None or transformed_end is None: + return None + return transformed_start, transformed_end, "endpoint-affine" + return None + + def _edge_endpoint_pair_error( + self, + edge_id: int, + expected_start: tuple[float, float, float], + expected_end: tuple[float, float, float], + ) -> float | None: + info = self.edge_info(edge_id) + start = _tuple_or_none(info.get("start_point")) + end = _tuple_or_none(info.get("end_point")) + if start is None or end is None: + return None + direct = max(_vector_length(_tuple_sub(start, expected_start)), _vector_length(_tuple_sub(end, expected_end))) + reversed_order = max( + _vector_length(_tuple_sub(start, expected_end)), + _vector_length(_tuple_sub(end, expected_start)), + ) + return min(direct, reversed_order) + + def _edge_length_affine_point( + self, + point: tuple[float, float, float], + plan: dict[str, object], + ) -> tuple[float, float, float] | None: + axis_point = _tuple_or_none(plan.get("affine_axis_point")) + if axis_point is None: + return None + try: + scale = float(plan.get("affine_scale", 1.0)) + except (TypeError, ValueError): + return None + relative = _tuple_sub(point, axis_point) + transform_kind = str(plan.get("affine_transform_kind", "axis-affine")) + if transform_kind == "uniform": + moved = _tuple_scale(relative, scale) + else: + axis_direction = _tuple_normalized(_tuple_or_none(plan.get("affine_axis_direction"))) + if axis_direction is None: + return None + axial = _tuple_scale(axis_direction, _tuple_dot(relative, axis_direction)) + radial = _tuple_sub(relative, axial) + if transform_kind == "radial-affine": + moved = ( + axial[0] + radial[0] * scale, + axial[1] + radial[1] * scale, + axial[2] + radial[2] * scale, + ) + else: + moved = ( + radial[0] + axial[0] * scale, + radial[1] + axial[1] * scale, + radial[2] + axial[2] * scale, + ) + return (axis_point[0] + moved[0], axis_point[1] + moved[1], axis_point[2] + moved[2]) + + def _local_edge_deform_shape(self, plan: dict[str, object]) -> TopoDS_Shape: + _target_kind, solid, _part, _source_solid = self._local_edge_deform_target(plan) + tolerance = max(_shape_diagonal(solid) * 1e-7, abs(float(plan.get("delta_length", 0.0))) * 1e-7, 1e-6) + faces = _explore(solid, TopAbs_FACE) + moved_faces: list[TopoDS_Shape] = [] + moved_points: dict[tuple[int, int, int], tuple[float, float, float]] = {} + for face in faces: + points = self._local_deform_face_vertex_points(face, tolerance) + if len(points) < 3: + raise RuntimeError("Local edge deformation could not read a stable face vertex loop.") + for point in points: + moved = self._local_edge_deform_moved_point(point, plan, tolerance) + moved_points[self._local_point_key(moved, tolerance)] = moved + + if not moved_points: + raise RuntimeError("Local edge deformation produced no moved vertices.") + center = ( + sum(point[0] for point in moved_points.values()) / len(moved_points), + sum(point[1] for point in moved_points.values()) / len(moved_points), + sum(point[2] for point in moved_points.values()) / len(moved_points), + ) + + for face in faces: + points = [ + self._local_edge_deform_moved_point(point, plan, tolerance) + for point in self._local_deform_face_vertex_points(face, tolerance) + ] + points = self._dedupe_local_points(points, tolerance) + if len(points) < 3: + raise RuntimeError("Local edge deformation collapsed a face.") + points = self._orient_local_polygon_outward(points, center) + if self._local_points_are_planar(points, tolerance): + moved_faces.append(self._make_local_polygon_face(points)) + else: + for index in range(1, len(points) - 1): + triangle = [points[0], points[index], points[index + 1]] + triangle = self._orient_local_polygon_outward(triangle, center) + moved_faces.append(self._make_local_polygon_face(triangle)) + + sewing = BRepBuilderAPI_Sewing(tolerance) + for face in moved_faces: + sewing.Add(face) + sewing.Perform() + sewed = sewing.SewedShape() + if sewed.IsNull(): + raise RuntimeError("Local edge deformation sewing produced an empty shape.") + if sewed.ShapeType() == TopAbs_SHELL: + shell = topods.Shell(sewed) + else: + shells = _explore(sewed, TopAbs_SHELL) + if not shells: + raise RuntimeError("Local edge deformation did not produce a sewable shell.") + shell = topods.Shell(shells[0]) + solid_builder = BRepBuilderAPI_MakeSolid(shell) + solid = solid_builder.Solid() + if solid.IsNull(): + raise RuntimeError("Local edge deformation could not create a solid from the rebuilt shell.") + return _ensure_valid_or_repaired_shape(solid, "local edge deformation") + + def _apply_local_edge_deform(self, plan: dict[str, object]) -> None: + target_kind, _source_shape, part, source_solid = self._local_edge_deform_target(plan) + transformed = self._local_edge_deform_shape(plan) + if target_kind == "part": + part.shape = transformed + else: + part_solids = _explore(part.shape, TopAbs_SOLID) + replaced = False + shapes: list[TopoDS_Shape] = [] + for item in part_solids: + if not replaced and source_solid is not None and _same_shape(item, source_solid): + shapes.append(transformed) + replaced = True + else: + shapes.append(item) + if not replaced: + raise RuntimeError(f"Could not locate solid {plan.get('solid_id')} inside part {plan.get('part_id')}.") + part.shape = _compound_from_shapes(shapes) + _ensure_valid_shape(part.shape) + self.refresh_topology() + + def _local_edge_deform_target( + self, + plan: dict[str, object], + ) -> tuple[str, TopoDS_Shape, object, TopoDS_Shape | None]: + part_id = int(plan.get("part_id", -1)) + solid_id = int(plan.get("solid_id", -1)) + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + if solid_id < 0 or solid_id >= len(self.solids): + raise ValueError(f"Unknown solid id {solid_id}") + target_kind = str(plan.get("local_edge_deform_target_kind", "part")) + solid = self.solids[solid_id][1] + return ("solid" if target_kind == "solid" else "part"), solid, part, solid + + def _local_deform_face_vertex_points( + self, + face: TopoDS_Shape, + tolerance: float, + ) -> list[tuple[float, float, float]]: + points: list[tuple[float, float, float]] = [] + explorer = TopExp_Explorer(face, TopAbs_VERTEX) + while explorer.More(): + vertex = topods.Vertex(explorer.Current()) + point = _point_tuple(BRep_Tool.Pnt(vertex)) + if not any(_vector_length(_tuple_sub(point, existing)) <= tolerance for existing in points): + points.append(point) + explorer.Next() + if len(points) < 3: + return points + surf = BRepAdaptor_Surface(face) + normal = _tuple_normalized(_dir_tuple(surf.Plane().Axis().Direction())) + if normal is None: + return points + if face.Orientation() == TopAbs_REVERSED: + normal = _tuple_scale(normal, -1.0) + return self._order_local_polygon_points(points, normal) + + def _order_local_polygon_points( + self, + points: list[tuple[float, float, float]], + normal: tuple[float, float, float], + ) -> list[tuple[float, float, float]]: + center = ( + sum(point[0] for point in points) / len(points), + sum(point[1] for point in points) / len(points), + sum(point[2] for point in points) / len(points), + ) + reference = (1.0, 0.0, 0.0) if abs(normal[0]) < 0.9 else (0.0, 1.0, 0.0) + u_axis = _tuple_normalized(_tuple_cross(normal, reference)) + if u_axis is None: + return points + v_axis = _tuple_normalized(_tuple_cross(normal, u_axis)) + if v_axis is None: + return points + ordered = sorted( + points, + key=lambda point: math.atan2( + _tuple_dot(_tuple_sub(point, center), v_axis), + _tuple_dot(_tuple_sub(point, center), u_axis), + ), + ) + polygon_normal = self._local_polygon_normal(ordered) + if polygon_normal is not None and _tuple_dot(polygon_normal, normal) < 0: + ordered.reverse() + return ordered + + def _local_edge_deform_moved_point( + self, + point: tuple[float, float, float], + plan: dict[str, object], + tolerance: float, + ) -> tuple[float, float, float]: + start = _tuple_or_none(plan.get("start_point")) + end = _tuple_or_none(plan.get("end_point")) + start_move = _tuple_or_none(plan.get("local_edge_deform_start_move")) or (0.0, 0.0, 0.0) + end_move = _tuple_or_none(plan.get("local_edge_deform_end_move")) or (0.0, 0.0, 0.0) + if start is not None and _vector_length(_tuple_sub(point, start)) <= tolerance: + return (point[0] + start_move[0], point[1] + start_move[1], point[2] + start_move[2]) + if end is not None and _vector_length(_tuple_sub(point, end)) <= tolerance: + return (point[0] + end_move[0], point[1] + end_move[1], point[2] + end_move[2]) + return point + + def _make_local_polygon_face(self, points: list[tuple[float, float, float]]) -> TopoDS_Shape: + polygon = BRepBuilderAPI_MakePolygon() + for point in points: + polygon.Add(gp_Pnt(*point)) + polygon.Close() + if hasattr(polygon, "IsDone") and not polygon.IsDone(): + raise RuntimeError("Local edge deformation could not create a polygon wire.") + maker = BRepBuilderAPI_MakeFace(polygon.Wire()) + if hasattr(maker, "IsDone") and not maker.IsDone(): + raise RuntimeError("Local edge deformation could not create a face from a polygon wire.") + face = maker.Face() + if face.IsNull(): + raise RuntimeError("Local edge deformation created an empty face.") + return face + + def _dedupe_local_points( + self, + points: list[tuple[float, float, float]], + tolerance: float, + ) -> list[tuple[float, float, float]]: + result: list[tuple[float, float, float]] = [] + for point in points: + if not any(_vector_length(_tuple_sub(point, existing)) <= tolerance for existing in result): + result.append(point) + if len(result) > 1 and _vector_length(_tuple_sub(result[0], result[-1])) <= tolerance: + result.pop() + return result + + def _local_points_are_planar( + self, + points: list[tuple[float, float, float]], + tolerance: float, + ) -> bool: + if len(points) <= 3: + return True + normal = self._local_polygon_normal(points) + if normal is None: + return False + origin = points[0] + return all(abs(_tuple_dot(_tuple_sub(point, origin), normal)) <= tolerance * 20.0 for point in points[3:]) + + def _local_polygon_normal( + self, + points: list[tuple[float, float, float]], + ) -> tuple[float, float, float] | None: + normal = (0.0, 0.0, 0.0) + count = len(points) + for index, point in enumerate(points): + next_point = points[(index + 1) % count] + normal = ( + normal[0] + (point[1] - next_point[1]) * (point[2] + next_point[2]), + normal[1] + (point[2] - next_point[2]) * (point[0] + next_point[0]), + normal[2] + (point[0] - next_point[0]) * (point[1] + next_point[1]), + ) + return _tuple_normalized(normal) + + def _orient_local_polygon_outward( + self, + points: list[tuple[float, float, float]], + shape_center: tuple[float, float, float], + ) -> list[tuple[float, float, float]]: + normal = self._local_polygon_normal(points) + if normal is None: + return points + center = ( + sum(point[0] for point in points) / len(points), + sum(point[1] for point in points) / len(points), + sum(point[2] for point in points) / len(points), + ) + if _tuple_dot(normal, _tuple_sub(center, shape_center)) < 0: + return list(reversed(points)) + return points + + def _local_point_key(self, point: tuple[float, float, float], tolerance: float) -> tuple[int, int, int]: + scale = max(float(tolerance), 1e-9) + return (round(point[0] / scale), round(point[1] / scale), round(point[2] / scale)) + def _edge_length_affine_preview_shape(self, plan: dict[str, object]) -> TopoDS_Shape: target_kind, source_shape, _part, _solid = self._edge_length_affine_target(plan) return self._affine_scaled_shape_along_edge(source_shape, plan) + def _refine_affine_edge_length_scale(self, plan: dict[str, object]) -> str: + edge_id = int(plan.get("edge_id", -1)) + if edge_id < 0 or edge_id >= len(self.edges): + return "" + target_length = float(plan.get("target_length", 0.0)) + if target_length <= 1e-9: + return "" + initial_scale = float(plan.get("affine_scale", 1.0)) + plan["affine_initial_scale"] = initial_scale + refined_scale = initial_scale + measured_length = 0.0 + iterations = 0 + tolerance = max(target_length * 5e-4, 1e-5) + for _index in range(3): + iterations += 1 + plan["affine_scale"] = refined_scale + try: + transformed_edge = self._affine_scaled_shape_along_edge(self.edges[edge_id], plan) + props = GProp_GProps() + brepgprop.LinearProperties(transformed_edge, props) + measured_length = float(props.Mass()) + except Exception: + plan["affine_scale"] = initial_scale + return "几何缩放比例预校正失败,将使用原始目标比例。" + if measured_length <= 1e-9: + plan["affine_scale"] = initial_scale + return "几何缩放比例预校正失败:预估Edge长度无效。" + if abs(measured_length - target_length) <= tolerance: + break + refined_scale *= target_length / measured_length + + plan["affine_scale"] = refined_scale + try: + transformed_edge = self._affine_scaled_shape_along_edge(self.edges[edge_id], plan) + props = GProp_GProps() + brepgprop.LinearProperties(transformed_edge, props) + final_measured_length = float(props.Mass()) + if final_measured_length > 1e-9: + measured_length = final_measured_length + except Exception: + pass + plan["affine_predicted_edge_length"] = measured_length + plan["affine_scale_correction"] = refined_scale / initial_scale if abs(initial_scale) > 1e-12 else 1.0 + plan["affine_scale_refine_iterations"] = iterations + if abs(refined_scale - initial_scale) > max(abs(initial_scale) * 1e-4, 1e-6): + plan["affine_scale_refine_note"] = "已用预变换Edge长度微调几何缩放比例。" + return "已用预变换Edge长度微调几何缩放比例,使目标Edge长度更接近输入值。" + plan["affine_scale_refine_note"] = "" + return "" + def _apply_edge_length_affine_transform(self, plan: dict[str, object]) -> None: target_kind, source_shape, part, solid = self._edge_length_affine_target(plan) transformed = self._affine_scaled_shape_along_edge(source_shape, plan) @@ -1282,11 +2133,18 @@ class OperationMixin: if axis_direction is None: raise ValueError("Missing affine edge-length axis direction.") ux, uy, uz = axis_direction - matrix = [ - [1.0 + (scale - 1.0) * ux * ux, (scale - 1.0) * ux * uy, (scale - 1.0) * ux * uz], - [(scale - 1.0) * uy * ux, 1.0 + (scale - 1.0) * uy * uy, (scale - 1.0) * uy * uz], - [(scale - 1.0) * uz * ux, (scale - 1.0) * uz * uy, 1.0 + (scale - 1.0) * uz * uz], - ] + if transform_kind == "radial-affine": + matrix = [ + [scale + (1.0 - scale) * ux * ux, (1.0 - scale) * ux * uy, (1.0 - scale) * ux * uz], + [(1.0 - scale) * uy * ux, scale + (1.0 - scale) * uy * uy, (1.0 - scale) * uy * uz], + [(1.0 - scale) * uz * ux, (1.0 - scale) * uz * uy, scale + (1.0 - scale) * uz * uz], + ] + else: + matrix = [ + [1.0 + (scale - 1.0) * ux * ux, (scale - 1.0) * ux * uy, (scale - 1.0) * ux * uz], + [(scale - 1.0) * uy * ux, 1.0 + (scale - 1.0) * uy * uy, (scale - 1.0) * uy * uz], + [(scale - 1.0) * uz * ux, (scale - 1.0) * uz * uy, 1.0 + (scale - 1.0) * uz * uz], + ] cx, cy, cz = axis_point moved_center = ( matrix[0][0] * cx + matrix[0][1] * cy + matrix[0][2] * cz, @@ -1302,10 +2160,10 @@ class OperationMixin: builder = BRepBuilderAPI_GTransform(shape, transform, True) builder.Build() if not builder.IsDone(): - raise RuntimeError("Affine edge-length transform failed.") + raise RuntimeError(f"{transform_kind} edge-length transform failed.") result = builder.Shape() if result.IsNull(): - raise RuntimeError("Affine edge-length transform produced an empty shape.") + raise RuntimeError(f"{transform_kind} edge-length transform produced an empty shape.") return result def push_pull_face(self, face_id: int, distance: float) -> str: diff --git a/step_editor/records.py b/step_editor/records.py index 1dc6941..5a6badb 100644 --- a/step_editor/records.py +++ b/step_editor/records.py @@ -7,6 +7,10 @@ from dataclasses import dataclass class OperationRecord: summary: str detail: str + operation_name: str | None = None + target: str | None = None + parameters: dict[str, object] | None = None + result_message: str | None = None target_kind: str | None = None target_id: int | None = None target_logical_id: int | None = None diff --git a/step_editor/transforms.py b/step_editor/transforms.py index 9d11c9d..d14532b 100644 --- a/step_editor/transforms.py +++ b/step_editor/transforms.py @@ -94,7 +94,7 @@ class TransformMixin: risk = readiness["translate_risk"] status = readiness["translate_status"] if part_solid_count <= 1 and status != "blocked": - warnings = _join_nonempty(warnings, "当前 part 只有一个 solid,平移 solid 实际会移动整个 part shape。") + warnings = _join_nonempty(warnings, "当前Part只有一个Solid,平移Solid实际会移动整个Part shape。") if risk == "low": risk = "medium" status = "caution" @@ -192,7 +192,7 @@ class TransformMixin: risk = readiness["rotate_risk"] status = readiness["rotate_status"] if part_solid_count <= 1 and status != "blocked": - warnings = _join_nonempty(warnings, "当前 part 只有一个 solid,旋转 solid 实际会旋转整个 part shape。") + warnings = _join_nonempty(warnings, "当前Part只有一个Solid,旋转Solid实际会旋转整个Part shape。") if risk == "low": risk = "medium" status = "caution" diff --git a/step_editor/ui_helpers.py b/step_editor/ui_helpers.py index ba649e1..6ce2968 100644 --- a/step_editor/ui_helpers.py +++ b/step_editor/ui_helpers.py @@ -65,6 +65,13 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [ "length_change_ratio", "edge_length_anchor_mode", "edge_length_anchor_label", + "affine_initial_scale", + "affine_scale", + "affine_scale_correction", + "affine_predicted_edge_length", + "affine_scale_refine_iterations", + "planar_edge_scale_sample_count", + "planar_edge_scale_plane_deviation", "circular_edge_current_radius", "circular_edge_target_radius", "circular_edge_length_scale", @@ -160,8 +167,15 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [ "shell_source_face_ids", "shell_opposite_face_id", "shell_thickness_estimate", + "shell_current_thickness", + "shell_target_thickness", + "shell_delta_thickness", + "shell_delta_ratio", + "shell_signed_thickness", "shell_overlap_ratio_estimate", "shell_opposite_normal_dot", + "shell_desired_movement_vector", + "shell_movement_alignment", "shell_note", "shell_region_note", ], @@ -379,13 +393,21 @@ INFO_LABELS = { "surface_area": "表面积", "area": "面积", "length": "长度", - "edge_length": "边长", - "current_length": "当前边长", - "target_length": "目标边长", - "delta_length": "边长变化量", - "length_change_ratio": "边长变化比例", - "edge_length_anchor_mode": "边长基准模式", - "edge_length_anchor_label": "边长基准", + "edge_length": "Edge长度", + "current_length": "当前Edge长度", + "target_length": "目标Edge长度", + "delta_length": "Edge长度变化量", + "length_change_ratio": "Edge长度变化比例", + "edge_length_anchor_mode": "Edge长度基准模式", + "edge_length_anchor_label": "Edge长度基准", + "local_edge_deform_target_kind": "局部边形变目标", + "local_edge_deform_anchor": "局部边形变基准", + "local_edge_deform_face_count": "局部边形变重建面数", + "local_edge_deform_start_move": "局部边形变起点移动", + "local_edge_deform_end_move": "局部边形变终点移动", + "local_edge_deform_moved_endpoint_count": "局部边形变移动端点数", + "local_edge_deform_risk": "局部边形变风险", + "local_edge_deform_note": "局部边形变说明", "circular_edge_current_radius": "圆边当前半径", "circular_edge_target_radius": "圆边目标半径", "circular_edge_length_scale": "圆边长度比例", @@ -400,10 +422,28 @@ INFO_LABELS = { "cylinder_resize_delta_ratio": "圆柱直径变化比例", "cylinder_resize_status": "圆柱编辑状态", "cylinder_resize_risk": "圆柱编辑风险", - "cylinder_resize_feature_guess": "圆柱候选判断", + "cylinder_resize_feature_guess": "圆柱面候选判断", "cylinder_resize_confidence": "圆柱判断置信度", "cylinder_resize_same_domain_face_ids": "圆柱同域 Face", "cylinder_resize_same_domain_face_count": "圆柱同域 Face 数", + "affine_scale": "几何缩放比例", + "affine_initial_scale": "几何缩放初始比例", + "affine_scale_correction": "几何缩放校正比例", + "affine_predicted_edge_length": "几何缩放预估Edge长度", + "affine_scale_refine_iterations": "几何缩放校正次数", + "affine_scale_refine_note": "几何缩放校正说明", + "affine_transform_kind": "几何缩放类型", + "affine_transform_label": "几何缩放类型", + "affine_transform_note": "几何缩放说明", + "affine_transform_warning": "几何缩放警告", + "affine_transform_risk": "几何缩放风险", + "affine_axis_point": "几何缩放基准点", + "affine_axis_direction": "几何缩放轴线方向", + "affine_axis_source": "几何缩放轴线来源", + "affine_anchor_source": "几何缩放基准来源", + "affine_target_kind": "几何缩放目标", + "planar_edge_scale_sample_count": "平面曲线采样点数", + "planar_edge_scale_plane_deviation": "平面曲线采样偏差", "radius": "半径", "diameter": "直径", "target_radius": "目标圆角半径", @@ -476,11 +516,19 @@ INFO_LABELS = { "shell_region_kind": "壳体/薄壁候选类型", "shell_region_status": "壳体/薄壁识别状态", "shell_confidence": "壳体/薄壁置信度", + "shell_thickness": "薄壁厚度", "shell_source_face_ids": "壳体/薄壁源平面 Face", "shell_opposite_face_id": "相对平面 Face", "shell_thickness_estimate": "薄壁厚度估算", + "shell_current_thickness": "当前薄壁厚度", + "shell_target_thickness": "目标薄壁厚度", + "shell_delta_thickness": "薄壁厚度变化量", + "shell_delta_ratio": "薄壁厚度变化比例", + "shell_signed_thickness": "有符号薄壁厚度", "shell_overlap_ratio_estimate": "相对平面重叠率估算", "shell_opposite_normal_dot": "相对平面法向点积", + "shell_desired_movement_vector": "薄壁目标移动向量", + "shell_movement_alignment": "薄壁移动方向匹配度", "shell_note": "壳体/薄壁识别说明", "shell_region_note": "壳体/薄壁识别说明", "u_range": "U 参数范围", @@ -730,10 +778,10 @@ def _edit_quality_warnings(before_part_stats, after_part_stats) -> list[str]: return [] warnings: list[str] = [] if after_part_stats.solids == 0: - warnings.append("目标零件编辑后没有检测到 solid,导出前请确认模型是否有效。") + warnings.append("目标零件编辑后没有检测到Solid,导出前请确认模型是否有效。") elif before_part_stats.solids > 0 and after_part_stats.solids != before_part_stats.solids: warnings.append( - "目标零件 solid 数发生变化:" + "目标零件Solid数发生变化:" f"{before_part_stats.solids} -> {after_part_stats.solids}。" "如果这是一次局部推拉/孔径修改,请重点检查导出后是否仍是一体实体。" ) diff --git a/step_editor/widgets.py b/step_editor/widgets.py index f82e973..439b8a6 100644 --- a/step_editor/widgets.py +++ b/step_editor/widgets.py @@ -5,4 +5,7 @@ from PySide6.QtWidgets import QComboBox class NoWheelComboBox(QComboBox): def wheelEvent(self, event) -> None: - event.accept() + if self.view().isVisible(): + super().wheelEvent(event) + return + event.ignore() diff --git a/step_editor/window_actions.py b/step_editor/window_actions.py index b739329..4e31f5f 100644 --- a/step_editor/window_actions.py +++ b/step_editor/window_actions.py @@ -20,6 +20,19 @@ from .ui_helpers import * # noqa: F403 from .workers import EditWorker, LoadWorker, ScanWorker +def _message_field(message: str, key: str) -> str | None: + marker = f"{key}=" + start = message.find(marker) + if start < 0: + return None + start += len(marker) + end = message.find(",", start) + if end < 0: + end = len(message) + value = message[start:end].strip().rstrip(".") + return value or None + + class WindowActionMixin: def export_all(self) -> None: if self.model is None: @@ -69,13 +82,13 @@ class WindowActionMixin: if self._edit_busy("请等待当前编辑完成后再导出。"): return if self.selected_solid_id is None: - QMessageBox.information(self, "未选择 solid", "请先选择一个 solid,或选择一个属于 solid 的 face。") + QMessageBox.information(self, "未选择 Solid", "请先选择一个 Solid,或选择一个属于 Solid 的 Face。") return if not self._confirm_export_quality("solid", self.selected_solid_id): return target, _ = QFileDialog.getSaveFileName( self, - "导出选中 solid", + "导出选中 Solid", str(self.step_path.parent / f"solid_{self.selected_solid_id}_export.step"), "STEP 文件 (*.step *.stp);;所有文件 (*.*)", ) @@ -83,7 +96,7 @@ class WindowActionMixin: return self._run_action( lambda: self.model.export_solid(self.selected_solid_id, target), - f"已导出选中 solid 到 {Path(target).name}", + f"已导出选中 Solid 到 {Path(target).name}", ) def export_selected_face(self) -> None: @@ -92,7 +105,7 @@ class WindowActionMixin: if self._edit_busy("请等待当前编辑完成后再导出。"): return if self.selected_face_id is None: - QMessageBox.information(self, "未选择 face", "请先切换到 Face 或 Feature 模式并选择一个 face。") + QMessageBox.information(self, "未选择 Face", "请先切换到 Face 或 Feature 模式并选择一个 Face。") return if not self._confirm_export_quality("face", self.selected_face_id): return @@ -138,13 +151,13 @@ class WindowActionMixin: if self._edit_busy("请等待当前编辑完成后再导出。"): return if self.selected_edge_id is None: - QMessageBox.information(self, "未选择 edge", "请先切换到 Edge 模式并选择一个 edge。") + QMessageBox.information(self, "未选择Edge", "请先切换到Edge模式并选择一个Edge。") return if not self._confirm_export_quality("edge", self.selected_edge_id): return target, _ = QFileDialog.getSaveFileName( self, - "导出选中 edge", + "导出选中 Edge", str(self.step_path.parent / f"edge_{self.selected_edge_id}_export.step"), "STEP 文件 (*.step *.stp);;所有文件 (*.*)", ) @@ -152,7 +165,7 @@ class WindowActionMixin: return self._run_action( lambda: self.model.export_edge(self.selected_edge_id, target), - f"已导出选中 edge 到 {Path(target).name}", + f"已导出选中 Edge 到 {Path(target).name}", ) def check_export_quality(self) -> None: @@ -246,8 +259,8 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="修复选中 solid", - target=f"solid {solid_id}", + operation_name="修复选中Solid", + target=f"Solid {solid_id}", parameters={ "scope": "solid", "solid_id": solid_id, @@ -268,7 +281,7 @@ class WindowActionMixin: self._run_edit_action( action, operation_name="修复选中零件", - target=f"part {part_id}", + target=f"Part {part_id}", parameters={ "scope": "part", "part_id": part_id, @@ -279,7 +292,7 @@ class WindowActionMixin: ) return - QMessageBox.information(self, "未选择对象", "请先选择 part、solid、face 或 edge。") + QMessageBox.information(self, "未选择对象", "请先选择 Part、Solid、Face 或 Edge。") self.statusBar().showMessage("未选择可修复对象") def push_pull_face(self) -> None: @@ -288,12 +301,12 @@ class WindowActionMixin: if self._edit_busy("请等待当前编辑完成后再执行新的推拉。"): return if self.selected_face_id is None: - QMessageBox.information(self, "未选择面", "请先选择一个平面 face。") + QMessageBox.information(self, "未选择 Face", "请先选择一个平面 Face。") return try: distance = float(self.offset_input.text()) except ValueError: - QMessageBox.critical(self, "距离无效", "请输入数字形式的面偏移距离。") + QMessageBox.critical(self, "距离无效", "请输入数字形式的Face偏移距离。") return face_id = self.selected_face_id plan = self.model.push_pull_plan(face_id, distance) @@ -309,7 +322,7 @@ class WindowActionMixin: self._run_edit_action( action, operation_name="推拉平面", - target=f"face {face_id}", + target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), "solid_id": plan.get("solid_id"), @@ -331,6 +344,98 @@ class WindowActionMixin: target_id=face_id, ) + def resize_shell_thickness(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再调整薄壁厚度。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择平面", "请先选择一个已识别到相对面的薄壁/壳体平面候选。") + return + if not hasattr(self, "shell_thickness_input"): + return + try: + target_thickness = float(self.shell_thickness_input.text()) + except ValueError: + QMessageBox.critical(self, "目标厚度无效", "请输入数字形式的目标薄壁厚度。") + return + + face_id = self.selected_face_id + plan = self.model.shell_thickness_plan(face_id, target_thickness) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能调整薄壁厚度", str(plan["message"])) + self.statusBar().showMessage("薄壁厚度调整已阻止") + return + + if plan["risk"] != "low": + warnings = str(plan.get("warnings", "")) + warnings_line = f"警告: {warnings}\n\n" if warnings else "" + result = QMessageBox.question( + self, + "确认调整薄壁厚度", + ( + f"face: {plan['face_id']}\n" + f"当前厚度估算: {_format_value(plan['shell_current_thickness'])}\n" + f"目标厚度: {_format_value(plan['shell_target_thickness'])}\n" + f"厚度变化量: {_format_value(plan['shell_delta_thickness'])}\n" + f"厚度变化比例: {_format_percent(plan['shell_delta_ratio'])}\n" + f"相对平面 Face: {_format_value(plan.get('shell_opposite_face_id', ''))}\n" + f"相对平面重叠率: {_format_percent(plan.get('shell_overlap_ratio_estimate'))}\n" + f"置信度: {_format_value(plan.get('shell_confidence', ''))}\n" + f"当前面推拉距离: {_format_value(plan.get('push_pull_distance', ''))}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "当前版本会移动当前平面区域,让它和相对平面的距离接近目标厚度;这不是 CAD 壳命令参数编辑。确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if result != QMessageBox.StandardButton.Yes: + self.statusBar().showMessage("已取消薄壁厚度调整") + return + + self._show_shell_thickness_preview(face_id, target_thickness) + + def action(): + return self.model.resize_shell_thickness(face_id, target_thickness) + + self._run_edit_action( + action, + operation_name="调整薄壁厚度", + target=f"Face {face_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "shell_region_kind": plan.get("shell_region_kind"), + "shell_confidence": plan.get("shell_confidence"), + "shell_source_face_ids": plan.get("shell_source_face_ids"), + "shell_opposite_face_id": plan.get("shell_opposite_face_id"), + "shell_current_thickness": plan.get("shell_current_thickness"), + "shell_target_thickness": plan.get("shell_target_thickness"), + "shell_delta_thickness": plan.get("shell_delta_thickness"), + "shell_delta_ratio": plan.get("shell_delta_ratio"), + "shell_signed_thickness": plan.get("shell_signed_thickness"), + "shell_overlap_ratio_estimate": plan.get("shell_overlap_ratio_estimate"), + "shell_opposite_normal_dot": plan.get("shell_opposite_normal_dot"), + "shell_desired_movement_vector": plan.get("shell_desired_movement_vector"), + "shell_movement_alignment": plan.get("shell_movement_alignment"), + "push_pull_distance": plan.get("push_pull_distance"), + "outward_direction": plan.get("outward_direction"), + "push_pull_scope_face_ids": plan.get("push_pull_scope_face_ids"), + "push_pull_scope_face_count": plan.get("push_pull_scope_face_count"), + "push_pull_scope_note": plan.get("push_pull_scope_note"), + "resize_strategy": plan.get("resize_strategy"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + }, + target_kind="feature" if self.selected_kind == "feature" else "face", + target_id=face_id, + ) + def resize_hole(self) -> None: if self.model is None: return @@ -346,7 +451,7 @@ class WindowActionMixin: return info = self.model.face_info(self.selected_face_id) if "diameter" not in info: - QMessageBox.information(self, "不是圆柱面", "当前选中的 face 不是圆柱面,不能调整圆柱孔径。") + QMessageBox.information(self, "不是圆柱面", "当前选中的 Face 不是圆柱面,不能调整圆柱孔径。") return plan = self.model.cylindrical_resize_plan(self.selected_face_id, diameter) if plan["status"] == "blocked": @@ -391,7 +496,7 @@ class WindowActionMixin: f"补料策略: {plan.get('fill_strategy', '')}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" - "继续操作会对当前零件执行实验性布尔修改。\n\n" + "继续操作会对当前零件执行受限B-Rep布尔修改。\n\n" "确定继续吗?" ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, @@ -409,7 +514,7 @@ class WindowActionMixin: self._run_edit_action( action, operation_name="调整圆柱孔径", - target=f"face {face_id}", + target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), "solid_id": plan.get("solid_id"), @@ -461,7 +566,7 @@ class WindowActionMixin: if self._edit_busy("请等待当前编辑完成后再调整槽/半孔宽度。"): return if self.selected_face_id is None: - QMessageBox.information(self, "未选择槽/半孔", "请先选择一个槽/半孔候选 face。") + QMessageBox.information(self, "未选择槽/半孔", "请先选择一个槽/半孔候选 Face。") return if not hasattr(self, "slot_width_input"): return @@ -477,7 +582,7 @@ class WindowActionMixin: face_id = self.selected_face_id info = self.model.feature_info(face_id) if info.get("surface") != "cylinder" or info.get("slot_kind") != "partial-cylindrical-groove": - QMessageBox.information(self, "不是槽/半孔候选", "当前选中 face 没有被识别为槽/半孔候选。") + QMessageBox.information(self, "不是槽/半孔候选", "当前选中Face没有被识别为槽/半孔候选。") return angular_span = _float_or_none(info.get("slot_angular_span")) if angular_span is None: @@ -513,7 +618,7 @@ class WindowActionMixin: f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" - "第一版会把槽宽换算成圆柱直径后执行孔/槽重建,不是完整 CAD 槽参数编辑。确定继续吗?" + "当前版本会把槽宽换算成圆柱直径后执行孔/槽重建,不是完整 CAD 槽参数编辑。确定继续吗?" ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, @@ -530,7 +635,7 @@ class WindowActionMixin: self._run_edit_action( action, operation_name="调整槽/半孔宽度", - target=f"face {face_id}", + target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), "solid_id": plan.get("solid_id"), @@ -573,7 +678,7 @@ class WindowActionMixin: if self._edit_busy("请等待当前编辑完成后再调整凸台直径。"): return if self.selected_face_id is None: - QMessageBox.information(self, "未选择圆柱凸台", "请先选择一个圆柱凸台候选 face。") + QMessageBox.information(self, "未选择圆柱凸台", "请先选择一个圆柱凸台候选 Face。") return try: diameter = float(self.boss_diameter_input.text()) @@ -582,7 +687,7 @@ class WindowActionMixin: return info = self.model.face_info(self.selected_face_id) if "diameter" not in info: - QMessageBox.information(self, "不是圆柱面", "当前选中的 face 不是圆柱面,不能调整圆柱凸台直径。") + QMessageBox.information(self, "不是圆柱面", "当前选中的 Face 不是圆柱面,不能调整圆柱凸台直径。") return plan = self.model.cylindrical_boss_resize_plan(self.selected_face_id, diameter) if plan["status"] == "blocked": @@ -618,7 +723,7 @@ class WindowActionMixin: f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" - "继续操作会对当前零件执行实验性布尔修改。\n\n" + "继续操作会对当前零件执行受限B-Rep布尔修改。\n\n" "确定继续吗?" ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, @@ -637,7 +742,7 @@ class WindowActionMixin: self._run_edit_action( action, operation_name="调整圆柱凸台直径", - target=f"face {face_id}", + target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), "solid_id": plan.get("solid_id"), @@ -677,12 +782,12 @@ class WindowActionMixin: if self._edit_busy("请等待当前编辑完成后再封堵圆柱孔。"): return if self.selected_face_id is None: - QMessageBox.information(self, "未选择圆柱面", "请先选择一个圆柱孔 face。") + QMessageBox.information(self, "未选择圆柱面", "请先选择一个圆柱孔 Face。") return info = self.model.face_info(self.selected_face_id) if info.get("surface") != "cylinder" or "diameter" not in info: - QMessageBox.information(self, "不是圆柱孔", "当前选中的 face 不是可封堵的圆柱孔。") + QMessageBox.information(self, "不是圆柱孔", "当前选中的 Face 不是可封堵的圆柱孔。") return plan = self.model.cylindrical_suppress_plan(self.selected_face_id) if plan["status"] == "blocked": @@ -711,7 +816,7 @@ class WindowActionMixin: f"补料高度: {_format_value(plan['fill_height'])}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" - "继续操作会用补料体封堵当前孔,并对当前零件执行实验性 Fuse。\n\n" + "继续操作会用补料体封堵当前孔,并对当前零件执行受限OCCT Fuse。\n\n" "确定继续吗?" ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, @@ -730,7 +835,7 @@ class WindowActionMixin: self._run_edit_action( action, operation_name="封堵圆柱孔", - target=f"face {face_id}", + target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), "solid_id": plan.get("solid_id"), @@ -793,7 +898,7 @@ class WindowActionMixin: info = self.model.face_info(self.selected_face_id) if info.get("surface") != "cylinder": - QMessageBox.information(self, "不是圆柱孔/槽", "当前选中的 face 不是可调整孔深的圆柱孔/槽。") + QMessageBox.information(self, "不是圆柱孔/槽", "当前选中的 Face 不是可调整孔深的圆柱孔/槽。") return plan = self.model.cylindrical_depth_plan( self.selected_face_id, @@ -835,7 +940,7 @@ class WindowActionMixin: f"工具半径: {_format_value(plan['depth_tool_radius'])}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" - "继续操作会对当前零件执行实验性布尔修改。\n\n" + "继续操作会对当前零件执行受限B-Rep布尔修改。\n\n" "确定继续吗?" ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, @@ -858,7 +963,7 @@ class WindowActionMixin: self._run_edit_action( action, operation_name="调整盲孔深度", - target=f"face {face_id}", + target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), "solid_id": plan.get("solid_id"), @@ -907,7 +1012,7 @@ class WindowActionMixin: if self._edit_busy("请等待当前编辑完成后再修改已有圆角。"): return if self.selected_face_id is None: - QMessageBox.information(self, "未选择已有圆角", "请先选择一个已有圆角/倒圆候选 face。") + QMessageBox.information(self, "未选择已有圆角", "请先选择一个已有圆角/倒圆候选 Face。") return try: target_radius = float(self.edge_fillet_radius_input.text()) @@ -962,7 +1067,7 @@ class WindowActionMixin: self._run_edit_action( action, operation_name="修改已有圆角半径", - target=f"face {face_id}", + target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), "solid_id": plan.get("solid_id"), @@ -997,7 +1102,7 @@ class WindowActionMixin: if self._edit_busy("请等待当前编辑完成后再添加圆角。"): return if self.selected_edge_id is None: - QMessageBox.information(self, "未选择边", "请先选择一个直线 edge。") + QMessageBox.information(self, "未选择Edge", "请先选择一条直线Edge。") return try: radius = float(self.edge_fillet_radius_input.text()) @@ -1019,18 +1124,18 @@ class WindowActionMixin: warnings_line = f"警告: {warnings}\n\n" if warnings else "" result = QMessageBox.question( self, - "确认给边添加圆角", + "确认给Edge添加圆角", ( - f"edge: {plan['edge_id']}\n" - f"边长: {_format_value(plan['edge_length'])}\n" + f"Edge: {plan['edge_id']}\n" + f"Edge长度: {_format_value(plan['edge_length'])}\n" f"目标圆角半径: {_format_value(plan['target_radius'])}\n" - f"半径/边长比例: {_format_percent(plan['radius_to_length_ratio'])}\n" + f"半径/Edge长度比例: {_format_percent(plan['radius_to_length_ratio'])}\n" f"相邻 Face: {_format_value(plan.get('adjacent_face_ids', ''))}\n" f"相邻 Face 数: {_format_value(plan.get('adjacent_face_count', ''))}\n" f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" - "继续操作会对当前零件执行实验性 OCCT 倒圆。\n\n" + "继续操作会调用OCCT倒圆并真实修改当前零件。\n\n" "确定继续吗?" ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, @@ -1047,8 +1152,8 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="给边添加圆角", - target=f"edge {edge_id}", + operation_name="给Edge添加圆角", + target=f"Edge {edge_id}", parameters={ "part_id": plan.get("part_id"), "solid_id": plan.get("solid_id"), @@ -1074,7 +1179,7 @@ class WindowActionMixin: if self._edit_busy("请等待当前编辑完成后再添加倒角。"): return if self.selected_edge_id is None: - QMessageBox.information(self, "未选择边", "请先选择一个直线 edge。") + QMessageBox.information(self, "未选择Edge", "请先选择一条直线Edge。") return try: distance = float(self.edge_chamfer_distance_input.text()) @@ -1096,18 +1201,18 @@ class WindowActionMixin: warnings_line = f"警告: {warnings}\n\n" if warnings else "" result = QMessageBox.question( self, - "确认给边添加倒角", + "确认给Edge添加倒角", ( - f"edge: {plan['edge_id']}\n" - f"边长: {_format_value(plan['edge_length'])}\n" + f"Edge: {plan['edge_id']}\n" + f"Edge长度: {_format_value(plan['edge_length'])}\n" f"目标倒角距离: {_format_value(plan['target_distance'])}\n" - f"倒角距离/边长比例: {_format_percent(plan['distance_to_length_ratio'])}\n" + f"倒角距离/Edge长度比例: {_format_percent(plan['distance_to_length_ratio'])}\n" f"相邻 Face: {_format_value(plan.get('adjacent_face_ids', ''))}\n" f"相邻 Face 数: {_format_value(plan.get('adjacent_face_count', ''))}\n" f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" - "继续操作会对当前零件执行实验性 OCCT 倒角。\n\n" + "继续操作会调用OCCT倒角并真实修改当前零件。\n\n" "确定继续吗?" ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, @@ -1124,8 +1229,8 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="给边添加倒角", - target=f"edge {edge_id}", + operation_name="给Edge添加倒角", + target=f"Edge {edge_id}", parameters={ "part_id": plan.get("part_id"), "solid_id": plan.get("solid_id"), @@ -1148,23 +1253,23 @@ class WindowActionMixin: def resize_edge_length(self) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再直接修改边长。"): + if self._edit_busy("请等待当前编辑完成后再修改Edge长度。"): return if self.selected_edge_id is None: - QMessageBox.information(self, "未选择边", "请先选择一条 edge。") + QMessageBox.information(self, "未选择Edge", "请先选择一条Edge。") return try: target_length = float(self.edge_target_length_input.text()) except ValueError: - QMessageBox.critical(self, "目标长度无效", "请输入数字形式的目标边长。") + QMessageBox.critical(self, "目标长度无效", "请输入数字形式的Edge目标长度。") return edge_id = self.selected_edge_id anchor_mode = self._edge_length_anchor_mode_from_ui() plan = self.model.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode) if plan["status"] == "blocked": - QMessageBox.information(self, "不能直接修改边长", str(plan["message"])) - self.statusBar().showMessage("边长直接修改已阻止") + QMessageBox.information(self, "不能修改Edge长度", str(plan["message"])) + self.statusBar().showMessage("Edge长度修改已阻止") return if plan["risk"] != "low": @@ -1172,33 +1277,40 @@ class WindowActionMixin: warnings_line = f"警告: {warnings}\n\n" if warnings else "" result = QMessageBox.question( self, - "确认直接修改边长", + "确认修改Edge长度", ( - f"edge: {plan['edge_id']}\n" - f"当前边长: {_format_value(plan['current_length'])}\n" - f"目标边长: {_format_value(plan['target_length'])}\n" + f"Edge: {plan['edge_id']}\n" + f"当前Edge长度: {_format_value(plan['current_length'])}\n" + f"目标Edge长度: {_format_value(plan['target_length'])}\n" f"变化量: {_format_value(plan['delta_length'])}\n" - f"边长基准: {_format_value(plan.get('edge_length_anchor_label', ''))}\n" + f"Edge长度基准: {_format_value(plan.get('edge_length_anchor_label', ''))}\n" f"策略: {_format_value(plan.get('resize_strategy', ''))}\n" - f"端面 face: {_format_value(plan.get('end_face_id', ''))}\n" + f"局部形变目标: {_format_value(plan.get('local_edge_deform_target_kind', ''))}\n" + f"局部形变: {_format_value(plan.get('local_edge_deform_anchor', ''))}\n" + f"重建面数: {_format_value(plan.get('local_edge_deform_face_count', ''))}\n" + f"端面Face: {_format_value(plan.get('end_face_id', ''))}\n" f"端面位置: {_format_value(plan.get('end_face_label', ''))}\n" f"端面推拉距离: {_format_value(plan.get('push_pull_distance', ''))}\n" - f"圆柱 face: {_format_value(plan.get('cylinder_resize_face_id', ''))}\n" - f"圆边模式: {_format_value(plan.get('circular_edge_cylinder_mode_label', ''))}\n" + f"圆柱Face: {_format_value(plan.get('cylinder_resize_face_id', ''))}\n" + f"圆形Edge模式: {_format_value(plan.get('circular_edge_cylinder_mode_label', ''))}\n" f"换算目标直径: {_format_value(plan.get('cylinder_resize_target_diameter', ''))}\n" + f"缩放类型: {_format_value(plan.get('affine_transform_label') or plan.get('affine_transform_kind', ''))}\n" + f"缩放说明: {_format_value(plan.get('affine_transform_note', ''))}\n" f"缩放目标: {_format_value(plan.get('affine_target_kind', ''))}\n" + f"预估Edge长度: {_format_value(plan.get('affine_predicted_edge_length', ''))}\n" + f"缩放校正: {_format_value(plan.get('affine_scale_correction', ''))}\n" f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" - "当前会优先通过端面推拉或相邻圆柱直径编辑改变边长;没有明确局部路径时会尝试几何缩放 fallback。" - "这不是通用参数化边长编辑。\n\n" + "当前会优先只移动被选直线Edge的端点并重建周边平面;没有明确局部路径时会尝试端面推拉、相邻圆柱直径编辑或几何缩放 fallback。" + "这不是通用参数化Edge长度编辑。\n\n" "确定继续吗?" ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if result != QMessageBox.StandardButton.Yes: - self.statusBar().showMessage("已取消边长直接修改") + self.statusBar().showMessage("已取消Edge长度修改") return self._show_edge_length_preview(edge_id, target_length, anchor_mode=anchor_mode) @@ -1208,8 +1320,8 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="直接修改边长", - target=f"edge {edge_id}", + operation_name="修改Edge长度", + target=f"Edge {edge_id}", parameters={ "part_id": plan.get("part_id"), "solid_id": plan.get("solid_id"), @@ -1219,17 +1331,37 @@ class WindowActionMixin: "length_change_ratio": plan.get("length_change_ratio"), "edge_length_anchor_mode": plan.get("edge_length_anchor_mode"), "edge_length_anchor_label": plan.get("edge_length_anchor_label"), + "local_edge_deform_target_kind": plan.get("local_edge_deform_target_kind"), + "local_edge_deform_anchor": plan.get("local_edge_deform_anchor"), + "local_edge_deform_face_count": plan.get("local_edge_deform_face_count"), + "local_edge_deform_start_move": plan.get("local_edge_deform_start_move"), + "local_edge_deform_end_move": plan.get("local_edge_deform_end_move"), + "local_edge_deform_moved_endpoint_count": plan.get("local_edge_deform_moved_endpoint_count"), + "local_edge_deform_risk": plan.get("local_edge_deform_risk"), + "local_edge_deform_note": plan.get("local_edge_deform_note"), "end_face_id": plan.get("end_face_id"), + "end_face_endpoint_role": plan.get("end_face_endpoint_role"), "end_face_label": plan.get("end_face_label"), "push_pull_distance": plan.get("push_pull_distance"), "resize_strategy": plan.get("resize_strategy"), "affine_scale": plan.get("affine_scale"), + "affine_initial_scale": plan.get("affine_initial_scale"), + "affine_scale_correction": plan.get("affine_scale_correction"), + "affine_predicted_edge_length": plan.get("affine_predicted_edge_length"), + "affine_scale_refine_iterations": plan.get("affine_scale_refine_iterations"), + "affine_scale_refine_note": plan.get("affine_scale_refine_note"), "affine_transform_kind": plan.get("affine_transform_kind"), + "affine_transform_label": plan.get("affine_transform_label"), + "affine_transform_note": plan.get("affine_transform_note"), + "affine_transform_warning": plan.get("affine_transform_warning"), + "affine_transform_risk": plan.get("affine_transform_risk"), "affine_axis_point": plan.get("affine_axis_point"), "affine_axis_direction": plan.get("affine_axis_direction"), "affine_axis_source": plan.get("affine_axis_source"), "affine_anchor_source": plan.get("affine_anchor_source"), "affine_target_kind": plan.get("affine_target_kind"), + "planar_edge_scale_sample_count": plan.get("planar_edge_scale_sample_count"), + "planar_edge_scale_plane_deviation": plan.get("planar_edge_scale_plane_deviation"), "circular_edge_current_radius": plan.get("circular_edge_current_radius"), "circular_edge_target_radius": plan.get("circular_edge_target_radius"), "circular_edge_length_scale": plan.get("circular_edge_length_scale"), @@ -1261,23 +1393,23 @@ class WindowActionMixin: def resize_any_edge_length(self) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再直接修改边长。"): + if self._edit_busy("请等待当前编辑完成后再修改Edge长度。"): return if self.selected_edge_id is None: - QMessageBox.information(self, "未选择边", "请先选择一条 edge。") + QMessageBox.information(self, "未选择Edge", "请先选择一条Edge。") return try: target_length = float(self.edge_target_length_input.text()) except ValueError: - QMessageBox.critical(self, "目标长度无效", "请输入数字形式的目标边长。") + QMessageBox.critical(self, "目标长度无效", "请输入数字形式的Edge目标长度。") return edge_id = self.selected_edge_id anchor_mode = self._edge_length_anchor_mode_from_ui() plan = self.model.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode) if plan["status"] == "blocked": - QMessageBox.information(self, "不能修改边长", str(plan["message"])) - self.statusBar().showMessage("边长直接修改已阻止") + QMessageBox.information(self, "不能修改Edge长度", str(plan["message"])) + self.statusBar().showMessage("Edge长度修改已阻止") return warnings = str(plan.get("warnings", "")) @@ -1286,24 +1418,31 @@ class WindowActionMixin: if plan["risk"] != "low": result = QMessageBox.question( self, - "确认直接修改边长", + "确认修改Edge长度", ( - f"edge: {plan['edge_id']}\n" + f"Edge: {plan['edge_id']}\n" f"曲线类型: {_format_value(plan.get('curve', ''))}\n" - f"当前边长: {_format_value(plan['current_length'])}\n" - f"目标边长: {_format_value(plan['target_length'])}\n" + f"当前Edge长度: {_format_value(plan['current_length'])}\n" + f"目标Edge长度: {_format_value(plan['target_length'])}\n" f"变化量: {_format_value(plan['delta_length'])}\n" - f"边长基准: {_format_value(plan.get('edge_length_anchor_label', ''))}\n" + f"Edge长度基准: {_format_value(plan.get('edge_length_anchor_label', ''))}\n" f"策略: {_format_value(strategy)}\n" - f"端面 face: {_format_value(plan.get('end_face_id', ''))}\n" + f"局部形变目标: {_format_value(plan.get('local_edge_deform_target_kind', ''))}\n" + f"局部形变: {_format_value(plan.get('local_edge_deform_anchor', ''))}\n" + f"重建面数: {_format_value(plan.get('local_edge_deform_face_count', ''))}\n" + f"端面Face: {_format_value(plan.get('end_face_id', ''))}\n" f"端面推拉距离: {_format_value(plan.get('push_pull_distance', ''))}\n" - f"圆柱 face: {_format_value(plan.get('cylinder_resize_face_id', ''))}\n" - f"圆边模式: {_format_value(plan.get('circular_edge_cylinder_mode_label', ''))}\n" + f"圆柱Face: {_format_value(plan.get('cylinder_resize_face_id', ''))}\n" + f"圆形Edge模式: {_format_value(plan.get('circular_edge_cylinder_mode_label', ''))}\n" f"换算目标直径: {_format_value(plan.get('cylinder_resize_target_diameter', ''))}\n" - f"缩放类型: {_format_value(plan.get('affine_transform_kind', ''))}\n" + f"缩放类型: {_format_value(plan.get('affine_transform_label') or plan.get('affine_transform_kind', ''))}\n" + f"缩放说明: {_format_value(plan.get('affine_transform_note', ''))}\n" f"缩放目标: {_format_value(plan.get('affine_target_kind', ''))}\n" f"缩放比例: {_format_value(plan.get('affine_scale', ''))}\n" + f"预估Edge长度: {_format_value(plan.get('affine_predicted_edge_length', ''))}\n" + f"缩放校正: {_format_value(plan.get('affine_scale_correction', ''))}\n" f"轴线来源: {_format_value(plan.get('affine_axis_source', ''))}\n" + f"平面采样偏差: {_format_value(plan.get('planar_edge_scale_plane_deviation', ''))}\n" f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" @@ -1313,7 +1452,7 @@ class WindowActionMixin: QMessageBox.StandardButton.No, ) if result != QMessageBox.StandardButton.Yes: - self.statusBar().showMessage("已取消边长直接修改") + self.statusBar().showMessage("已取消Edge长度修改") return self._show_edge_length_preview(edge_id, target_length, anchor_mode=anchor_mode) @@ -1323,8 +1462,8 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="直接修改边长", - target=f"edge {edge_id}", + operation_name="修改Edge长度", + target=f"Edge {edge_id}", parameters={ "part_id": plan.get("part_id"), "solid_id": plan.get("solid_id"), @@ -1335,17 +1474,37 @@ class WindowActionMixin: "length_change_ratio": plan.get("length_change_ratio"), "edge_length_anchor_mode": plan.get("edge_length_anchor_mode"), "edge_length_anchor_label": plan.get("edge_length_anchor_label"), + "local_edge_deform_target_kind": plan.get("local_edge_deform_target_kind"), + "local_edge_deform_anchor": plan.get("local_edge_deform_anchor"), + "local_edge_deform_face_count": plan.get("local_edge_deform_face_count"), + "local_edge_deform_start_move": plan.get("local_edge_deform_start_move"), + "local_edge_deform_end_move": plan.get("local_edge_deform_end_move"), + "local_edge_deform_moved_endpoint_count": plan.get("local_edge_deform_moved_endpoint_count"), + "local_edge_deform_risk": plan.get("local_edge_deform_risk"), + "local_edge_deform_note": plan.get("local_edge_deform_note"), "end_face_id": plan.get("end_face_id"), + "end_face_endpoint_role": plan.get("end_face_endpoint_role"), "end_face_label": plan.get("end_face_label"), "push_pull_distance": plan.get("push_pull_distance"), "resize_strategy": plan.get("resize_strategy"), "affine_scale": plan.get("affine_scale"), + "affine_initial_scale": plan.get("affine_initial_scale"), + "affine_scale_correction": plan.get("affine_scale_correction"), + "affine_predicted_edge_length": plan.get("affine_predicted_edge_length"), + "affine_scale_refine_iterations": plan.get("affine_scale_refine_iterations"), + "affine_scale_refine_note": plan.get("affine_scale_refine_note"), "affine_transform_kind": plan.get("affine_transform_kind"), + "affine_transform_label": plan.get("affine_transform_label"), + "affine_transform_note": plan.get("affine_transform_note"), + "affine_transform_warning": plan.get("affine_transform_warning"), + "affine_transform_risk": plan.get("affine_transform_risk"), "affine_axis_point": plan.get("affine_axis_point"), "affine_axis_direction": plan.get("affine_axis_direction"), "affine_axis_source": plan.get("affine_axis_source"), "affine_anchor_source": plan.get("affine_anchor_source"), "affine_target_kind": plan.get("affine_target_kind"), + "planar_edge_scale_sample_count": plan.get("planar_edge_scale_sample_count"), + "planar_edge_scale_plane_deviation": plan.get("planar_edge_scale_plane_deviation"), "circular_edge_current_radius": plan.get("circular_edge_current_radius"), "circular_edge_target_radius": plan.get("circular_edge_target_radius"), "circular_edge_length_scale": plan.get("circular_edge_length_scale"), @@ -1387,7 +1546,7 @@ class WindowActionMixin: if self._edit_busy("请等待当前编辑完成后再平移零件。"): return if self.selected_part_id is None: - QMessageBox.information(self, "未选择零件", "请先选择一个 part,或选择属于某个 part 的对象。") + QMessageBox.information(self, "未选择零件", "请先选择一个Part,或选择属于某个Part的对象。") return try: vector = self._translation_vector_from_inputs() @@ -1410,7 +1569,7 @@ class WindowActionMixin: self._run_edit_action( action, operation_name="平移零件", - target=f"part {part_id}", + target=f"Part {part_id}", parameters={ "part_id": part_id, "translation_vector": vector, @@ -1429,10 +1588,10 @@ class WindowActionMixin: def translate_selected_solid(self) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再平移 solid。"): + if self._edit_busy("请等待当前编辑完成后再平移Solid。"): return if self.selected_solid_id is None: - QMessageBox.information(self, "未选择 solid", "请先选择一个 solid,或选择属于 solid 的 face/edge。") + QMessageBox.information(self, "未选择 Solid", "请先选择一个 Solid,或选择属于 Solid 的 Face/Edge。") return try: vector = self._translation_vector_from_inputs() @@ -1442,11 +1601,11 @@ class WindowActionMixin: solid_id = self.selected_solid_id plan = self.model.translate_solid_plan(solid_id, vector) if plan["status"] == "blocked": - QMessageBox.information(self, "不能平移 solid", str(plan["message"])) - self.statusBar().showMessage("平移 solid 已阻止") + QMessageBox.information(self, "不能平移Solid", str(plan["message"])) + self.statusBar().showMessage("平移Solid已阻止") return - if not self._confirm_translation_plan("确认平移 solid", plan): - self.statusBar().showMessage("已取消平移 solid") + if not self._confirm_translation_plan("确认平移Solid", plan): + self.statusBar().showMessage("已取消平移Solid") return def action(): @@ -1454,8 +1613,8 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="平移 solid", - target=f"solid {solid_id}", + operation_name="平移Solid", + target=f"Solid {solid_id}", parameters={ "part_id": plan.get("part_id"), "solid_id": solid_id, @@ -1493,8 +1652,8 @@ class WindowActionMixin: title, ( f"对象: {plan.get('target_kind', '')}\n" - f"part: {_format_value(plan.get('part_id', ''))}\n" - f"solid: {_format_value(plan.get('solid_id', ''))}\n" + f"Part: {_format_value(plan.get('part_id', ''))}\n" + f"Solid: {_format_value(plan.get('solid_id', ''))}\n" f"平移向量: {_format_value(plan['translation_vector'])}\n" f"平移距离: {_format_value(plan['translation_distance'])}\n" f"对象 bbox_diagonal: {_format_value(plan.get('bbox_diagonal', ''))}\n" @@ -1515,7 +1674,7 @@ class WindowActionMixin: if self._edit_busy("请等待当前编辑完成后再旋转零件。"): return if self.selected_part_id is None: - QMessageBox.information(self, "未选择零件", "请先选择一个 part,或选择属于某个 part 的对象。") + QMessageBox.information(self, "未选择零件", "请先选择一个Part,或选择属于某个Part的对象。") return try: axis, angle = self._rotation_values_from_inputs() @@ -1538,7 +1697,7 @@ class WindowActionMixin: self._run_edit_action( action, operation_name="旋转零件", - target=f"part {part_id}", + target=f"Part {part_id}", parameters={ "part_id": part_id, "rotation_axis": plan.get("rotation_axis"), @@ -1558,10 +1717,10 @@ class WindowActionMixin: def rotate_selected_solid(self) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再旋转 solid。"): + if self._edit_busy("请等待当前编辑完成后再旋转Solid。"): return if self.selected_solid_id is None: - QMessageBox.information(self, "未选择 solid", "请先选择一个 solid,或选择属于 solid 的 face/edge。") + QMessageBox.information(self, "未选择 Solid", "请先选择一个 Solid,或选择属于 Solid 的 Face/Edge。") return try: axis, angle = self._rotation_values_from_inputs() @@ -1571,11 +1730,11 @@ class WindowActionMixin: solid_id = self.selected_solid_id plan = self.model.rotate_solid_plan(solid_id, axis, angle) if plan["status"] == "blocked": - QMessageBox.information(self, "不能旋转 solid", str(plan["message"])) - self.statusBar().showMessage("旋转 solid 已阻止") + QMessageBox.information(self, "不能旋转Solid", str(plan["message"])) + self.statusBar().showMessage("旋转Solid已阻止") return - if not self._confirm_rotation_plan("确认旋转 solid", plan): - self.statusBar().showMessage("已取消旋转 solid") + if not self._confirm_rotation_plan("确认旋转Solid", plan): + self.statusBar().showMessage("已取消旋转Solid") return def action(): @@ -1583,8 +1742,8 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="旋转 solid", - target=f"solid {solid_id}", + operation_name="旋转Solid", + target=f"Solid {solid_id}", parameters={ "part_id": plan.get("part_id"), "solid_id": solid_id, @@ -1620,8 +1779,8 @@ class WindowActionMixin: title, ( f"对象: {plan.get('target_kind', '')}\n" - f"part: {_format_value(plan.get('part_id', ''))}\n" - f"solid: {_format_value(plan.get('solid_id', ''))}\n" + f"Part: {_format_value(plan.get('part_id', ''))}\n" + f"Solid: {_format_value(plan.get('solid_id', ''))}\n" f"旋转轴: {_format_value(plan['rotation_axis'])}\n" f"旋转角度: {_format_value(plan['rotation_angle_degrees'])}\n" f"旋转中心: {_format_value(plan['rotation_center'])}\n" @@ -1636,36 +1795,73 @@ class WindowActionMixin: ) return result == QMessageBox.StandardButton.Yes - def _run_scan_task(self, scan_kind: str, action, finished_callback, failed_callback) -> None: + def _run_scan_task(self, scan_kind: str, action, context: dict[str, object] | None = None) -> None: if self.scan_in_progress: - self.statusBar().showMessage("后台扫描中,请等待当前扫描完成。") + self.statusBar().showMessage("扫描中,请等待当前扫描完成。") return if self.operation_in_progress: self.statusBar().showMessage("编辑计算中,请等待当前操作完成后再扫描。") return self.scan_in_progress = True self.pending_scan_kind = scan_kind + self.pending_scan_context = dict(context or {}) self._clear_hover(render=True) self._update_action_states() - thread = QThread(self) - worker = ScanWorker(action) - worker.moveToThread(thread) - thread.started.connect(worker.run) - worker.finished.connect(finished_callback) - worker.failed.connect(failed_callback) - worker.finished.connect(thread.quit) - worker.failed.connect(thread.quit) - thread.finished.connect(worker.deleteLater) - thread.finished.connect(thread.deleteLater) - thread.finished.connect(self._forget_scan_thread) - self.scan_thread = thread - self.scan_worker = worker - thread.start() + self.scan_thread = None + self.scan_worker = None + QTimer.singleShot(0, lambda: self._run_scan_task_sync(action)) + + def _run_scan_task_sync(self, action) -> None: + if not self.scan_in_progress: + return + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + QApplication.processEvents() + try: + result = action() + except Exception as exc: + self._fail_scan_task_result(str(exc)) + else: + self._finish_scan_task_result(result) + finally: + QApplication.restoreOverrideCursor() + + @Slot(object) + def _finish_scan_task_result(self, result: object) -> None: + scan_kind = self.pending_scan_kind + context = dict(self.pending_scan_context or {}) + if scan_kind == "editable": + self._finish_editable_scan( + result, + bool(context.get("show_info", True)), + str(context.get("scan_label", "扫描")), + int(context.get("limit", 0)), + ) + return + if scan_kind == "cylinder": + self._finish_cylinder_scan(result, bool(context.get("show_info", True))) + return + self._end_scan_task() + self.statusBar().showMessage("扫描已完成,但扫描类型已丢失。") + + @Slot(str) + def _fail_scan_task_result(self, message: str) -> None: + scan_kind = self.pending_scan_kind + if scan_kind == "editable": + self._fail_editable_scan(message) + return + if scan_kind == "cylinder": + self._fail_cylinder_scan(message) + return + self._end_scan_task() + QMessageBox.critical(self, "扫描失败", message) + self.statusBar().showMessage("扫描失败") def _end_scan_task(self) -> None: self.scan_in_progress = False self.pending_scan_kind = None + self.pending_scan_context = None self._update_action_states() + self._request_thread_quit(self.scan_thread) def _forget_scan_thread(self) -> None: self.scan_thread = None @@ -1687,7 +1883,7 @@ class WindowActionMixin: max_scan_faces = 1600 if deep_scan else 700 max_scan_edges = 2500 if deep_scan else 1200 scan_label = "深度扫描" if deep_scan else "扫描" - self.statusBar().showMessage(f"正在后台{scan_label}第一版可编辑对象...") + self.statusBar().showMessage(f"正在{scan_label}可编辑对象...") def action(): if self.model is None: @@ -1701,8 +1897,11 @@ class WindowActionMixin: self._run_scan_task( "editable", action, - lambda result: self._finish_editable_scan(result, show_info, scan_label, limit), - self._fail_editable_scan, + { + "show_info": show_info, + "scan_label": scan_label, + "limit": limit, + }, ) def _finish_editable_scan(self, result: object, show_info: bool, scan_label: str, limit: int) -> None: @@ -1715,7 +1914,7 @@ class WindowActionMixin: def _fail_editable_scan(self, message: str) -> None: self._end_scan_task() QMessageBox.critical(self, "扫描失败", message) - self.statusBar().showMessage("第一版可编辑对象扫描失败") + self.statusBar().showMessage("可编辑对象扫描失败") def _populate_editable_candidates( self, @@ -1725,7 +1924,7 @@ class WindowActionMixin: limit: int, ) -> None: self.editable_table.setRowCount(len(candidates)) - lines = [f"第一版可编辑对象:显示 {len(candidates)} 个({scan_label}上限 {limit})"] + lines = [f"可编辑对象:显示 {len(candidates)} 个({scan_label}上限 {limit})"] for row, item in enumerate(candidates): target_kind = str(item.get("target_kind", "face")) target_id = int(item.get("target_id", item.get("face_id", item.get("edge_id", -1)))) @@ -1757,14 +1956,14 @@ class WindowActionMixin: self.editable_table.setItem(row, column, table_item) lines.append( f"{target_kind} {target_id}: {item['operation']}, " - f"part={item['part_id']}, solid={item['solid_id']}, " + f"Part={item['part_id']}, Solid={item['solid_id']}, " f"object={item['feature_guess']}, " f"{item['current_value_label']}={_format_value(item['current_value'])}, " f"status={item['status']}, risk={item['risk']}, " f"confidence={item['confidence']}, note={item['note']}" ) self.editable_table.resizeColumnsToContents() - self.statusBar().showMessage(f"已{scan_label}第一版可编辑对象:{len(candidates)} 个") + self.statusBar().showMessage(f"已{scan_label}可编辑对象:{len(candidates)} 个") if show_info: self.set_plain_info("\n".join(lines)) @@ -1777,9 +1976,9 @@ class WindowActionMixin: def refresh_cylinder_candidates(self, show_info: bool = True) -> None: if self.model is None: return - if self._edit_busy("编辑计算中,暂时不能扫描圆柱候选。"): + if self._edit_busy("编辑计算中,暂时不能扫描圆柱面候选。"): return - self.statusBar().showMessage("正在后台扫描圆柱候选...") + self.statusBar().showMessage("正在扫描圆柱面候选...") def action(): if self.model is None: @@ -1789,8 +1988,7 @@ class WindowActionMixin: self._run_scan_task( "cylinder", action, - lambda result: self._finish_cylinder_scan(result, show_info), - self._fail_cylinder_scan, + {"show_info": show_info}, ) def _finish_cylinder_scan(self, result: object, show_info: bool) -> None: @@ -1805,13 +2003,13 @@ class WindowActionMixin: self._end_scan_task() self._clear_cylinder_candidates() QMessageBox.critical(self, "扫描失败", message) - self.statusBar().showMessage("圆柱候选扫描失败") + self.statusBar().showMessage("圆柱面候选扫描失败") def _filter_cached_cylinder_candidates(self, show_info: bool = False) -> None: if not self.cylinder_candidates_loaded: self.cylinder_table.setRowCount(0) if show_info: - self.set_plain_info("圆柱候选尚未扫描。点击 `列出圆柱候选` 后再切换筛选类型。") + self.set_plain_info("圆柱面候选尚未扫描。点击 `扫描圆柱面` 后再切换筛选类型。") return candidates = [ item @@ -1822,7 +2020,7 @@ class WindowActionMixin: def _populate_cylinder_table(self, candidates: list[dict[str, object]], show_info: bool = True) -> None: self.cylinder_table.setRowCount(len(candidates)) - lines = [f"圆柱候选:显示 {len(candidates)} 个"] + lines = [f"圆柱面候选:显示 {len(candidates)} 个"] for row, item in enumerate(candidates): face_item = QTableWidgetItem(str(item["face_id"])) face_item.setData(Qt.UserRole, int(item["face_id"])) @@ -1842,7 +2040,7 @@ class WindowActionMixin: self.cylinder_table.setItem(row, 6, risk_item) self.cylinder_table.setItem(row, 7, part_item) lines.append( - f"face {item['face_id']}: part {item['part_id']}, " + f"Face {item['face_id']}: Part {item['part_id']}, " f"guess={item['feature_guess']}, " f"diameter={_format_float(float(item['diameter']))}, " f"height={_format_float(float(item['height_estimate']))}, " @@ -1850,7 +2048,7 @@ class WindowActionMixin: f"risk={item['resize_risk']}" ) self.cylinder_table.resizeColumnsToContents() - self.statusBar().showMessage(f"已显示圆柱候选:{len(candidates)} 个") + self.statusBar().showMessage(f"已显示圆柱面候选:{len(candidates)} 个") if show_info: self.set_plain_info("\n".join(lines)) @@ -1927,10 +2125,8 @@ class WindowActionMixin: worker = EditWorker(self._make_edit_job(action, context)) worker.moveToThread(thread) thread.started.connect(worker.run) - worker.finished.connect(self._finish_edit_action) - worker.failed.connect(self._fail_edit_action) - worker.finished.connect(thread.quit) - worker.failed.connect(thread.quit) + worker.finished.connect(self._finish_edit_action, Qt.ConnectionType.QueuedConnection) + worker.failed.connect(self._fail_edit_action, Qt.ConnectionType.QueuedConnection) thread.finished.connect(worker.deleteLater) thread.finished.connect(thread.deleteLater) thread.finished.connect(self._forget_edit_thread) @@ -1975,6 +2171,16 @@ class WindowActionMixin: f"编辑失败,且回滚到操作前状态也失败:{rollback_exc}\n原始错误:{exc}" ) from exc raise RuntimeError(f"编辑失败,模型已恢复到操作前状态:{exc}") from exc + model_polydata = None + edge_polydata = None + try: + model_polydata = self.model.build_face_polydata() + edge_polydata = self.model.build_edge_polydata( + show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False)) + ) + except Exception: + model_polydata = None + edge_polydata = None return { "message": str(result), "snapshot": snapshot, @@ -1986,6 +2192,8 @@ class WindowActionMixin: "after_part_stats": after_part_stats, "quality_warnings": _edit_quality_warnings(before_part_stats, after_part_stats), "after_geometry": after_geometry, + "model_polydata": model_polydata, + "edge_polydata": edge_polydata, } return job @@ -2049,8 +2257,8 @@ class WindowActionMixin: progress_text = f"{operation_name} 正在后台计算,蓝色预览表示目标边和半径范围。" elif "倒角" in operation_name: progress_text = f"{operation_name} 正在后台计算,橙色预览表示目标边和倒角距离范围。" - elif "边长" in operation_name: - progress_text = f"{operation_name} 正在后台计算,预览表示端面推拉或几何缩放后的目标范围。" + elif "边长" in operation_name or "Edge长度" in operation_name: + progress_text = f"{operation_name} 正在后台计算,预览表示局部边形变、端面推拉或后备策略后的目标范围。" elif "平移" in operation_name: progress_text = f"{operation_name} 正在后台计算,完成后会刷新模型位置和拓扑索引。" elif "旋转" in operation_name: @@ -2163,6 +2371,7 @@ class WindowActionMixin: if clear_preview: self.clear_edit_preview(render=False) self._update_action_states() + self._request_thread_quit(self.edit_thread) QApplication.processEvents() def _forget_edit_thread(self) -> None: @@ -2201,6 +2410,16 @@ class WindowActionMixin: summary_parts.append(f"distance={_format_value(parameters['semantic_distance'])}") if "new_diameter" in parameters: summary_parts.append(f"diameter={_format_value(parameters['new_diameter'])}") + if parameters.get("target_length") is not None: + summary_parts.append(f"target_length={_format_value(parameters['target_length'])}") + if parameters.get("resize_strategy"): + summary_parts.append(f"strategy={parameters['resize_strategy']}") + actual_edge_length = _message_field(result_message, "nearest_length") + target_error = _message_field(result_message, "target_error") + if actual_edge_length is not None: + summary_parts.append(f"actual_length={actual_edge_length}") + if target_error is not None: + summary_parts.append(f"target_error={target_error}") summary = " | ".join(summary_parts) lines = [ @@ -2263,6 +2482,10 @@ class WindowActionMixin: return OperationRecord( summary=summary, detail="\n".join(lines), + operation_name=operation_name, + target=target, + parameters=dict(parameters), + result_message=result_message, target_kind=target_kind, target_id=target_id, target_logical_id=target_logical_id, diff --git a/step_editor/window_core.py b/step_editor/window_core.py index 1225737..1bdfc86 100644 --- a/step_editor/window_core.py +++ b/step_editor/window_core.py @@ -54,7 +54,7 @@ class WindowCoreMixin: self.interactor.AddObserver("MouseMoveEvent", self.on_mouse_move) light = vtk.vtkLight() - light.SetLightTypeToSceneLight() + light.SetLightTypeToHeadlight() light.SetPosition(1, 1, 1) light.SetIntensity(0.9) self.renderer.AddLight(light) @@ -62,20 +62,30 @@ class WindowCoreMixin: self.interactor.Initialize() def closeEvent(self, event) -> None: - if self.load_in_progress: + load_thread_running = bool( + (self.load_thread is not None and self.load_thread.isRunning()) + or (self.load_refine_thread is not None and self.load_refine_thread.isRunning()) + ) + if self.load_in_progress or load_thread_running: self.statusBar().showMessage("STEP background loading is still running.") event.ignore() return - if self.operation_in_progress: + edit_thread_running = bool(self.edit_thread is not None and self.edit_thread.isRunning()) + if self.operation_in_progress or edit_thread_running: self.statusBar().showMessage("后台编辑正在计算,请等待当前操作完成后再关闭窗口。") event.ignore() return - if self.scan_in_progress: - self.statusBar().showMessage("后台扫描正在进行,请等待扫描完成后再关闭窗口。") + scan_thread_running = bool(self.scan_thread is not None and self.scan_thread.isRunning()) + if self.scan_in_progress or scan_thread_running: + self.statusBar().showMessage("扫描正在进行,请等待扫描完成后再关闭窗口。") event.ignore() return super().closeEvent(event) + def _request_thread_quit(self, thread: QThread | None) -> None: + if thread is not None and thread.isRunning(): + thread.quit() + def load_step(self, path: str | Path, *, background: bool = True) -> None: self._load_step_background_or_sync(path, background=background) return @@ -110,53 +120,62 @@ class WindowCoreMixin: return new_path = Path(path) if not background: - self.statusBar().showMessage(f"Loading {new_path.name}...") - QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) - QApplication.processEvents() - try: - result = self._load_step_result( - new_path, - deflection=0.8, - show_internal_edges=self._show_same_domain_internal_edges(), - ) - except Exception as exc: - QMessageBox.critical(self, "Load failed", str(exc)) - self.statusBar().showMessage("STEP load failed; current model was left unchanged.") - return - finally: - QApplication.restoreOverrideCursor() - self._apply_loaded_model_result(result, reset_camera=True) - self.statusBar().showMessage(f"Loaded {self.step_path.name}") + self._load_step_sync( + new_path, + deflection=0.8, + show_internal_edges=self._show_same_domain_internal_edges(), + status_prefix="Loading", + ) return self.load_in_progress = True self.pending_load_path = new_path - self.statusBar().showMessage(f"Loading {new_path.name} in background...") + self.statusBar().showMessage(f"Preparing fast load for {new_path.name}...") self._clear_hover(render=True) self._update_action_states() + QTimer.singleShot(0, lambda path=new_path: self._run_deferred_initial_load(path)) - def action(): - return self._load_step_result( + def _load_step_sync( + self, + new_path: Path, + *, + deflection: float, + show_internal_edges: bool, + status_prefix: str, + ) -> bool: + self.statusBar().showMessage(f"{status_prefix} {new_path.name}...") + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + QApplication.processEvents() + try: + result = self._load_step_result( new_path, + deflection=deflection, + show_internal_edges=show_internal_edges, + ) + result["display"] = "ready" + except Exception as exc: + QMessageBox.critical(self, "Load failed", str(exc)) + self.statusBar().showMessage("STEP load failed; current model was left unchanged.") + return False + finally: + QApplication.restoreOverrideCursor() + self._apply_loaded_model_result(result, reset_camera=True) + self.statusBar().showMessage(f"Loaded {self.step_path.name}") + return True + + @Slot(object) + def _run_deferred_initial_load(self, expected_path: Path) -> None: + if self.pending_load_path != expected_path: + return + try: + self._load_step_sync( + expected_path, deflection=self.initial_load_deflection, show_internal_edges=True, - build_polydata=False, + status_prefix="Fast loading", ) - - thread = QThread(self) - worker = LoadWorker(action) - worker.moveToThread(thread) - thread.started.connect(worker.run) - worker.finished.connect(self._finish_initial_load) - worker.failed.connect(self._fail_initial_load) - worker.finished.connect(thread.quit) - worker.failed.connect(thread.quit) - thread.finished.connect(worker.deleteLater) - thread.finished.connect(thread.deleteLater) - thread.finished.connect(self._forget_load_thread) - self.load_thread = thread - self.load_worker = worker - thread.start() + finally: + self._end_load_task() def _apply_loaded_model_result(self, result: dict[str, object], *, reset_camera: bool) -> None: new_path = Path(result["path"]) @@ -194,11 +213,12 @@ class WindowCoreMixin: "faces": stats.faces, "edges": stats.edges, "vertices": stats.vertices, - "display": "quick preview" if self.load_in_progress else "ready", + "display": result.get("display", "quick preview" if self.load_in_progress else "ready"), } ) self._update_action_states() + @Slot(object) def _finish_initial_load(self, result: object) -> None: try: if not isinstance(result, dict): @@ -210,49 +230,21 @@ class WindowCoreMixin: self._end_load_task() QMessageBox.critical(self, "Load failed", str(exc)) self.statusBar().showMessage("STEP load failed; current model was left unchanged.") + finally: + self._request_thread_quit(self.load_thread) + @Slot(str) def _fail_initial_load(self, message: str) -> None: self._end_load_task() QMessageBox.critical(self, "Load failed", message) self.statusBar().showMessage("STEP load failed; current model was left unchanged.") def _start_load_refine(self, initial_result: dict[str, object]) -> None: - model = initial_result.get("model") - if model is None or model is not self.model: - self._end_load_task() - return - path = Path(initial_result["path"]) - stats = initial_result["stats"] - show_internal_edges = self._show_same_domain_internal_edges() - - def action(): - return { - "path": path, - "model": model, - "stats": stats, - "model_polydata": model.build_face_polydata(deflection=0.8), - "edge_polydata": model.build_edge_polydata( - deflection=0.8, - show_same_domain_internal_edges=show_internal_edges, - ), - "show_internal_edges": show_internal_edges, - } - - thread = QThread(self) - worker = LoadWorker(action) - worker.moveToThread(thread) - thread.started.connect(worker.run) - worker.finished.connect(self._finish_load_refine) - worker.failed.connect(self._fail_load_refine) - worker.finished.connect(thread.quit) - worker.failed.connect(thread.quit) - thread.finished.connect(worker.deleteLater) - thread.finished.connect(thread.deleteLater) - thread.finished.connect(self._forget_load_refine_thread) - self.load_refine_thread = thread - self.load_refine_worker = worker - thread.start() + self._end_load_task() + if self.model is not None: + self.statusBar().showMessage(f"Loaded {self.step_path.name}") + @Slot(object) def _finish_load_refine(self, result: object) -> None: try: if isinstance(result, dict) and result.get("model") is self.model: @@ -277,6 +269,7 @@ class WindowCoreMixin: finally: self._end_load_task() + @Slot(str) def _fail_load_refine(self, message: str) -> None: self._end_load_task() self.statusBar().showMessage(f"Quick preview is available; display refinement failed: {message}") @@ -285,6 +278,8 @@ class WindowCoreMixin: self.load_in_progress = False self.pending_load_path = None self._update_action_states() + self._request_thread_quit(self.load_thread) + self._request_thread_quit(self.load_refine_thread) def _forget_load_thread(self) -> None: self.load_thread = None @@ -319,6 +314,8 @@ class WindowCoreMixin: self.clear_diff_preview(render=False) if hasattr(self, "history_list"): self.history_list.clear() + if hasattr(self, "export_all_button"): + self._update_action_states() def _refresh_history_list(self) -> None: was_blocked = self.history_list.blockSignals(True) @@ -328,6 +325,7 @@ class WindowCoreMixin: self.history_list.addItem(f"{index}. {entry.summary}") finally: self.history_list.blockSignals(was_blocked) + self._update_action_states() def on_history_row_changed(self, row: int) -> None: if self._edit_busy("编辑计算中,暂时不能查看历史记录详情。"): @@ -407,6 +405,10 @@ class WindowCoreMixin: "index": index, "summary": record.summary, "detail": record.detail, + "operation_name": record.operation_name, + "target": record.target, + "parameters": safe_value(record.parameters or {}), + "result_message": record.result_message, "target_kind": record.target_kind, "target_id": record.target_id, "target_logical_id": record.target_logical_id, @@ -417,7 +419,7 @@ class WindowCoreMixin: } ) payload = { - "format": "step-editor-operation-history-v1", + "format": "step-editor-operation-history-v2", "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "source_file": str(self.step_path), "record_count": len(records), @@ -558,27 +560,27 @@ class WindowCoreMixin: if self.selected_kind == "part" and self.selected_part_id is not None: part_ids = [self.selected_part_id] - label = f"part {self.selected_part_id}" + label = f"Part {self.selected_part_id}" elif self.selected_kind == "solid" and self.selected_solid_id is not None: face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == self.selected_solid_id] edge_ids = self.model.edge_ids_for_solid(self.selected_solid_id) - label = f"solid {self.selected_solid_id}" + label = f"Solid {self.selected_solid_id}" elif self.selected_kind == "feature" and self.selected_face_id is not None: info = self.model.feature_info(self.selected_face_id) face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id] edge_ids = _int_values(info.get("feature_boundary_edge_ids")) - label = f"feature face {self.selected_face_id}" + label = f"Feature Face {self.selected_face_id}" elif self.selected_kind == "face" and self.selected_face_id is not None: face_ids = self.model.connected_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id] edge_ids = self.model.face_region_boundary_edge_ids(self.selected_face_id) - label = f"face {self.selected_face_id}" + label = f"Face {self.selected_face_id}" elif self.selected_kind == "edge" and self.selected_edge_id is not None: info = self.model.edge_info(self.selected_edge_id) face_ids = _int_values(info.get("adjacent_face_ids")) edge_ids = [self.selected_edge_id] - label = f"edge {self.selected_edge_id}" + label = f"Edge {self.selected_edge_id}" else: - QMessageBox.information(self, "未选择对象", "请先选择 part、solid、face、edge 或 feature。") + QMessageBox.information(self, "未选择对象", "请先选择 Part、Solid、Face、Edge 或 Feature。") return model_polydata = self.model.build_face_polydata(face_ids=face_ids, part_ids=part_ids) @@ -608,7 +610,7 @@ class WindowCoreMixin: return bounds = self._selected_focus_bounds() if bounds is None: - QMessageBox.information(self, "未选择对象", "请先选择 part、solid、face、edge 或 feature。") + QMessageBox.information(self, "未选择对象", "请先选择 Part、Solid、Face、Edge 或 Feature。") return self._fit_camera_to_bounds(bounds) self.render_window.Render() @@ -772,6 +774,11 @@ class WindowCoreMixin: self.model_actor.GetProperty().SetSpecular(0.25) self.model_actor.GetProperty().SetSpecularPower(18) self.model_actor.GetProperty().SetInterpolationToPhong() + backface_property = vtk.vtkProperty() + backface_property.SetColor(0.58, 0.62, 0.64) + backface_property.SetDiffuse(0.9) + backface_property.SetAmbient(0.28) + self.model_actor.SetBackfaceProperty(backface_property) self.renderer.AddActor(self.model_actor) self.edge_polydata = edge_polydata @@ -816,7 +823,7 @@ class WindowCoreMixin: self.statusBar().showMessage("编辑计算中,请等待当前操作完成") return if self.scan_in_progress: - self.statusBar().showMessage("后台扫描中,请等待扫描完成后再选择对象。") + self.statusBar().showMessage("扫描中,请等待扫描完成后再选择对象。") return if self.model is None: return @@ -876,7 +883,16 @@ class WindowCoreMixin: self._queue_hover_position(x, y) def _queue_hover_position(self, x: int, y: int) -> None: - self.pending_hover_position = (int(x), int(y)) + position = (int(x), int(y)) + threshold = int(getattr(self, "hover_move_threshold_px", 0) or 0) + if threshold > 0: + previous = self.pending_hover_position or self.last_hover_pick_position + if previous is not None: + dx = abs(position[0] - int(previous[0])) + dy = abs(position[1] - int(previous[1])) + if max(dx, dy) < threshold: + return + self.pending_hover_position = position if not self.hover_timer.isActive(): self.hover_timer.start(self.hover_interval_ms) @@ -1102,7 +1118,7 @@ class WindowCoreMixin: self.select_part(int(target_id)) def on_cylinder_row_clicked(self, row: int, _column: int) -> None: - if self._edit_busy("编辑计算中,暂时不能切换圆柱候选。"): + if self._edit_busy("编辑计算中,暂时不能切换圆柱面候选。"): return item = self.cylinder_table.item(row, 0) if item is None: @@ -1127,47 +1143,51 @@ class WindowCoreMixin: if action == "resize_cylinder": self.mode_combo.setCurrentText("Feature") self.select_feature(int(target_id)) - self.statusBar().showMessage(f"已选择可调整孔径候选 face {target_id}") + self.statusBar().showMessage(f"已选择可调整孔径候选Face {target_id}") elif action == "resize_boss": self.mode_combo.setCurrentText("Feature") self.select_feature(int(target_id)) - self.statusBar().showMessage(f"已选择可调整凸台直径候选 face {target_id}") + self.statusBar().showMessage(f"已选择可调整凸台直径候选Face {target_id}") elif action == "resize_slot_width": self.mode_combo.setCurrentText("Feature") self.select_feature(int(target_id)) - self.statusBar().showMessage(f"已选择可调整槽/半孔宽度候选 face {target_id}") + self.statusBar().showMessage(f"已选择可调整槽/半孔宽度候选Face {target_id}") + elif action == "resize_shell_thickness": + self.mode_combo.setCurrentText("Feature") + self.select_feature(int(target_id)) + self.statusBar().showMessage(f"已选择可调整薄壁厚度候选Face {target_id}") elif action == "suppress_cylinder": self.mode_combo.setCurrentText("Feature") self.select_feature(int(target_id)) - self.statusBar().showMessage(f"已选择可封堵圆柱孔 face {target_id}") + self.statusBar().showMessage(f"已选择可封堵圆柱孔Face {target_id}") elif action == "resize_depth": self.mode_combo.setCurrentText("Feature") self.select_feature(int(target_id)) - self.statusBar().showMessage(f"已选择可调整盲孔深度候选 face {target_id}") + self.statusBar().showMessage(f"已选择可调整盲孔深度候选Face {target_id}") elif action == "inspect_existing_fillet": self.mode_combo.setCurrentText("Feature") self.select_feature(int(target_id)) - self.statusBar().showMessage(f"已选择已有圆角/倒圆候选 face {target_id}") + self.statusBar().showMessage(f"已选择已有圆角/倒圆候选Face {target_id}") elif action == "fillet_edge": self.mode_combo.setCurrentText("Edge") self.select_edge(int(target_id)) - self.statusBar().showMessage(f"已选择可添加圆角 edge {target_id}") + self.statusBar().showMessage(f"已选择可添加圆角Edge {target_id}") elif action == "chamfer_edge": self.mode_combo.setCurrentText("Edge") self.select_edge(int(target_id)) - self.statusBar().showMessage(f"已选择可添加倒角 edge {target_id}") + self.statusBar().showMessage(f"已选择可添加倒角Edge {target_id}") elif action == "resize_edge_length": self.mode_combo.setCurrentText("Edge") self.select_edge(int(target_id)) - self.statusBar().showMessage(f"已选择可尝试调整长度的直线 edge {target_id}") + self.statusBar().showMessage(f"已选择可尝试调整长度的Edge {target_id}") elif target_kind == "edge": self.mode_combo.setCurrentText("Edge") self.select_edge(int(target_id)) - self.statusBar().showMessage(f"已选择 edge {target_id}") + self.statusBar().showMessage(f"已选择Edge {target_id}") else: self.mode_combo.setCurrentText("Face") self.select_face(int(target_id)) - self.statusBar().showMessage(f"已选择可推拉平面 face {target_id}") + self.statusBar().showMessage(f"已选择可推拉平面Face {target_id}") def select_by_id(self, kind: str | None = None) -> None: if self.model is None: @@ -1240,7 +1260,7 @@ class WindowCoreMixin: ) -> None: if self.model is None: return - if self._edit_busy("编辑计算中,暂时不能选择 solid。"): + if self._edit_busy("编辑计算中,暂时不能选择Solid。"): return info = self.model.solid_info(solid_id) self._reset_selection(clear_highlight=False) @@ -1254,7 +1274,7 @@ class WindowCoreMixin: self._sync_id_picker("Solid", solid_id) self.set_info(self._with_pick_info(info, pick_position)) self._update_action_states() - self.statusBar().showMessage(self._selection_status(f"已选择 solid {solid_id}", pick_position)) + self.statusBar().showMessage(self._selection_status(f"已选择Solid {solid_id}", pick_position)) def select_face( self, @@ -1264,7 +1284,7 @@ class WindowCoreMixin: ) -> None: if self.model is None: return - if self._edit_busy("编辑计算中,暂时不能选择 face。"): + if self._edit_busy("编辑计算中,暂时不能选择Face。"): return self._reset_selection(clear_highlight=False) self.selected_kind = "feature" if feature_mode else "face" @@ -1279,9 +1299,10 @@ class WindowCoreMixin: if len(highlight_face_ids) > 1: info["same_domain_face_ids"] = tuple(highlight_face_ids) info["same_domain_face_count"] = len(highlight_face_ids) - info["same_domain_note"] = "已高亮属于同一几何面且范围相接/重叠的连续 face 区域。" + info["same_domain_note"] = "已高亮属于同一几何面且范围相接/重叠的连续Face区域。" logical_id = self.model.face_region_logical_id(face_id) - self._sync_cylindrical_edit_inputs(info) + input_info = self.model.feature_info(face_id) if info.get("surface") == "plane" else info + self._sync_cylindrical_edit_inputs(input_info) self.selected_part_id = int(info["part_id"]) self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None self._highlight_faces(face_ids=highlight_face_ids or [face_id]) @@ -1289,9 +1310,9 @@ class WindowCoreMixin: self._sync_id_picker("Feature" if feature_mode else "Face", face_id if feature_mode else logical_id) self.set_info(self._with_pick_info(info, pick_position)) self._update_action_states() - suffix = f",同域区域 {len(highlight_face_ids)} 个 face" if not feature_mode and len(highlight_face_ids) > 1 else "" - raw_note = f"(拓扑 face {face_id})" if not feature_mode and logical_id != face_id else "" - message = f"已选择逻辑面区域 {logical_id}{raw_note}{suffix}" if not feature_mode else f"已选择 face {face_id}" + suffix = f",同域区域 {len(highlight_face_ids)} 个Face" if not feature_mode and len(highlight_face_ids) > 1 else "" + raw_note = f"(拓扑Face {face_id})" if not feature_mode and logical_id != face_id else "" + message = f"已选择逻辑面区域 {logical_id}{raw_note}{suffix}" if not feature_mode else f"已选择Face {face_id}" self.statusBar().showMessage(self._selection_status(message, pick_position)) def select_feature(self, face_id: int, pick_position: tuple[float, float, float] | None = None) -> None: @@ -1314,10 +1335,16 @@ class WindowCoreMixin: self.set_info(self._with_pick_info(info, pick_position)) feature_type = str(info.get("feature_type", "局部特征候选")) self._update_action_states() - self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 face {face_id}", pick_position)) + self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源Face {face_id}", pick_position)) def _sync_cylindrical_edit_inputs(self, info: dict[str, object]) -> None: fillet_radius_suggestion: str | None = None + shell_thickness = _float_or_none(info.get("shell_thickness_estimate")) + if hasattr(self, "shell_thickness_input"): + if info.get("shell_region_status") == "candidate" and shell_thickness is not None and shell_thickness > 0: + self.shell_thickness_input.setText(_format_float(shell_thickness * 1.2)) + else: + self.shell_thickness_input.clear() if "diameter" in info: feature_guess = str(info.get("feature_guess", "")) if feature_guess == "boss/outer-round candidate": @@ -1373,6 +1400,8 @@ class WindowCoreMixin: def _sync_edge_edit_inputs(self, info: dict[str, object]) -> None: self.hole_diameter_input.clear() + if hasattr(self, "shell_thickness_input"): + self.shell_thickness_input.clear() if hasattr(self, "slot_width_input"): self.slot_width_input.clear() self.boss_diameter_input.clear() @@ -1395,7 +1424,7 @@ class WindowCoreMixin: def select_edge(self, edge_id: int, pick_position: tuple[float, float, float] | None = None) -> None: if self.model is None: return - if self._edit_busy("编辑计算中,暂时不能选择 edge。"): + if self._edit_busy("编辑计算中,暂时不能选择Edge。"): return self._reset_selection(clear_highlight=False) self.selected_kind = "edge" @@ -1410,7 +1439,7 @@ class WindowCoreMixin: self._sync_id_picker("Edge", edge_id) self.set_info(self._with_pick_info(info, pick_position)) self._update_action_states() - self.statusBar().showMessage(self._selection_status(f"已选择 edge {edge_id}", pick_position)) + self.statusBar().showMessage(self._selection_status(f"已选择Edge {edge_id}", pick_position)) def _highlight_faces(self, face_ids=None, part_ids=None) -> None: if self.model is None: @@ -1635,6 +1664,19 @@ class WindowCoreMixin: self._start_edit_preview_pulse() self.render_window.Render() + def _show_shell_thickness_preview(self, face_id: int, target_thickness: float) -> None: + if self.model is None: + return + self.clear_edit_preview(render=False) + try: + polydata = self.model.shell_thickness_preview_polydata(face_id, target_thickness) + except Exception as exc: + self.statusBar().showMessage(f"薄壁厚度调整预览不可用:{exc}") + return + self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34) + self._start_edit_preview_pulse() + self.render_window.Render() + def _show_cylinder_resize_preview(self, face_id: int, diameter: float) -> None: if self.model is None: return @@ -1740,7 +1782,7 @@ class WindowCoreMixin: try: polydata = self.model.straight_edge_length_preview_polydata(edge_id, target_length, anchor_mode=anchor_mode) except Exception as exc: - self.statusBar().showMessage(f"边长直接修改预览不可用:{exc}") + self.statusBar().showMessage(f"Edge长度修改预览不可用:{exc}") return if isinstance(polydata, list): for preview in polydata: @@ -1771,7 +1813,7 @@ class WindowCoreMixin: ids.InsertNextId(cell_id) break if ids.GetNumberOfIds() == 0: - self.statusBar().showMessage(f"{label}不可用:没有找到选中的 edge。") + self.statusBar().showMessage(f"{label}不可用:没有找到选中的Edge。") return extract = vtk.vtkExtractCells() extract.SetInputData(polydata) @@ -1817,6 +1859,8 @@ class WindowCoreMixin: if render and hasattr(self, "render_window"): self.render_window.Render() self.statusBar().showMessage("已清除差异预览") + if hasattr(self, "export_all_button"): + self._update_action_states() def _show_operation_diff(self, record: OperationRecord) -> str: if self.model is None: @@ -1838,6 +1882,7 @@ class WindowCoreMixin: for actor in self.diff_actors: self.renderer.AddViewProp(actor) self.render_window.Render() + self._update_action_states() if heatmap_stats: record.diff_stats = heatmap_stats return ( diff --git a/step_editor/window_state.py b/step_editor/window_state.py index 9ca985f..6f3e488 100644 --- a/step_editor/window_state.py +++ b/step_editor/window_state.py @@ -87,7 +87,7 @@ class WindowStateMixin: self.statusBar().showMessage(message) return True if self.scan_in_progress: - self.statusBar().showMessage("后台扫描中,请等待扫描完成后再执行该操作。") + self.statusBar().showMessage("扫描中,请等待扫描完成后再执行该操作。") return True return False @@ -98,33 +98,229 @@ class WindowStateMixin: def _update_action_states(self) -> None: if not hasattr(self, "export_all_button"): return - has_model = ( - self.model is not None - and not self.operation_in_progress - and not self.scan_in_progress - and not self.load_in_progress - ) + has_loaded_model = self.model is not None + busy = self.operation_in_progress or self.scan_in_progress or self.load_in_progress + has_model = has_loaded_model and not busy selected_kind = self.selected_kind - self.export_all_button.setEnabled(has_model) - self.export_check_button.setEnabled(has_model) + has_selection = any( + item is not None + for item in ( + self.selected_part_id, + self.selected_solid_id, + self.selected_face_id, + self.selected_edge_id, + ) + ) + has_part = self.selected_part_id is not None + has_solid = self.selected_solid_id is not None + has_face = self.selected_face_id is not None and selected_kind in {"face", "feature"} + has_edge = self.selected_edge_id is not None and selected_kind == "edge" + id_text = self.id_input.text().strip() if hasattr(self, "id_input") else "" + history_row = self.history_list.currentRow() if hasattr(self, "history_list") else -1 + has_history = has_model and bool(getattr(self, "operation_history", [])) + has_history_selection = has_history and 0 <= history_row < len(self.operation_history) + has_diff_preview = has_model and bool(getattr(self, "diff_actors", [])) + has_measure_points = bool(getattr(self, "measure_point_a", None) or getattr(self, "measure_point_b", None)) + has_measure_result = bool(getattr(self, "measure_point_a", None) and getattr(self, "measure_point_b", None)) + pick_value = getattr(self, "current_info_values", {}).get("pick_position") + has_pick_position = self.selected_pick_position is not None or ( + isinstance(pick_value, (tuple, list)) and len(pick_value) == 3 + ) + selected_id_text = self._selected_id_text() if hasattr(self, "_selected_id_text") else "" + has_current_info = bool(getattr(self, "current_info_text", "")) + wait_or_load_tip = "请先加载 STEP 文件,或等待当前后台任务完成。" + + if hasattr(self, "open_button"): + self._set_control_state( + self.open_button, + not busy, + "选择并打开一个 .step 或 .stp 文件。", + "请等待当前后台任务完成后再打开其他 STEP 文件。", + ) + self._set_control_state( + self.reload_button, + has_loaded_model and not busy, + "从磁盘重新读取当前 STEP 文件。", + wait_or_load_tip, + ) + if hasattr(self, "part_tree"): + self._set_control_state( + self.part_tree, + has_model, + "从结构树中选择 Part、Solid 或装配节点。", + wait_or_load_tip, + ) + if hasattr(self, "mode_combo"): + self._set_control_state( + self.mode_combo, + not busy, + "选择鼠标点击模型时要选什么对象。", + "请等待当前后台任务完成后再切换鼠标选择模式。", + ) + if hasattr(self, "id_input"): + self._set_control_state( + self.id_input, + has_model, + "输入要选中的对象 ID。", + wait_or_load_tip, + ) + self._set_control_state( + self.select_id_button, + has_model and bool(id_text), + "按当前选择模式跳转到输入的 ID。", + "请先加载模型并输入整数 ID。", + ) + + self._set_control_state( + self.export_all_button, + has_model, + "导出当前完整 STEP 模型。", + wait_or_load_tip, + ) + self._set_control_state( + self.export_check_button, + has_model, + "检查当前导出对象的 B-Rep、数量、体积和包围盒等质量信息。", + wait_or_load_tip, + ) if hasattr(self, "repair_model_button"): self._set_control_state( self.repair_model_button, has_model, "对当前完整模型执行 ShapeFix 和同域面/边合并,并写入撤销历史。", - "请先加载 STEP 文件,或等待当前后台任务完成。", + wait_or_load_tip, ) self._set_control_state( self.repair_selected_button, - has_model and (self.selected_solid_id is not None or self.selected_part_id is not None), - "优先修复当前选中对象所属 solid;没有 solid 时修复所属零件,并写入撤销历史。", - "请先选择 part、solid、face 或 edge,或等待当前后台任务完成。", + has_model and (has_solid or has_part), + "优先修复当前选中对象所属Solid;没有Solid时修复所属零件,并写入撤销历史。", + "请先选择 Part、Solid、Face 或 Edge,或等待当前后台任务完成。", + ) + self._set_control_state( + self.export_part_button, + has_model and has_part, + "导出当前选中对象所属零件。", + "请先选择 Part,或选择属于某个 Part 的 Solid、Face 或 Edge。", + ) + self._set_control_state( + self.export_solid_button, + has_model and has_solid, + "导出当前选中对象所属 Solid。", + "请先选择 Solid,或选择属于某个 Solid 的 Face 或 Edge。", + ) + self._set_control_state( + self.export_face_button, + has_model and has_face, + "导出当前选中的 Face 或同域面区域。", + "请先在 Face 或 Feature 模式下选择一个 Face。", + ) + self._set_control_state( + self.export_feature_button, + has_model and selected_kind == "feature" and self.selected_face_id is not None, + "导出 Feature 模式识别到的局部特征区域。", + "请先切换到 Feature 模式并选择孔、槽、圆角或凸台候选。", + ) + self._set_control_state( + self.export_edge_button, + has_model and has_edge, + "导出当前选中的 Edge。", + "请先切换到 Edge 模式并选择一条 Edge。", + ) + if hasattr(self, "isolate_button"): + self._set_control_state( + self.isolate_button, + has_model and has_selection, + "只显示当前选中的对象或区域。", + "请先加载模型并选择 Part、Solid、Face、Edge 或 Feature。", + ) + self._set_control_state( + self.fit_button, + has_model and has_selection, + "把相机对准当前选中对象。", + "请先加载模型并选择一个对象。", + ) + self._set_control_state( + self.show_all_button, + has_model, + "恢复显示完整模型。", + wait_or_load_tip, + ) + self._set_control_state( + self.show_internal_edges_checkbox, + has_model, + "显示或隐藏同域内部拓扑边。", + wait_or_load_tip, + ) + if hasattr(self, "set_measure_a_button"): + measure_point_tip = "把当前选中对象的拾取点或中心设为测量点。" + self._set_control_state( + self.set_measure_a_button, + has_model and has_selection, + measure_point_tip, + "请先加载模型并选择一个对象。", + ) + self._set_control_state( + self.set_measure_b_button, + has_model and has_selection, + measure_point_tip, + "请先加载模型并选择一个对象。", + ) + self._set_control_state( + self.copy_measure_button, + has_measure_result, + "复制 A/B 两点距离和 X/Y/Z 差值。", + "请先设置测量点 A 和 B。", + ) + self._set_control_state( + self.clear_measure_button, + has_measure_points, + "清除 A/B 测量点和 3D 测量线。", + "当前没有可清除的测量点。", + ) + if hasattr(self, "history_list"): + self._set_control_state( + self.history_list, + has_history, + "点击历史记录可查看参数、定位目标并显示差异预览。", + "当前还没有编辑历史。", + ) + self._set_control_state( + self.clear_diff_button, + has_diff_preview, + "清除当前显示的差异预览。", + "当前没有正在显示的差异预览。", + ) + self._set_control_state( + self.export_diff_button, + has_history_selection, + "导出当前选中历史记录的差异报告。", + "请先在操作历史中选择一条记录。", + ) + self._set_control_state( + self.export_history_button, + has_history, + "导出本次会话的完整编辑历史 JSON。", + "当前还没有可导出的编辑历史。", + ) + if hasattr(self, "copy_id_button"): + self._set_control_state( + self.copy_id_button, + bool(selected_id_text), + "复制当前选中对象的 ID。", + "请先选择一个对象。", + ) + self._set_control_state( + self.copy_pick_button, + has_pick_position, + "复制最近一次鼠标拾取到的三维坐标。", + "当前没有可复制的拾取坐标。", + ) + self._set_control_state( + self.copy_info_button, + has_current_info, + "复制当前属性区里的完整文本。", + "当前属性区还没有可复制的信息。", ) - self.export_part_button.setEnabled(has_model and selected_kind == "part" and self.selected_part_id is not None) - self.export_solid_button.setEnabled(has_model and selected_kind == "solid" and self.selected_solid_id is not None) - self.export_face_button.setEnabled(has_model and selected_kind == "face" and self.selected_face_id is not None) - self.export_feature_button.setEnabled(has_model and selected_kind == "feature" and self.selected_face_id is not None) - self.export_edge_button.setEnabled(has_model and selected_kind == "edge" and self.selected_edge_id is not None) self._update_edit_action_states(has_model) def _update_edit_action_states(self, has_model: bool) -> None: @@ -139,6 +335,7 @@ class WindowStateMixin: has_face = self.selected_face_id is not None and self.selected_kind in {"face", "feature"} has_edge = self.selected_edge_id is not None and self.selected_kind == "edge" is_plane = has_face and surface == "plane" + is_shell_candidate = is_plane and action_info.get("shell_region_status") == "candidate" is_cylinder = has_face and surface == "cylinder" and "diameter" in action_info is_hole_or_groove = is_cylinder and feature_guess == "hole/groove candidate" is_boss = is_cylinder and feature_guess == "boss/outer-round candidate" @@ -158,111 +355,237 @@ class WindowStateMixin: ) has_fillet_support = len(_int_values(action_info.get("feature_existing_fillet_support_face_ids"))) >= 2 is_line_edge = has_edge and curve == "line" + can_push = has_model and is_plane + can_resize_shell = has_model and is_shell_candidate + can_resize_hole = has_model and is_hole_or_groove + can_resize_slot = has_model and is_slot_or_half_hole + can_resize_boss = has_model and is_boss and is_full_cylinder + can_suppress_hole = has_model and is_hole_or_groove and is_full_cylinder + can_resize_depth = has_model and is_hole_or_groove and ((is_blind and has_bottom) or has_manual_bottom) + can_fillet_edge = has_model and is_line_edge + can_resize_existing_fillet = has_model and is_existing_fillet and has_fillet_support + can_chamfer_edge = has_model and is_line_edge + can_resize_edge_length = has_model and has_edge + can_transform_part = has_model and self.selected_part_id is not None + can_transform_solid = has_model and self.selected_solid_id is not None + + self._set_control_state( + self.offset_input, + can_push, + "输入平面 Face 的推拉距离。", + "请先选择一个可推拉的平面 Face。", + ) self._set_control_state( self.push_button, - has_model and is_plane, - "对当前平面 face 执行推拉。", - "请先选择一个平面 face,或在 Feature 模式下选择可推拉平面候选。", + can_push, + "对当前平面 Face 执行推拉。", + "请先选择一个平面 Face,或在 Feature 模式下选择可推拉平面候选。", + ) + if hasattr(self, "resize_shell_thickness_button"): + self._set_control_state( + self.shell_thickness_input, + can_resize_shell, + "输入薄壁/壳体局部区域的目标厚度。", + "请先选择一个已识别到相对平面的薄壁/壳体平面候选。", + ) + self._set_control_state( + self.resize_shell_thickness_button, + can_resize_shell, + "按目标厚度推拉当前薄壁/壳体平面区域。", + "请先选择一个已识别到相对平面的薄壁/壳体平面候选。", + ) + self._set_control_state( + self.hole_diameter_input, + can_resize_hole, + "输入圆柱孔/槽的目标直径。", + "请先选择被识别为孔/槽候选的圆柱 Face。", ) self._set_control_state( self.resize_button, - has_model and is_hole_or_groove, + can_resize_hole, "调整当前圆柱孔/槽候选的直径。", - "请先选择被识别为孔/槽候选的圆柱 face。", + "请先选择被识别为孔/槽候选的圆柱 Face。", ) if hasattr(self, "resize_slot_button"): + self._set_control_state( + self.slot_width_input, + can_resize_slot, + "输入槽/半孔候选的目标宽度。", + "请先选择被识别为槽/半孔的部分圆柱 Face。", + ) self._set_control_state( self.resize_slot_button, - has_model and is_slot_or_half_hole, - "按目标槽宽调整当前槽/半孔候选;第一版会换算为对应圆柱直径后执行。", - "请先选择被识别为槽/半孔的部分圆柱 face。", + can_resize_slot, + "按目标槽宽调整当前槽/半孔候选;当前版本会换算为对应圆柱直径后执行。", + "请先选择被识别为槽/半孔的部分圆柱 Face。", ) + self._set_control_state( + self.boss_diameter_input, + can_resize_boss, + "输入完整圆柱凸台候选的目标直径。", + "请先选择被识别为完整凸台/外圆候选的圆柱 Face。", + ) self._set_control_state( self.resize_boss_button, - has_model and is_boss and is_full_cylinder, + can_resize_boss, "调整当前完整圆柱凸台候选的直径。", - "请先选择被识别为完整凸台/外圆候选的圆柱 face。", + "请先选择被识别为完整凸台/外圆候选的圆柱 Face。", ) self._set_control_state( self.suppress_button, - has_model and is_hole_or_groove and is_full_cylinder, + can_suppress_hole, "封堵当前完整圆柱孔候选。", "请先选择接近完整圆柱的孔候选;半孔/槽不会放行。", ) + self._set_control_state( + self.hole_depth_input, + can_resize_hole, + "输入盲孔/盲槽的目标深度。", + "请先选择孔/槽圆柱面。", + ) + self._set_control_state( + self.hole_bottom_face_input, + can_resize_hole, + "必要时输入底面 Face ID,用来明确盲孔/盲槽深度。", + "请先选择孔/槽圆柱面。", + ) self._set_control_state( self.resize_depth_button, - has_model and is_hole_or_groove and ((is_blind and has_bottom) or has_manual_bottom), + can_resize_depth, "调整当前盲孔/盲槽深度;自动底面不稳定时可手动填写底面 Face ID。", "请先选择孔/槽圆柱面;如果没有自动识别到底面,请填写底面 Face ID。", ) + self._set_control_state( + self.edge_fillet_radius_input, + can_fillet_edge or can_resize_existing_fillet, + "输入新圆角或已有圆角的目标半径。", + "请先选择直线Edge,或选择可修改的已有圆角 Face。", + ) self._set_control_state( self.fillet_edge_button, - has_model and is_line_edge, - "给当前直线 edge 添加新圆角。", - "请先选择一条直线 edge。", + can_fillet_edge, + "给当前直线Edge添加新圆角。", + "请先选择一条直线Edge。", ) self._set_control_state( self.resize_existing_fillet_button, - has_model and is_existing_fillet and has_fillet_support, + can_resize_existing_fillet, "尝试修改当前已有圆角/倒圆候选的半径。", - "请先选择一个已有圆角/倒圆候选 face;第一版需要识别到至少两个支撑 face。", + "请先选择一个已有圆角/倒圆候选 Face;当前版本需要识别到至少两个支撑 Face。", + ) + self._set_control_state( + self.edge_chamfer_distance_input, + can_chamfer_edge, + "输入当前直线Edge的倒角距离。", + "请先选择一条直线Edge。", ) self._set_control_state( self.chamfer_edge_button, - has_model and is_line_edge, - "给当前直线 edge 添加倒角。", - "请先选择一条直线 edge。", + can_chamfer_edge, + "给当前直线Edge添加倒角。", + "请先选择一条直线Edge。", + ) + self._set_control_state( + self.edge_target_length_input, + can_resize_edge_length, + "输入当前Edge的目标长度。", + "请先选择一条Edge。", + ) + self._set_control_state( + self.edge_length_anchor_combo, + can_resize_edge_length, + "选择修改Edge长度时尽量固定哪一端。", + "请先选择一条Edge。", ) self._set_control_state( self.resize_edge_length_button, - has_model and has_edge, - "直接修改当前 edge 的长度;直线优先端面推拉,其他 edge 使用高风险几何 fallback。", - "请先选择一条 edge。", + can_resize_edge_length, + "修改当前Edge的长度;直线Edge优先局部形变,圆/椭圆/平面曲线优先用局部或径向策略。", + "请先选择一条Edge。", ) + for widget in (self.translate_x_input, self.translate_y_input, self.translate_z_input): + self._set_control_state( + widget, + can_transform_part or can_transform_solid, + "输入选中零件或 Solid 的平移距离。", + "请先选择一个零件、Solid,或其所属 Face/Edge。", + ) self._set_control_state( self.translate_part_button, - has_model and self.selected_part_id is not None, + can_transform_part, "平移当前选中对象所属零件。", "请先选择一个零件,或选择属于某个零件的对象。", ) self._set_control_state( self.rotate_part_button, - has_model and self.selected_part_id is not None, + can_transform_part, "旋转当前选中对象所属零件。", "请先选择一个零件,或选择属于某个零件的对象。", ) + self._set_control_state( + self.rotate_axis_combo, + can_transform_part or can_transform_solid, + "选择选中零件或 Solid 的旋转轴。", + "请先选择一个零件、Solid,或其所属 Face/Edge。", + ) + self._set_control_state( + self.rotate_angle_input, + can_transform_part or can_transform_solid, + "输入选中零件或 Solid 的旋转角度。", + "请先选择一个零件、Solid,或其所属 Face/Edge。", + ) self._set_control_state( self.translate_solid_button, - has_model and self.selected_solid_id is not None, - "平移当前选中对象所属 solid。", - "请先选择一个 solid,或选择属于某个 solid 的 face/edge。", + can_transform_solid, + "平移当前选中对象所属 Solid。", + "请先选择一个 Solid,或选择属于某个 Solid 的 Face/Edge。", ) self._set_control_state( self.rotate_solid_button, - has_model and self.selected_solid_id is not None, - "旋转当前选中对象所属 solid。", - "请先选择一个 solid,或选择属于某个 solid 的 face/edge。", + can_transform_solid, + "旋转当前选中对象所属 Solid。", + "请先选择一个 Solid,或选择属于某个 Solid 的 Face/Edge。", ) self._set_control_state( self.cylinders_button, has_model, - "扫描当前模型中的圆柱候选。", + "扫描当前模型中的圆柱面候选。", "请先加载 STEP 文件。", ) if hasattr(self, "editable_refresh_button"): self._set_control_state( self.editable_refresh_button, has_model, - "扫描第一版可编辑对象。", + "扫描可编辑对象。", "请先加载 STEP 文件,或等待当前后台任务完成。", ) self._set_control_state( self.editable_deep_scan_button, has_model, - "深度扫描更多第一版可编辑对象。", + "深度扫描更多可编辑对象。", "请先加载 STEP 文件,或等待当前后台任务完成。", ) + self._set_control_state( + self.editable_table, + has_model and self.editable_table.rowCount() > 0, + "点击一行可选中对应对象并填入相关编辑参数。", + "当前没有可编辑对象列表;请先点击扫描对象。", + ) + if hasattr(self, "candidate_filter_combo"): + has_cylinder_candidates = has_model and bool(getattr(self, "cylinder_candidates_loaded", False)) + self._set_control_state( + self.candidate_filter_combo, + has_cylinder_candidates, + "筛选已扫描的圆柱面候选。", + "请先点击扫描圆柱面。", + ) + self._set_control_state( + self.cylinder_table, + has_model and self.cylinder_table.rowCount() > 0, + "点击一行可选中对应圆柱 Face。", + "当前没有圆柱面候选列表;请先点击扫描圆柱面。", + ) self._set_control_state( self.undo_button, has_model and bool(self.undo_stack), @@ -292,7 +615,7 @@ class WindowStateMixin: return self.model.feature_info(self.selected_face_id) if self.selected_kind == "face" and self.selected_face_id is not None: info = self.model.face_info(self.selected_face_id) - if info.get("surface") == "cylinder": + if info.get("surface") in {"cylinder", "plane"}: return self.model.feature_info(self.selected_face_id) return info if self.selected_kind == "edge" and self.selected_edge_id is not None: @@ -330,23 +653,23 @@ class WindowStateMixin: face_ids = self.model.face_region_ids(resolved_face_id) self._highlight_faces(face_ids=face_ids) located = True - region_note = f";同域面区域 {len(face_ids)} 个 face" if len(face_ids) > 1 else "" + region_note = f";同域面区域 {len(face_ids)} 个Face" if len(face_ids) > 1 else "" id_note = ( f"逻辑 Face ID {current_logical_id}" if record.target_logical_id is not None - else f"face {record.target_id}" + else f"Face {record.target_id}" ) if resolved_face_id != record.target_id: - id_note += f"(当前拓扑 face {resolved_face_id})" + id_note += f"(当前拓扑Face {resolved_face_id})" locator_note = ( f"定位: 已尝试高亮当前模型中的 {id_note}{region_note}。" - "布尔编辑后 face ID 可能发生语义变化,请结合拾取点确认。" + "布尔编辑后Face ID可能发生语义变化,请结合拾取点确认。" ) else: if record.target_logical_id is not None: locator_note = f"定位: 原目标逻辑 Face ID {record.target_logical_id} 在当前模型索引中已经不存在。" else: - locator_note = f"定位: 原目标 face {record.target_id} 在当前模型索引中已经不存在。" + locator_note = f"定位: 原目标Face {record.target_id} 在当前模型索引中已经不存在。" elif record.target_kind == "edge" and record.target_id is not None: if 0 <= record.target_id < len(self.model.edges): @@ -361,11 +684,11 @@ class WindowStateMixin: self._highlight_edge(record.target_id) located = True locator_note = ( - f"定位: 已尝试高亮当前模型中的 edge {record.target_id}。" - "布尔/倒圆编辑后 edge ID 可能发生语义变化,请结合拾取点确认。" + f"定位: 已尝试高亮当前模型中的Edge {record.target_id}。" + "布尔/倒圆编辑后Edge ID可能发生语义变化,请结合拾取点确认。" ) else: - locator_note = f"定位: 原目标 edge {record.target_id} 在当前模型索引中已经不存在。" + locator_note = f"定位: 原目标Edge {record.target_id} 在当前模型索引中已经不存在。" if record.pick_position is not None: self._show_pick_marker(record.pick_position)