feat: 完善参数化编辑和孔修改稳定性

This commit is contained in:
2026-07-28 18:30:58 +08:00
parent 7c263d1e96
commit 720c2dc970
13 changed files with 2190 additions and 690 deletions
+53 -53
View File
@@ -1,4 +1,4 @@
# STEP 零件查看编辑原型
# STEP 零件与特征查看编辑原型
这是一个基于 Python、pythonocc-core/OCCT、VTK 和 PySide6/Qt 的 STEP 模型查看、选择、局部编辑与导出原型。
@@ -19,13 +19,13 @@
- 加载 `.step` / `.stp` 模型文件。
- 展示三维模型,支持旋转、缩放、平移。
- 读取 STEP 中的零件、装配、Solid 等结构。
- 用户可以选择零件、Solid、Face、Edge 和局部几何特征。
- 用户可以选择零件、Solid、Face、Edge 和几何特征。
- 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。
- 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。
- 用户可以修改可控的局部特征,比如平面推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、凸台直径调整、Edge倒圆、Edge倒角和修改Edge长度。
- 用户可以修改可控的特征,比如平面推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、凸台直径调整、Edge倒圆、Edge倒角和修改Edge长度。
- 用户可以撤销/重做修改,避免一步编辑做坏。
- 用户可以导出修改后的完整模型。
- 如果 STEP 文件里存在多个零件,用户可以只导出选中的某个零件。
- 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。
需要注意:STEP 文件通常是 B-Rep 几何数据,不是带完整建模历史的参数化 CAD 原文件。所以“随便选一条边改长度”不一定能稳定实现,实际会转化成更可靠的操作,比如移动某个面、重切某个孔、调整某个圆角等。
@@ -36,7 +36,7 @@
- 能打开 STEP。
- 能显示模型。
- 能读取 STEP 中的零件/装配标签。
- 能选择 Part、Solid、Face、Edge。
- 能选择零件、Solid、Face、Edge 和特征
- 能显示选中对象的基本属性。
- 能高亮选中对象。
- 能导出整个当前模型。
@@ -51,28 +51,28 @@
- 默认加载 `assets/models/geom_extract.step`
- 支持打开其他 `.step` / `.stp` 文件。
- 打开 STEP 时会先在临时模型里完成读取和显示网格生成;失败时当前模型保持不变。
- 使用 XCAF 读取 STEP 中的 Part / Assembly 标签。
- 使用 XCAF 读取 STEP 中的零件 / Assembly 标签;代码内部仍沿用 `PartNode` / `part_id` 作为结构名
- 模型结构树会用中文显示 STEP 里的装配、零件和实体层级,并在对应零件下面列出实体子节点。
- 点击模型结构树中的实体子节点,可以直接选中、高亮、查看属性并用于导出该实体。
- 使用 OCCT 读取真实 B-Rep 拓扑。
- 使用 VTK 显示模型,并通过 PySide6/Qt 承载桌面界面。
- 左侧操作面板按功能模块分组显示,并使用四边同色的轻量彩色边框和标题层级区分文件、模型树、选择、编辑、导出、显示、测量、候选、历史和属性信息。
- 支持选择模式:
- Part
- 零件
- Solid
- Face
- 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 映射。
- 操作历史会记录 `target_logical_id` 和当时的拓扑Face ID。点击历史记录定位Face / 特征时,会优先用逻辑 ID 找回当前模型中的整片面区域;撤销/重做快照也会保留这层逻辑 ID 映射。
- 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界Edge。
- 支持按当前选择模式输入 ID 直接选择 Part / Solid / Face / Edge / Feature
- Face / Feature 按 ID 选择会优先解析 `逻辑 Face ID`。例如推拉前侧壁是 558,推拉后当前拓扑里变成 1556 和 1802,只要它们被绑定到逻辑 558,输入 558 就会选中这片面区域。
- 支持按当前选择模式输入 ID 直接选择零件 / Solid / Face / Edge / 特征
- Face / 特征按 ID 选择会优先解析 `逻辑 Face ID`。例如推拉前侧壁是 558,推拉后当前拓扑里变成 1556 和 1802,只要它们被绑定到逻辑 558,输入 558 就会选中这片面区域。
- `鼠标选择模式` 下拉框关闭时会把鼠标滚轮交还给左侧滚动面板,避免鼠标经过时误切换模式;打开下拉列表后仍可用滚轮滚动选项。
- `按 ID 选择` 会在输入框和 `选择` 按钮之间同步显示当前鼠标选择模式。
- `Feature` 模式现在会把点到的Face解释为局部几何特征候选:
- `特征` 模式现在会把点到的Face解释为几何特征候选:
- 平面会识别为可推拉平面候选。
- 平面会尝试查找同一Solid内投影重叠的相对平面,用于估算薄壁/壳体局部区域厚度。
- 圆柱面会识别为圆柱孔候选、槽/半孔候选、圆角/倒圆候选、凸台/外圆候选或未明确圆柱特征。
@@ -92,7 +92,7 @@
- 边长度
- 圆边半径/直径
- 属性面板现在还会显示更多几何调试信息:
- Part / Solid 的包围盒、尺寸、体积、重心、表面积。
- 零件 / Solid 的包围盒、尺寸、体积、重心、表面积。
- Face 的方向、面积中心、UV 参数范围、边界边数量、包围盒。
- 平面Face的几何法向和按拓扑方向修正后的法向。
- 平面Face的推拉向外方向、向内方向和方向判断置信度。
@@ -101,23 +101,23 @@
- 直线Edge的方向,圆弧Edge的圆心、轴线、半径和直径。
- 属性面板支持复制当前对象 ID、拾取坐标和完整信息。
- 面/边属性查询带缓存;重复选择同一个对象会复用上次计算结果,模型编辑、撤销、重做或重新加载后缓存会自动清空。
- 支持高亮选中的零件、Solid、Face、Edge或局部特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face 选择会对共面平面和同轴同半径圆柱面做同域区域高亮。
- 支持高亮选中的零件、Solid、Face、Edge或特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face 选择会对共面平面和同轴同半径圆柱面做同域区域高亮。
- 默认模型边线渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔推拉后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部Edge,仍然可以单独高亮查看。
- 支持只显示当前选中对象、对准当前选中对象,并可一键恢复显示完整模型。
- 支持两点测量:
- 可以把当前选中对象的拾取点设为 A 或 B;如果没有拾取点,会退回使用对象中心、面积中心、长度中心、重心或包围盒中心。
- 设置 A/B 后会显示两点距离和 X/Y/Z 方向差值。
- 3D 视图中会显示一条青色测量线;测量结果可以复制,加载新模型时会自动清除旧测量。
- Feature 模式会在属性表里显示特征来源Face、侧壁Face、同域圆柱范围、端面Face、疑似底面Face、开口端相邻Face、边界Edge和当前可用操作。
- 特征模式会在属性表里显示特征来源Face、侧壁Face、同域圆柱范围、端面Face、疑似底面Face、开口端相邻Face、边界Edge和当前可用操作。
- 支持导出当前完整模型为 STEP。
- 支持导出选中的Part为 STEP。
- 支持导出选中的零件为 STEP。
- 支持导出选中的Solid为 STEP。
- 支持导出选中的Face / 同域面区域为 STEP;如果当前Face与共面或同轴同半径的相邻面一起高亮,导出也会包含整片黄色区域。
- 支持导出选中特征区域为 STEP。
- 支持导出选中的Edge为 STEP。
- 支持导出质量检查:导出前会检查 B-Rep 有效性、Solid / Face / Edge 数量、体积和包围盒;如果发现风险,会先弹窗确认。
- 支持一键修复当前模型或选中零件/Solid:执行 ShapeFix 和同域面/边合并,修复会进入后台编辑流程,并支持撤销/重做。
- 导出完整模型、Part 或 Solid 前,会先尝试执行 ShapeFix 和同域面/边合并,减少外部 CAD 打开时出现碎面、坏体或明显拼接痕迹的概率。
- 导出完整模型、零件或 Solid 前,会先尝试执行 ShapeFix 和同域面/边合并,减少外部 CAD 打开时出现碎面、坏体或明显拼接痕迹的概率。
- 编辑按钮会根据当前选中对象自动启用或禁用,并通过悬停提示说明为什么当前操作不可用,减少 Face、Edge、孔、凸台、盲孔等对象混用导致的误操作。
- 支持扫描可编辑对象:
- 默认扫描优先保证界面响应速度,会列出一组有代表性的候选。
@@ -166,7 +166,7 @@
- 当前版本支持槽/半孔候选结构化识别:
- 对局部圆柱形 `hole/groove candidate`,会标记为 `partial-cylindrical-groove`
- 会估算槽宽、圆弧长度和槽深。
- 会识别槽圆柱Face和槽边界相邻Face,并在 Feature 模式下一起高亮。
- 会识别槽圆柱Face和槽边界相邻Face,并在特征模式下一起高亮。
- 这些仍是 B-Rep 几何估算,不是 CAD 历史里的槽特征参数。
- 当前版本支持槽/半孔宽度调整:
- 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。
@@ -180,7 +180,7 @@
- 当前版本支持已有圆角/倒圆候选结构化识别:
- 对局部小半径圆柱面,会标记为 `round/fillet candidate`
- 会估算已有圆角半径、圆弧角度和圆弧长度。
- 会识别已有圆角Face和相邻支撑Face,并在 Feature 模式下一起高亮。
- 会识别已有圆角Face和相邻支撑Face,并在特征模式下一起高亮。
- `可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径`,点击后会选中该圆角 Face,并把圆角半径输入框预填为当前估算半径的参考目标值。
- 选中已有圆角候选时,不会再误把它填入 `孔直径`,避免把圆角当孔径修改。
- 当前版本的已有圆角半径修改会先尝试移除圆角Face,再在恢复出的锐边上重新倒圆;只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。
@@ -222,17 +222,17 @@
- 椭圆边和可稳定采样为平面的 B-spline / Bezier 等非直线边,也会尝试围绕曲线所在平面法向做径向缩放;如果采样点不够平面,会退回更保守的缩放 fallback。
- 几何缩放 fallback 会先只预变换目标Edge来估算结果长度,并在必要时微调缩放比例,让实际边长更接近输入值。
- `中心`、没有明确端面、或非圆边没有更明确局部路径时,会尝试整体/轴向几何缩放 fallback,以覆盖更多任意Edge的最小可用场景。
- 该 fallback 会影响所属Part/Solid的其它尺寸,因此会按高风险确认,并依赖失败回滚/撤销兜底。
- 该 fallback 会影响所属零件/Solid的其它尺寸,因此会按高风险确认,并依赖失败回滚/撤销兜底。
- 这仍不是通用参数化边长编辑;复杂拓扑、斜面过渡、圆角链或布尔失败场景仍可能不能稳定修改。
- 支持 Part / Solid 级平移:
- 支持零件 / Solid 级平移:
- 输入 X/Y/Z 平移量后,可以平移当前选中零件。
- 选中Solid、或选中属于某个Solid的Face/Edge后,可以平移当前Solid。
- 单Solid零件中平移Solid实际等同于移动整个Part shape,程序会按中风险提示。
- 单Solid零件中平移Solid实际等同于移动整个零件 shape,程序会按中风险提示。
- 平移操作会写入历史记录,并支持撤销/重做。
- 支持 Part / Solid 级旋转:
- 支持零件 / Solid 级旋转:
- 选择 X/Y/Z 旋转轴并输入角度后,可以旋转当前选中零件或Solid。
- 当前版本的旋转中心取目标对象的包围盒中心。
- 单Solid零件中旋转Solid实际等同于旋转整个Part shape,程序会按中风险提示。
- 单Solid零件中旋转Solid实际等同于旋转整个零件 shape,程序会按中风险提示。
- 旋转操作会写入历史记录,并支持撤销/重做。
- 后台编辑计算时不再弹出模态进度框,也不再把主界面整体置灰;UI 线程只负责渲染和响应基础视图交互,真实 B-Rep 编辑在后台线程执行。
- 编辑计算期间,半透明预览会继续在 3D 视图里渲染,用户可以旋转或缩放查看;会改变模型状态的操作仍会暂时禁用,避免同一模型被多个后台编辑同时修改。
@@ -292,7 +292,7 @@ edges: 4982
vertices: 3262
```
所以当前测试文件大概率是单个零件,不是多零件装配。但代码已经按“将来可能有多个零件/装配”的方式组织。
所以当前测试文件大概率是单个零件,不是多零件装配。Face、Edge、圆柱面、孔、槽和圆角等,是这个零件里的几何特征;代码也按“将来可能有多个零件/装配”的方式组织。
## 还没有实现什么
@@ -399,17 +399,17 @@ python scripts\verify_edge_length_resize.py
左侧操作面板按功能分区排列,每个模块都有四边同色的独立边框和颜色提示,顺序大致按工作流排列:先打开和选择模型,再做编辑或导出,然后进行显示控制和测量,后面继续放可编辑对象、圆柱面候选、操作历史和对象信息。
左侧 `鼠标选择模式` 用来切换选择模式:
左侧 `选择模式` 用来切换鼠标选择模式:
- `Part`:选择整个零件。
- `零件`:选择整个零件。
- `Solid`:选择实体。
- `Face`:选择面。
- `Edge`:选择边。
- `Feature`:按局部特征候选方式选择。平面会识别为可推拉平面候选;圆柱面会进一步识别为圆柱孔、槽/半孔、圆角/倒圆、凸台/外圆等候选。
- `特征`:按几何特征候选方式选择。平面会识别为可推拉平面候选;圆柱面会进一步识别为圆柱孔、槽/半孔、圆角/倒圆、凸台/外圆等候选。
圆柱面候选会额外显示侧壁 Face、端面 Face、疑似底面 Face、开口端相邻 Face 和边界 Edge。
- 鼠标悬停时,当前模式下将要选中的对象会显示为红色;真正选中后会显示为黄色。
- 如果当前模式是 `Edge`,即使鼠标点到的是面,程序也会从这个面的边界里选取距离鼠标拾取点最近的Edge。
- `鼠标选择模式` 下拉框关闭时不会吃掉滚轮,鼠标停在上面滚动会继续滚动左侧面板;点击打开下拉列表后,滚轮才用于滚动选项。
- `选择模式` 下拉框关闭时不会吃掉滚轮,鼠标停在上面滚动会继续滚动左侧面板;点击打开下拉列表后,滚轮才用于滚动选项。
左侧 `模型结构树` 会显示 STEP 里的装配、零件和实体层级;如果某个零件下有实体,会展开显示 `实体 0``实体 1` 这样的子节点。点击实体子节点会自动切换到 `Solid` 选择,并高亮对应实体。
@@ -424,17 +424,17 @@ python scripts\verify_edge_length_resize.py
左侧 `按 ID 选择` 可以直接输入编号选择对象:
- 按 ID 选择会复用上方 `鼠标选择模式` 的当前类型;先切换模式,再输入 ID,点击 `选择`
- 按 ID 选择会复用上方 `选择模式` 的当前类型;先切换模式,再输入 ID,点击 `选择`
- 输入框右侧会同步显示当前类型,例如 `Face``Edge``Solid`,用于确认这个 ID 会按哪类对象解释。
- `Part` 的 ID 从 1 开始。
- `Solid``Face``Edge``Feature` 的 ID 从 0 开始;`Feature` 使用来源Face的 ID。
- `零件` 的 ID 从 1 开始。
- `Solid``Face``Edge``特征` 的 ID 从 0 开始;`特征` 使用来源Face的 ID。
- 这个功能适合配合圆柱面候选表格、操作历史和属性面板使用。
左侧 `显示` 用来临时缩小三维视图范围:
- `只显示选中`:只显示当前选中的 Part、Solid、Face、Edge 或 Feature 区域。
- `只显示选中`:只显示当前选中的零件、Solid、Face、Edge 或特征区域。
- Face 模式下 `只显示选中` 会隔离当前黄色同域面区域,并显示这片区域的外边界Edge。
- `对准选中`:不改变显示范围,只把相机对准当前选中的 Part、Solid、Face、Edge 或 Feature,适合快速查看小孔、小边和局部特征。
- `对准选中`:不改变显示范围,只把相机对准当前选中的零件、Solid、Face、Edge 或特征,适合快速查看小孔、小边和局部特征。
- `显示全部`:恢复显示完整模型。
- `显示同域内部边`:默认关闭,用来隐藏同一平面/同一圆柱面内部的拓扑分割边和几何重复拼接边;打开后可以查看完整 B-Rep 拓扑边线,适合调试布尔推拉后的分面情况。
- `导出选中面区域` 会导出当前 Face 模式黄色高亮的同域面区域,而不是只导出鼠标点到的单个拓扑Face。
@@ -465,7 +465,7 @@ python scripts\verify_edge_length_resize.py
- `调整盲孔深度` 行表示这个Face是较明确的盲孔/盲槽候选,可以配合 `孔深度``调整盲孔深度` 使用。
- `给Edge添加圆角` 行表示这个对象是直线Edge,可以配合 `圆角半径``给Edge添加圆角` 使用。
- `给Edge添加倒角` 行表示这个对象是直线Edge,可以配合 `倒角距离``给Edge添加倒角` 使用。
- `修改已有圆角半径` 行表示这个Face是已有圆角/倒圆候选,点击后会切到 `Feature` 模式并预填圆角半径参考值;真正执行需要再点击编辑区里的 `修改已有圆角半径` 按钮并确认。
- `修改已有圆角半径` 行表示这个Face是已有圆角/倒圆候选,点击后会切到 `特征` 模式并预填圆角半径参考值;真正执行需要再点击编辑区里的 `修改已有圆角半径` 按钮并确认。
- `状态``ready` 表示比较适合尝试,`caution` 表示可以尝试但风险更高,`blocked` 表示当前参数或对象不适合执行。
- `风险` 越高越应该谨慎,尤其是圆角、凸柱或未明确圆柱面,不要直接当孔修改。
- 点击任意一行会自动选中并高亮对应Face或Edge。
@@ -474,7 +474,7 @@ python scripts\verify_edge_length_resize.py
- 鼠标经过对象时会出现红色预高亮;被选对象会显示为黄色高亮。
-`Face` 模式下,如果相邻Face属于同一几何面,并且投影范围或轴向范围相接/重叠,程序会把它们作为同域区域一起红色预高亮和黄色选中高亮;这主要用于缓解平面/圆柱推拉后视觉上被拓扑边分成多段的问题。
- 如果处于 `Feature` 模式,程序会尽量高亮这个特征相关的局部区域,例如圆柱侧壁、相邻端面和槽边界相邻面。
- 如果处于 `特征` 模式,程序会尽量高亮这个特征相关的局部区域,例如圆柱侧壁、相邻端面和槽边界相邻面。
- 对槽/半孔候选,属性表会显示 `槽宽估算``槽圆弧长度估算``槽深估算``槽边界相邻 Face`
- 对圆角/倒圆候选,属性表会显示 `已有圆角半径估算``已有圆角圆弧角度``已有圆角圆弧长度估算``已有圆角支撑 Face`
- 对盲孔/槽候选,属性表中的 `疑似底面 Face` 是根据圆柱端部 inside / outside 采样和相邻平面位置推断的,不等于 CAD 原始建模历史里的孔深参数。
@@ -496,7 +496,7 @@ python scripts\verify_edge_length_resize.py
- 类型下拉框关闭时不会吃掉滚轮,鼠标停在上面滚动会继续滚动左侧面板;点击打开下拉列表后,滚轮才用于滚动选项。
- 当前默认显示前 60 个圆柱面候选;如果模型很复杂,扫描仍可能需要等待一会儿,但扫描过程中界面会尽量保持响应。
- 表格里的每一行对应一个圆柱面候选。
- 点击候选行会自动切到 `Feature` 模式,并选中对应Face。
- 点击候选行会自动切到 `特征` 模式,并选中对应Face。
- `guess` 列是程序通过几何采样得到的初步判断,不是 STEP 文件里自带的 CAD 历史特征。
- `round/fillet candidate` 是圆角/倒圆候选;部分规整圆柱圆角可以通过 `修改已有圆角半径` 进行当前版本受限修改。
- `diameter` 是圆柱直径。
@@ -541,7 +541,7 @@ python scripts\verify_edge_length_resize.py
- 端部类型是通过轴线端部 inside / outside 采样得到的估算,不等于 CAD 原始建模历史里的“孔深”参数。
- 如果选到的圆柱面不是孔,而是柱或圆角,结果可能不是你想要的,所以它目前仍是实验功能。
- `槽/半孔宽度` + `调整槽/半孔宽度`
- 先切换到 `Feature` 选择模式并选择一个槽/半孔候选 Face,或从圆柱面候选表中选择局部圆柱形 `hole/groove candidate`
- 先切换到 `特征` 选择模式并选择一个槽/半孔候选 Face,或从圆柱面候选表中选择局部圆柱形 `hole/groove candidate`
- 选中符合条件的槽/半孔后,`槽/半孔宽度` 会自动填入一个比当前槽宽估算略大的建议值。
- 输入目标槽宽后点击 `调整槽/半孔宽度`
- 程序会用当前圆弧角度把目标槽宽换算成目标圆柱直径,再复用圆柱孔/槽调整流程。
@@ -588,7 +588,7 @@ python scripts\verify_edge_length_resize.py
- 这是“给直线边添加新圆角”,不是修改已有圆角面。
- 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒圆;失败时程序会提示并尝试恢复编辑前状态。
- `圆角半径` + `修改已有圆角半径`
- 先切换到 `Feature` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。
- 先切换到 `特征` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。
- 选中已有圆角候选后,`圆角半径` 会自动填入一个基于当前估算半径的参考目标值。
- 输入目标半径后点击 `修改已有圆角半径`
- 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。
@@ -617,25 +617,25 @@ python scripts\verify_edge_length_resize.py
- 如果圆边没有可复用圆柱面,但能读取圆心和轴线,会走 `围绕圆边轴线径向缩放` 后备策略,尽量不改变轴向厚度/高度;执行后部分解析几何可能变成 B-spline。
- 椭圆边、平面 B-spline / Bezier 边会尝试走 `围绕平面曲线法向径向缩放`;确认窗口会显示平面采样偏差,偏差不稳定时会回退到更保守的缩放 fallback。
- `固定起点` 会让起点不动、移动终点;`固定终点` 相反;`中心` 会让两端各移动一半,只有进入缩放 fallback 时才会变成以Edge中心为基准缩放。
- 如果没有明确端面或可复用圆柱面,程序会按所选基准尝试整体/轴向几何缩放 fallback;这会影响所属Part/Solid的其它尺寸,因此会弹出高风险确认。
- 执行成功后会做后置边长校验:在刷新后的Part/Solid范围内寻找最接近目标长度的Edge,并把实际最近边长、误差和相对误差写入操作结果。
- 如果没有明确端面或可复用圆柱面,程序会按所选基准尝试整体/轴向几何缩放 fallback;这会影响所属零件/Solid的其它尺寸,因此会弹出高风险确认。
- 执行成功后会做后置边长校验:在刷新后的零件/Solid范围内寻找最接近目标长度的Edge,并把实际最近边长、误差和相对误差写入操作结果。
- 通过检查后,会显示蓝绿色半透明预览,表示将要移动或缩放的范围。
- 真实编辑在后台执行,失败时会回滚到编辑前状态。
- 这是当前版本的 B-Rep 结果几何修改,不是任意边参数化改长。
- `平移 X/Y/Z` + `平移选中零件` / `平移选中Solid`
- 输入 X、Y、Z 三个方向的平移量。
- 选择 `Part` 后点击 `平移选中零件`,会移动整个Part
- 选择 `零件` 后点击 `平移选中零件`,会移动整个零件
- 选择 `Solid`,或选择属于某个Solid的Face/Edge后,点击 `平移选中Solid`,会移动当前Solid。
- 如果当前Part只有一个Solid,平移Solid实际会移动整个Part shape,程序会提示中风险。
- 如果当前零件只有一个Solid,平移Solid实际会移动整个零件 shape,程序会提示中风险。
- 平移向量为 0 会直接阻止。
- 如果平移距离相对对象包围盒过大,程序会提高风险并要求确认。
- 平移属于真实 B-Rep 变换,会写入操作历史,可以撤销/重做。
- `旋转轴/角度` + `旋转选中零件` / `旋转选中Solid`
- 选择 X、Y 或 Z 旋转轴。
- 输入旋转角度,单位是度。
- 选择 `Part` 后点击 `旋转选中零件`,会绕该Part的包围盒中心旋转整个Part
- 选择 `零件` 后点击 `旋转选中零件`,会绕该零件的包围盒中心旋转整个零件
- 选择 `Solid`,或选择属于某个Solid的Face/Edge后,点击 `旋转选中Solid`,会绕该Solid的包围盒中心旋转当前Solid。
- 如果当前Part只有一个Solid,旋转Solid实际会旋转整个Part shape,程序会提示中风险。
- 如果当前零件只有一个Solid,旋转Solid实际会旋转整个零件 shape,程序会提示中风险。
- 旋转角度为 0 会直接阻止;角度超过 360 度会提高风险并要求确认。
- 旋转属于真实 B-Rep 变换,会写入操作历史,可以撤销/重做。
- `撤销`
@@ -664,7 +664,7 @@ python scripts\verify_edge_length_resize.py
- `导出选中零件`:导出当前选中的零件。
- `导出选中Solid`:先选中一个Solid,再导出该Solid。
- `导出选中面区域`:先切换到 `Face` 选择模式并选中一个Face,再导出该Face或同域面区域。
- `导出选中特征区域`:先切换到 `Feature` 选择模式并选中局部特征,再导出当前特征高亮的Face集合。
- `导出选中特征区域`:先切换到 `特征` 选择模式并选中特征,再导出当前特征高亮的Face集合。
- `导出选中Edge`:先切换到 `Edge` 选择模式并选中一个Edge,再导出该Edge。
- `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。
- `修复当前模型`:对当前完整模型执行 ShapeFix 和同域面/边合并,完成后会刷新显示并写入操作历史;如果修复失败,会尝试回滚到修复前状态。
@@ -701,7 +701,7 @@ pythonocc-step-editor/
polydata.py # OCC Shape / Edge / Face 到 VTK polydata 的显示数据生成
records.py # 操作历史记录数据结构
step_io.py # STEP/XCAF 读取、产品名解析和 STEP 写出
transforms.py # Part / Solid 平移和旋转
transforms.py # 零件 / Solid 平移和旋转
ui_helpers.py # UI 常量、格式化函数和 VTK 显示小工具
widgets.py # 自定义 Qt 小组件
window_actions.py # 导出、编辑按钮、扫描、后台编辑调度等窗口动作
@@ -739,7 +739,7 @@ pythonocc-step-editor/
本项目是一个基于 Python、pythonocc-core/OCCT、VTK 和 PySide6/Qt 的 STEP 查看与局部编辑原型。它处理的是 STEP 里的 B-Rep 结果几何,不是原 CAD 软件的完整参数化建模历史。
最小系统目标是打通:加载 STEP、显示模型、选择 Part/Solid/Face/Edge/Feature、查看属性、识别初步候选特征、执行受限局部编辑、撤销/重做、质量检查和导出。
最小系统目标是打通:加载 STEP、显示模型、选择零件/Solid/Face/Edge/特征、查看属性、识别初步候选特征、执行受限局部编辑、撤销/重做、质量检查和导出。
### 运行方式
@@ -797,11 +797,11 @@ git diff --check
### 当前关键能力
- STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。
- 选择:支持 Part、Solid、Face、Edge、Feature。鼠标悬停红色预高亮,选中黄色高亮。
- 同域面:Face/Feature 会尽量把共面或同轴同半径的连续碎面合成逻辑区域。
- 编辑:平面推拉、孔径、槽/半孔宽度、盲孔/盲槽深度、凸台直径、已有圆角半径当前版本修改、Edge圆角、Edge倒角、薄壁厚度、修改Edge长度、Part/Solid 平移和旋转。
- 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮。
- 同域面:Face/特征会尽量把共面或同轴同半径的连续碎面合成逻辑区域。
- 编辑:平面推拉、孔径、槽/半孔宽度、盲孔/盲槽深度、凸台直径、已有圆角半径当前版本修改、Edge圆角、Edge倒角、薄壁厚度、修改Edge长度、零件/Solid 平移和旋转。
- 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。
- 导出:完整模型、Part、Solid、Face、Feature 区域和 Edge,导出前做基础质量检查。
- 导出:完整模型、零件、Solid、Face、特征区域和 Edge,导出前做基础质量检查。
### 修改Edge长度
@@ -809,7 +809,7 @@ git diff --check
当前策略顺序:
1. `local-edge-only-deform`:直线Edge位于简单全平面多面体时,优先只移动被选Edge的端点并重建所属Solid/Part。对立方体测试模型,默认固定起点移动终点;相邻面会自然变成梯形,必要时非共面四边面拆三角面,而不是把整个正方体缩放成长方体。
1. `local-edge-only-deform`:直线Edge位于简单全平面多面体时,优先只移动被选Edge的端点并重建所属Solid/零件。对立方体测试模型,默认固定起点移动终点;相邻面会自然变成梯形,必要时非共面四边面拆三角面,而不是把整个正方体缩放成长方体。
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,风险高,会影响其它尺寸。
+252 -84
View File
@@ -7,15 +7,17 @@ import sys
from pathlib import Path
import vtk
from PySide6.QtCore import Qt, QThread, QTimer, Slot
from PySide6.QtCore import Qt, QThread, QTimer, Signal, Slot
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QCheckBox,
QComboBox,
QFileDialog,
QFrame,
QGridLayout,
QGroupBox,
QHeaderView,
QHBoxLayout,
QLabel,
QLineEdit,
@@ -25,6 +27,7 @@ from PySide6.QtWidgets import (
QPushButton,
QPlainTextEdit,
QScrollArea,
QSizePolicy,
QTableWidget,
QTableWidgetItem,
QTabWidget,
@@ -52,6 +55,16 @@ _CRASH_LOG_HANDLE = None
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
# Panel build switches. These work like a Python-side "#if 0": code in a
# disabled branch is kept for later, but the widgets are not constructed.
ENABLE_STRUCTURE_TREE_PANEL = False
ENABLE_VIEW_PANEL = False
ENABLE_MEASURE_PANEL = False
ENABLE_EXPORT_PANEL = False
ENABLE_EDITABLE_OBJECTS_PANEL = False
ENABLE_CYLINDER_CANDIDATES_PANEL = False
ENABLE_HISTORY_PANEL = False
def _enable_crash_log() -> None:
global _CRASH_LOG_HANDLE
@@ -66,9 +79,12 @@ def _enable_crash_log() -> None:
class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, InfoPanelMixin, QMainWindow):
ui_task_requested = Signal(object)
def __init__(self, step_path: str | Path, *, background_load: bool = True):
super().__init__()
self.setWindowTitle("STEP 零件查看与编辑原型")
self.ui_task_requested.connect(self._run_ui_task, Qt.ConnectionType.QueuedConnection)
self.setWindowTitle("几何参数化")
self.resize(1280, 820)
self.model: StepModel | None = None
@@ -143,6 +159,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.pending_load_path: Path | None = None
self.initial_load_deflection = 2.0
self.last_id_kind = "Face"
self.property_editor_updating = False
self.property_editor_specs: list[dict[str, object]] = []
self.property_table_expanded = False
self.property_table_collapsed_rows = 6
self._build_ui()
self._build_vtk()
@@ -237,17 +257,31 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
border-color: #b8dfd6;
color: #0f766e;
}
QGroupBox#modeSection,
QGroupBox#idSection {
background: #f7f5ff;
border: 1px solid #cbc4ff;
border-left: 5px solid #6b5cff;
QFrame#modeSection {
background: #f6f3ff;
border: 1px solid #a99bff;
border-left: 5px solid #5b4bdb;
border-radius: 7px;
}
QGroupBox#modeSection::title,
QGroupBox#idSection::title {
background: #eeeaff;
border-color: #cbc4ff;
color: #5642c7;
QLabel#modeSectionTitle {
background: #ebe7ff;
border: 1px solid #a99bff;
border-radius: 5px;
color: #4736b3;
font-weight: 700;
padding: 1px 8px;
}
QFrame#modeIdSeparator {
background: #ded8ff;
border-left: 2px solid #5b4bdb;
border-right: 1px solid #b8adff;
max-width: 8px;
min-width: 8px;
}
QLabel#mouseModeLabel,
QLabel#idSelectLabel {
color: #4736b3;
font-weight: 700;
}
QGroupBox#viewSection {
background: #f1faff;
@@ -337,12 +371,6 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
QComboBox:focus {
border: 1px solid #3478f6;
}
QLineEdit#idModeDisplay {
background: #eef2ff;
border-color: #93a5e8;
color: #2d3a8c;
font-weight: 700;
}
QPushButton {
background: #f8fafc;
border: 1px solid #c8d2df;
@@ -365,6 +393,24 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
color: #8f99a8;
font-weight: 500;
}
QPushButton#propertyRowEditButton {
background: #fff3d9;
border: 1px solid #d97706;
border-radius: 5px;
color: #92400e;
font-weight: 700;
min-height: 18px;
padding: 1px 6px;
}
QPushButton#propertyRowEditButton:hover {
background: #ffe6b8;
border-color: #b45309;
}
QPushButton#propertyRowEditButton:disabled {
background: #eef2f6;
border: 1px dashed #bcc7d4;
color: #8f99a8;
}
QLineEdit:disabled,
QComboBox:disabled,
QPlainTextEdit:disabled {
@@ -372,6 +418,26 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
border: 1px dashed #bcc7d4;
color: #8f99a8;
}
QLineEdit#idSelectInput {
background: #ffffff;
border: 1px solid #8f82ea;
color: #172033;
placeholder-text-color: #64748b;
}
QLineEdit#idSelectInput:focus {
background: #ffffff;
border: 1px solid #5b4bdb;
}
QLineEdit#idSelectInput:disabled {
background: #ffffff;
border: 1px solid #c8d2df;
color: #172033;
}
QLineEdit#stepPathDisplay {
background: #ffffff;
border: 1px solid #bdd3ff;
color: #172033;
}
QCheckBox:disabled,
QTabWidget:disabled,
QTreeWidget:disabled,
@@ -423,26 +489,32 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
file_box = QGroupBox("STEP 文件")
file_box.setObjectName("fileSection")
help_tip(file_box, "打开、重新加载和查看当前 STEP 文件路径")
help_tip(file_box, "导入、查看路径并读取当前 STEP 几何模型")
file_layout = QVBoxLayout(file_box)
self.path_label = QLabel("")
self.path_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
self.path_label.setWordWrap(True)
help_tip(self.path_label, "当前打开的 STEP 文件路径。可以选中文字复制路径。")
file_layout.addWidget(self.path_label)
file_buttons = QHBoxLayout()
self.open_button = QPushButton("打开")
help_tip(self.open_button, "选择并打开一个 .step 或 .stp 文件。打开失败时会保留当前模型")
file_buttons.setSpacing(6)
self.open_button = QPushButton("导入几何模型")
self.open_button.setMinimumWidth(96)
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.path_label = QLineEdit(str(self.step_path))
self.path_label.setObjectName("stepPathDisplay")
self.path_label.setReadOnly(True)
self.path_label.setMinimumWidth(120)
help_tip(self.path_label, "当前 STEP 文件的完整路径。可以选中文字复制路径。")
self.path_label.setToolTip(str(self.step_path))
self.path_label.setCursorPosition(0)
self.reload_button = QPushButton("读取模型")
self.reload_button.setMinimumWidth(78)
help_tip(self.reload_button, "从显示的路径重新读取 STEP 模型,用于放弃本次会话里的临时查看状态。")
self.reload_button.clicked.connect(self.reload_step)
file_buttons.addWidget(self.open_button)
file_buttons.addWidget(self.path_label, stretch=1)
file_buttons.addWidget(self.reload_button)
file_layout.addLayout(file_buttons)
panel_layout.addWidget(file_box)
if ENABLE_STRUCTURE_TREE_PANEL:
tree_box = QGroupBox("模型结构树")
tree_box.setObjectName("treeSection")
help_tip(tree_box, "查看 STEP 里的装配、零件和实体层级,并从树上直接选中对象。")
@@ -456,53 +528,92 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
tree_layout.addWidget(self.part_tree)
panel_layout.addWidget(tree_box)
mode_box = QGroupBox("鼠标选择模式")
mode_box.setObjectName("modeSection")
help_tip(mode_box, "决定鼠标点模型时选中 Part、Solid、Face、Edge,还是识别局部特征。")
mode_layout = QVBoxLayout(mode_box)
mode_box = QWidget()
mode_box.setMinimumHeight(62)
help_tip(mode_box, "决定鼠标点模型时选中零件、Solid、Face、Edge,还是识别几何特征。")
self.mode_section_title = QLabel("选择模式", mode_box)
self.mode_section_title.setObjectName("modeSectionTitle")
self.mode_section_title.setFixedSize(74, 20)
self.mode_section_title.move(12, 0)
self.mode_section_title.raise_()
help_tip(self.mode_section_title, "选择鼠标点模型时按什么对象类型选择。")
mode_outer_layout = QVBoxLayout(mode_box)
mode_outer_layout.setContentsMargins(0, 9, 0, 0)
mode_outer_layout.setSpacing(0)
self.mode_section_frame = QFrame()
self.mode_section_frame.setObjectName("modeSection")
self.mode_section_frame.setMinimumHeight(50)
mode_outer_layout.addWidget(self.mode_section_frame)
self.mode_section_title.raise_()
mode_layout = QHBoxLayout(self.mode_section_frame)
mode_layout.setSpacing(0)
mode_layout.setContentsMargins(0, 0, 0, 0)
mode_pick_panel = QWidget()
mode_pick_layout = QHBoxLayout(mode_pick_panel)
mode_pick_layout.setContentsMargins(8, 10, 8, 6)
mode_pick_layout.setSpacing(6)
self.mouse_mode_label = QLabel("按鼠标:")
self.mouse_mode_label.setObjectName("mouseModeLabel")
self.mouse_mode_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight)
self.mouse_mode_label.setMaximumWidth(62)
help_tip(self.mouse_mode_label, "这里决定鼠标点击 3D 模型时按哪种对象类型选择。")
self.mode_combo = NoWheelComboBox()
self.mode_combo.addItems(["Part", "Solid", "Face", "Edge", "Feature"])
self.mode_combo.setCurrentText("Face")
for mode in ("Part", "Solid", "Face", "Edge", "Feature"):
self.mode_combo.addItem(_selection_mode_label(mode), mode)
self._set_selection_mode("Face")
self.mode_combo.setMinimumWidth(94)
self.mode_combo.setMaximumWidth(112)
help_tip(
self.mode_combo,
"选择鼠标点击模型时要选什么:Part、Solid、Face、Edge,或把 Face 解释成孔/槽/圆角等特征候选。",
"选择模式决定鼠标点击模型时要选什么:零件、Solid、Face、Edge,或把 Face 解释成孔/槽/圆角等几何特征候选。",
)
self.mode_combo.currentTextChanged.connect(self._on_mode_changed)
mode_layout.addWidget(self.mode_combo)
self.mode_combo.currentIndexChanged.connect(lambda _index: self._on_mode_changed(self._current_selection_mode()))
mode_pick_layout.addWidget(self.mouse_mode_label)
mode_pick_layout.addWidget(self.mode_combo)
mode_layout.addWidget(mode_pick_panel)
mode_separator = QFrame()
mode_separator.setFrameShape(QFrame.Shape.NoFrame)
mode_separator.setObjectName("modeIdSeparator")
mode_separator.setFixedWidth(8)
mode_separator.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
self.mode_id_separator = mode_separator
help_tip(mode_separator, "左侧是鼠标点选模式,右侧是按当前模式输入 ID 选择。")
mode_layout.addWidget(mode_separator)
id_select_panel = QWidget()
select_layout = QHBoxLayout(id_select_panel)
select_layout.setContentsMargins(8, 10, 8, 6)
select_layout.setSpacing(6)
self.id_select_label = QLabel(f"按ID{_selection_mode_label(self._current_selection_mode())}")
self.id_select_label.setObjectName("idSelectLabel")
self.id_select_label.setMinimumWidth(78)
help_tip(self.id_select_label, "按当前选择模式解释输入的 ID。")
self.id_input = QLineEdit("")
self.id_input.setObjectName("idSelectInput")
self.id_input.setPlaceholderText("ID")
self.id_input.setMaximumWidth(70)
help_tip(self.id_input, "输入要选中的对象 ID。ID 类型显示在左侧标签中,并会跟随选择模式变化。")
self.select_id_button = QPushButton("选择")
self.select_id_button.setMaximumWidth(58)
help_tip(self.select_id_button, "按当前选择模式跳转到输入的 ID,并在 3D 视图中高亮它。")
self.select_id_button.clicked.connect(lambda _checked=False: self.select_by_id(self._current_selection_mode()))
self.id_input.textChanged.connect(lambda _text: self._update_action_states())
self.id_input.returnPressed.connect(lambda: self.select_by_id(self._current_selection_mode()))
select_layout.addWidget(self.id_select_label)
select_layout.addWidget(self.id_input)
select_layout.addWidget(self.select_id_button)
mode_layout.addWidget(id_select_panel, stretch=1)
panel_layout.addWidget(mode_box)
select_box = QGroupBox("按 ID 选择")
select_box.setObjectName("idSection")
help_tip(select_box, "知道对象 ID 时,可以直接输入 ID 跳转选择。")
select_layout = QGridLayout(select_box)
self.id_input = QLineEdit("")
self.id_input.setPlaceholderText("输入 ID")
help_tip(self.id_input, "输入要选中的对象 ID。ID 的类型由右侧显示的选择模式决定。")
self.id_mode_display = QLineEdit(self.mode_combo.currentText())
self.id_mode_display.setObjectName("idModeDisplay")
self.id_mode_display.setReadOnly(True)
self.id_mode_display.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.id_mode_display.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.id_mode_display.setMinimumWidth(70)
help_tip(self.id_mode_display, "当前按这个鼠标选择模式解释输入的 ID。它会跟随鼠标选择模式自动变化。")
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(self.select_id_button, 0, 3)
panel_layout.addWidget(select_box)
if ENABLE_VIEW_PANEL:
view_box = QGroupBox("显示")
view_box.setObjectName("viewSection")
help_tip(view_box, "只改变视图显示方式,不会修改 STEP 几何。")
view_layout = QVBoxLayout(view_box)
view_buttons = QHBoxLayout()
self.isolate_button = QPushButton("只显示选中")
help_tip(self.isolate_button, "把视图临时隔离到当前选中的 Part、Solid、Face、Edge 或特征区域。不会修改模型。")
help_tip(self.isolate_button, "把视图临时隔离到当前选中的零件、Solid、Face、Edge 或几何特征区域。不会修改模型。")
self.isolate_button.clicked.connect(self.isolate_selected)
self.fit_button = QPushButton("对准选中")
help_tip(self.fit_button, "把相机移动到当前选中对象附近,方便看清细节。不会修改模型。")
@@ -522,6 +633,7 @@ 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)
if ENABLE_MEASURE_PANEL:
measure_box = QGroupBox("测量")
measure_box.setObjectName("measureSection")
help_tip(measure_box, "把当前选中对象的拾取点或中心设为 A/B,计算两点距离和 X/Y/Z 差值。不会修改模型。")
@@ -551,6 +663,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
measure_layout.addLayout(measure_buttons)
self._refresh_measurement_panel()
if ENABLE_EXPORT_PANEL:
export_box = QGroupBox("导出")
export_box.setObjectName("exportSection")
help_tip(export_box, "把当前模型或选中对象导出为 STEP,也可以做基础质量检查和修复。")
@@ -568,7 +681,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
help_tip(self.export_face_button, "导出当前选中的面区域;同域高亮的共面/同圆柱区域也会一起导出。")
self.export_face_button.clicked.connect(self.export_selected_face)
self.export_feature_button = QPushButton("导出选中特征区域")
help_tip(self.export_feature_button, "导出 Feature 模式识别到的局部特征区域,例如孔、槽、圆角或凸台候选。")
help_tip(self.export_feature_button, "导出特征模式识别到的区域,例如孔、槽、圆角或凸台候选。")
self.export_feature_button.clicked.connect(self.export_selected_feature)
self.export_edge_button = QPushButton("导出选中Edge")
help_tip(self.export_edge_button, "导出当前选中的Edge。主要用于调试、定位或把边界单独拿出去检查。")
@@ -592,9 +705,57 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
export_layout.addWidget(self.repair_model_button)
export_layout.addWidget(self.repair_selected_button)
edit_box = QGroupBox("编辑")
self.object_edit_box = QGroupBox("当前选中对象:未选择")
self.object_edit_box.setObjectName("editSection")
help_tip(
self.object_edit_box,
"选择模型对象后,这里会列出当前对象的属性和值;能改的行可以输入目标值,不能改的行只读。",
)
object_edit_layout = QVBoxLayout(self.object_edit_box)
self.property_table = QTableWidget(0, 4)
self.property_table.setHorizontalHeaderLabels(["属性", "当前值", "目标值", "状态"])
self.property_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.property_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.property_table.setAlternatingRowColors(True)
self.property_table.setMinimumHeight(180)
self.property_table.setWordWrap(False)
self.property_table.setTextElideMode(Qt.TextElideMode.ElideMiddle)
self.property_table.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.property_table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.property_table.verticalHeader().setVisible(False)
self.property_table.verticalHeader().setDefaultSectionSize(24)
self.property_table.verticalHeader().setMinimumSectionSize(22)
property_header = self.property_table.horizontalHeader()
property_header.setMinimumSectionSize(44)
property_header.setStretchLastSection(False)
property_header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
self.property_table.setColumnWidth(0, 106)
self.property_table.setColumnWidth(1, 92)
self.property_table.setColumnWidth(2, 92)
self.property_table.setColumnWidth(3, 62)
help_tip(
self.property_table,
"当前选中对象的属性表。可修改的行可以在目标值列输入新值,并点击状态列里的修改按钮只应用这一行。",
)
self.property_table.itemChanged.connect(self._on_property_table_item_changed)
object_edit_layout.addWidget(self.property_table)
self.property_expand_button = QPushButton("展开全部属性")
help_tip(self.property_expand_button, "当前选中对象属性较多时,默认只显示前 6 行;点击这里展开或收起属性表。")
self.property_expand_button.clicked.connect(self.toggle_property_table_expanded)
self.property_expand_button.setVisible(False)
object_edit_layout.addWidget(self.property_expand_button)
self.apply_property_button = QPushButton("参数化建模")
help_tip(self.apply_property_button, "按当前属性表里修改过的目标值执行参数化建模;一次只执行一个几何修改,成功后可撤销。")
self.apply_property_button.clicked.connect(self.apply_current_property_edit)
object_edit_layout.addWidget(self.apply_property_button)
edit_box = QGroupBox(panel)
edit_box.setObjectName("editSection")
help_tip(edit_box, "这里的按钮会真实修改当前 B-Rep 模型;执行前通常会预览,成功后可撤销。部分能力仍是当前版本的几何近似。")
edit_box.setVisible(False)
self.hidden_edit_controls_box = edit_box
edit_layout = QGridLayout(edit_box)
edit_layout.addWidget(QLabel("Face偏移"), 0, 0)
self.offset_input = QLineEdit("5.0")
@@ -770,24 +931,15 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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.clicked.connect(self.list_cylinders)
edit_layout.addWidget(self.cylinders_button, 26, 0, 1, 2)
self.undo_button = QPushButton("撤销")
help_tip(self.undo_button, "撤销上一次成功编辑,把模型恢复到编辑前快照。")
self.undo_button.clicked.connect(self.undo_edit)
self.redo_button = QPushButton("重做")
help_tip(self.redo_button, "重做刚刚撤销的编辑。")
self.redo_button.clicked.connect(self.redo_edit)
edit_layout.addWidget(self.undo_button, 27, 0)
edit_layout.addWidget(self.redo_button, 27, 1)
panel_layout.addWidget(edit_box)
panel_layout.addWidget(self.object_edit_box)
if ENABLE_EXPORT_PANEL:
panel_layout.addWidget(export_box)
if ENABLE_VIEW_PANEL:
panel_layout.addWidget(view_box)
if ENABLE_MEASURE_PANEL:
panel_layout.addWidget(measure_box)
if ENABLE_EDITABLE_OBJECTS_PANEL:
editable_box = QGroupBox("可编辑对象")
editable_box.setObjectName("editableSection")
help_tip(editable_box, "自动找出当前版本可以尝试编辑的Face或Edge,方便不用手动到处点。")
@@ -795,7 +947,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
editable_button_row = QHBoxLayout()
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_refresh_button.clicked.connect(
lambda _checked=False: self.refresh_editable_candidates(show_info=True)
)
self.editable_deep_scan_button = QPushButton("深度扫描")
help_tip(self.editable_deep_scan_button, "扫描更多候选对象,结果更全但更慢。默认列表找不到目标时再用。")
self.editable_deep_scan_button.clicked.connect(
@@ -818,11 +972,16 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
editable_layout.addWidget(self.editable_table)
panel_layout.addWidget(editable_box)
if ENABLE_CYLINDER_CANDIDATES_PANEL:
candidate_box = QGroupBox("圆柱面候选")
candidate_box.setObjectName("candidateSection")
help_tip(candidate_box, "列出圆柱面候选,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。")
candidate_layout = QVBoxLayout(candidate_box)
filter_layout = QHBoxLayout()
self.cylinders_button = QPushButton("扫描圆柱面")
help_tip(self.cylinders_button, "扫描模型里的圆柱面候选,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。")
self.cylinders_button.clicked.connect(self.list_cylinders)
filter_layout.addWidget(self.cylinders_button)
filter_layout.addWidget(QLabel("类型"))
self.candidate_filter_combo = NoWheelComboBox()
self.candidate_filter_combo.addItems(
@@ -845,7 +1004,7 @@ 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", "零件"]
)
self.cylinder_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.cylinder_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
@@ -857,6 +1016,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
candidate_layout.addWidget(self.cylinder_table)
panel_layout.addWidget(candidate_box)
if ENABLE_HISTORY_PANEL:
history_box = QGroupBox("操作历史")
history_box.setObjectName("historySection")
help_tip(history_box, "查看已经成功执行的编辑、差异预览,并导出报告或 JSON 历史。")
@@ -867,6 +1027,12 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.history_list.currentRowChanged.connect(self.on_history_row_changed)
self.history_list.currentRowChanged.connect(lambda _row: self._update_action_states())
history_buttons = QHBoxLayout()
self.undo_button = QPushButton("撤销")
help_tip(self.undo_button, "撤销上一次成功编辑,把模型恢复到编辑前快照。")
self.undo_button.clicked.connect(self.undo_edit)
self.redo_button = QPushButton("重做")
help_tip(self.redo_button, "重做刚刚撤销的编辑。")
self.redo_button.clicked.connect(self.redo_edit)
self.clear_diff_button = QPushButton("清除差异预览")
help_tip(self.clear_diff_button, "关闭历史记录产生的红/绿差异叠加和热力图显示。不会修改模型。")
self.clear_diff_button.clicked.connect(lambda _checked=False: self.clear_diff_preview())
@@ -877,6 +1043,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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(self.undo_button)
history_buttons.addWidget(self.redo_button)
history_buttons.addWidget(self.clear_diff_button)
history_buttons.addWidget(self.export_diff_button)
history_buttons.addWidget(self.export_history_button)
@@ -889,7 +1057,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
info_layout = QVBoxLayout(info_box)
info_buttons = QHBoxLayout()
self.copy_id_button = QPushButton("复制 ID")
help_tip(self.copy_id_button, "复制当前选中对象的 ID。Face/Feature 会优先复制逻辑 Face ID。")
help_tip(self.copy_id_button, "复制当前选中对象的 ID。Face/特征会优先复制逻辑 Face ID。")
self.copy_id_button.clicked.connect(self.copy_selected_id)
self.copy_pick_button = QPushButton("复制坐标")
help_tip(self.copy_pick_button, "复制最近一次鼠标点到模型上的三维坐标。")
+1 -1
View File
@@ -80,7 +80,7 @@ class ExportMixin:
part = self.part_by_id(target_id)
if part is None:
raise ValueError(f"Unknown part id {target_id}")
info = _shape_quality_info(f"零件 {part.id}: {part.name}", part.shape, expect_solid=True)
info = _shape_quality_info(f"特征 {part.id}: {part.name}", part.shape, expect_solid=True)
info["part_id"] = part.id
return info
if scope == "solid":
+1 -1
View File
@@ -986,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"],
+16 -5
View File
@@ -197,7 +197,7 @@ def _shape_quality_info(label: str, shape: TopoDS_Shape, expect_solid: bool) ->
elif expect_solid and solids > 1:
warnings.append(
f"检测到 {solids} 个Solid。"
"如果这不是有意的多实体零件,导出后可能看起来像多个体叠在一起或彼此分离。"
"如果这不是有意的多实体特征,导出后可能看起来像多个体叠在一起或彼此分离。"
)
geometry_info = _shape_volume_info(shape)
@@ -560,7 +560,8 @@ def _axis_aligned_edge_candidates(
def _finalize_boolean_result(op, operation_name: str) -> TopoDS_Shape:
op.SetNonDestructive(True)
if hasattr(op, "SetGlue"):
use_glue = "cut" not in operation_name.lower()
if use_glue and hasattr(op, "SetGlue"):
try:
op.SetGlue(BOPAlgo_GlueFull)
except Exception:
@@ -764,7 +765,14 @@ def _cylinder_resize_readiness(
else:
diameter_delta = abs(new_diameter - current_diameter)
delta_ratio = diameter_delta / max(current_diameter, 1e-9)
if delta_ratio > 1.0:
if new_diameter > current_diameter * 2.0:
status = "blocked"
risk = "blocked"
blockers.append(
"目标直径超过当前直径的 2 倍;当前受限 B-Rep 孔径编辑会直接阻止这种极端放大,"
"否则很容易出现布尔返回成功但孔壁没有真正变成目标直径。"
)
elif delta_ratio > 1.0:
risk = _max_risk(risk, "high")
warnings.append("目标直径变化超过当前直径的 100%,很可能导致大范围误切或布尔失败。")
elif delta_ratio > 0.35:
@@ -772,8 +780,11 @@ def _cylinder_resize_readiness(
warnings.append("目标直径变化超过当前直径的 35%,请确认预览范围。")
if height_estimate > 0 and new_diameter > height_estimate * 2.0:
risk = _max_risk(risk, "high")
warnings.append("目标直径超过圆柱面估算高度的 2 倍,几何比例异常。")
status = "blocked"
risk = "blocked"
blockers.append(
"目标直径超过圆柱面估算高度的 2 倍;当前版本无法稳定判断周边材料余量,已阻止。"
)
elif height_estimate > 0 and new_diameter > height_estimate:
risk = _max_risk(risk, "medium")
warnings.append("目标直径超过圆柱面估算高度,可能不是常规孔径修改。")
+6 -2
View File
@@ -27,7 +27,9 @@ 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"):
if hasattr(self, "_refresh_property_editor"):
self._refresh_property_editor()
if hasattr(self, "mode_combo"):
self._update_action_states()
def set_plain_info(self, text: str) -> None:
@@ -36,7 +38,9 @@ class InfoPanelMixin:
self.info_tree.clear()
self.info_text.setPlainText(text)
self.info_tabs.setCurrentWidget(self.info_text)
if hasattr(self, "export_all_button"):
if hasattr(self, "_clear_property_editor"):
self._clear_property_editor()
if hasattr(self, "mode_combo"):
self._update_action_states()
def _populate_info_tree(self, info: dict[str, object]) -> None:
+36 -1
View File
@@ -9,7 +9,8 @@ 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_Transform
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeVertex, BRepBuilderAPI_Transform
from OCC.Core.BRepExtrema import BRepExtrema_DistShapeShape
from OCC.Core.BRepCheck import BRepCheck_Analyzer
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
@@ -948,6 +949,40 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
best_edge_id = edge_id
return best_edge_id if best_edge_id is not None else valid_edge_ids[0]
def nearest_face_id_to_point(
self,
face_ids: Iterable[int],
point: tuple[float, float, float] | None,
) -> int | None:
valid_face_ids = [int(face_id) for face_id in face_ids if 0 <= int(face_id) < len(self.faces)]
if not valid_face_ids:
return None
if point is None:
return valid_face_ids[0]
point_vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(float(point[0]), float(point[1]), float(point[2]))).Vertex()
best_face_id: int | None = None
best_distance = math.inf
for face_id in valid_face_ids:
try:
extrema = BRepExtrema_DistShapeShape(point_vertex, self.faces[face_id])
extrema.Perform()
if not extrema.IsDone():
continue
distance = float(extrema.Value())
except Exception:
center = _surface_center(self.faces[face_id])
distance = math.sqrt(
_point_distance_sq(
(float(point[0]), float(point[1]), float(point[2])),
_point_tuple(center),
)
)
if distance < best_distance:
best_distance = distance
best_face_id = face_id
return best_face_id if best_face_id is not None else valid_face_ids[0]
def _adjacent_face_ids_for_edges(self, edge_ids: list[int], face_id: int) -> list[int]:
if not edge_ids:
return []
+100 -14
View File
@@ -106,18 +106,18 @@ class OperationMixin:
def repair_part(self, part_id: int) -> str:
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
raise ValueError(f"未知零件 ID {part_id}")
if part.shape.IsNull():
raise RuntimeError(f"Part {part_id} has a null shape and cannot be repaired.")
raise RuntimeError(f"零件 {part_id} 为空 shape,无法修复。")
before_stats = self.part_topology_stats(part_id)
repaired = _prepare_shape_for_step_export(part.shape)
if repaired.IsNull():
raise RuntimeError(f"Part {part_id} repair returned a null shape.")
raise RuntimeError(f"零件 {part_id} 修复结果为空 shape")
part.shape = repaired
self.refresh_topology()
after_stats = self.part_topology_stats(part_id)
return (
f"Part repair completed: part {part_id}, "
f"零件修复完成: 零件 {part_id}, "
f"solids {before_stats.solids}->{after_stats.solids}, "
f"faces {before_stats.faces}->{after_stats.faces}, "
f"edges {before_stats.edges}->{after_stats.edges}."
@@ -180,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,Edge倒圆会作用在整个Part shape 上,请导出前检查结果。",
f"当前零件包含 {part_stats.solids} 个Solid,Edge倒圆会作用在整个零件 shape 上,请导出前检查结果。",
)
readiness["fillet_note"] = _join_nonempty(readiness["fillet_note"], readiness["fillet_warnings"])
@@ -224,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,Edge倒角会作用在整个Part shape 上,请导出前检查结果。",
f"当前零件包含 {part_stats.solids} 个Solid,Edge倒角会作用在整个零件 shape 上,请导出前检查结果。",
)
readiness["chamfer_note"] = _join_nonempty(readiness["chamfer_note"], readiness["chamfer_warnings"])
@@ -479,7 +479,7 @@ class OperationMixin:
part_id = int(edge_info.get("part_id", -1))
part = self.part_by_id(part_id)
if part is None:
return None, "局部边形变不可用:找不到所属Part"
return None, "局部边形变不可用:找不到所属零件"
solid = self.solids[solid_id][1]
solid_faces = _explore(solid, TopAbs_FACE)
if not solid_faces:
@@ -760,7 +760,7 @@ class OperationMixin:
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"
return None, "平面曲线径向缩放不可用:找不到所属零件"
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)
@@ -1634,7 +1634,7 @@ class OperationMixin:
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"
scope = f"零件 {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:
@@ -2246,6 +2246,8 @@ class OperationMixin:
return None
if face_id < 0 or face_id >= len(self.faces):
return None
if len(_explore(self.faces[face_id], TopAbs_WIRE)) > 1:
return None
cap_center = _surface_center(self.faces[face_id])
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
@@ -2256,7 +2258,7 @@ class OperationMixin:
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
outward_dir = gp_Dir(float(outward[0]), float(outward[1]), float(outward[2]))
best: tuple[float, dict[str, object]] | None = None
candidates: list[tuple[float, dict[str, object]]] = []
for adjacent_id in adjacent_face_ids:
side_surf = BRepAdaptor_Surface(self.faces[adjacent_id])
@@ -2312,10 +2314,18 @@ class OperationMixin:
"start_parameter": new_min,
"end_parameter": new_max,
}
if best is None or score < best[0]:
best = (score, candidate)
candidates.append((score, candidate))
return best[1] if best is not None else None
if not candidates:
return None
distinct_radii: list[float] = []
for _score, candidate in candidates:
radius = float(candidate["radius"])
if not any(abs(radius - existing) <= max(radius, existing, 1.0) * 1e-5 for existing in distinct_radii):
distinct_radii.append(radius)
if len(distinct_radii) > 1:
return None
return min(candidates, key=lambda item: item[0])[1]
def resize_existing_fillet(self, face_id: int, target_radius: float) -> str:
plan = self.existing_fillet_resize_plan(face_id, target_radius)
@@ -2441,6 +2451,7 @@ class OperationMixin:
raise ValueError(f"Unknown part id {part_id}")
direction = cyl.Axis().Direction()
old_part_shape = part.shape
source_shape = part.shape
if plan["resize_mode"] == "shrink":
fill_start = gp_Pnt(*plan["fill_start_point"])
@@ -2461,14 +2472,89 @@ class OperationMixin:
result = _finalize_boolean_result(op, "cylinder cut")
part.shape = result
self.refresh_topology()
verification = self._verify_cylindrical_resize_result(plan, part_id)
if not verification["matched"]:
part.shape = old_part_shape
self.refresh_topology()
detail = str(verification.get("detail", ""))
raise RuntimeError(
"孔/圆柱直径布尔计算返回了结果,但结果里没有检测到目标直径的圆柱面,"
"已回滚到修改前状态。"
f"{detail}"
)
action = "enlarged by bounded cut" if plan["resize_mode"] == "enlarge" else "shrunk by fill and recut"
return (
f"Cylindrical resize completed: diameter {old_radius * 2.0:g} -> {new_diameter:g}, "
f"mode={plan['resize_mode']}, action={action}, "
f"risk={plan['risk']}, feature={plan['feature_guess']}, "
f"cutter={plan['cutter_strategy']}, height={float(plan['cutter_height']):g}."
f"cutter={plan['cutter_strategy']}, height={float(plan['cutter_height']):g}, "
f"verified_face={verification.get('face_id', '')}."
)
def _verify_cylindrical_resize_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]:
target_diameter = float(plan.get("target_diameter", 0.0))
target_radius = target_diameter / 2.0
if target_radius <= 1e-9:
return {"matched": False, "detail": " 目标直径无效。"}
try:
axis_point = gp_Pnt(*plan["cutter_axis_point"])
axis_direction = gp_Dir(*plan["cutter_axis_direction"])
except Exception:
return {"matched": False, "detail": " 缺少原孔轴信息,无法确认结果。"}
part = self.part_by_id(part_id)
diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_radius, 1.0)
radius_tolerance = max(target_radius * 0.02, diagonal * 1e-6, 1e-5)
axis_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4)
best: dict[str, object] | None = None
best_score = math.inf
for face_id, face in enumerate(self.faces):
if self.face_part_ids[face_id] != part_id:
continue
try:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
continue
cyl = surf.Cylinder()
candidate_radius = float(cyl.Radius())
radius_error = abs(candidate_radius - target_radius)
axis_dot = abs(_direction_dot(axis_direction, cyl.Axis().Direction()))
if axis_dot < 1.0 - 1e-5:
continue
axis_distance = _point_axis_distance(axis_point, axis_direction, cyl.Axis().Location())
except Exception:
continue
score = radius_error / max(radius_tolerance, 1e-9) + axis_distance / max(axis_tolerance, 1e-9)
candidate = {
"matched": radius_error <= radius_tolerance and axis_distance <= axis_tolerance,
"face_id": face_id,
"diameter": candidate_radius * 2.0,
"radius_error": radius_error,
"axis_distance": axis_distance,
"radius_tolerance": radius_tolerance,
"axis_tolerance": axis_tolerance,
}
if candidate["matched"]:
return candidate
if score < best_score:
best_score = score
best = candidate
if best is None:
return {"matched": False, "detail": " 未找到同零件内与原孔轴平行的圆柱面。"}
return {
"matched": False,
"detail": (
f" 最近候选 Face {best['face_id']} 的直径约 {float(best['diameter']):.6g}"
f"目标直径 {target_diameter:.6g}"
f"半径误差 {float(best['radius_error']):.6g}"
f"轴距 {float(best['axis_distance']):.6g}"
),
**best,
}
def resize_cylindrical_boss(self, face_id: int, new_diameter: float) -> str:
plan = self.cylindrical_boss_resize_plan(face_id, new_diameter)
if plan["status"] == "blocked":
+6 -6
View File
@@ -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, "当前零件只有一个Solid,平移Solid实际会移动整个零件 shape。")
if risk == "low":
risk = "medium"
status = "caution"
@@ -119,12 +119,12 @@ class TransformMixin:
raise ValueError(str(plan["message"]))
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
raise ValueError(f"未知零件 ID {part_id}")
part.shape = _translated_shape_by_vector(part.shape, vector)
_ensure_valid_shape(part.shape)
self.refresh_topology()
return (
f"Part translated: part {part_id}, vector={_format_tuple(vector)}, "
f"零件已平移: 零件 {part_id}, vector={_format_tuple(vector)}, "
f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}."
)
@@ -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, "当前零件只有一个Solid,旋转Solid实际会旋转整个零件 shape。")
if risk == "low":
risk = "medium"
status = "caution"
@@ -218,12 +218,12 @@ class TransformMixin:
raise ValueError(str(plan["message"]))
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
raise ValueError(f"未知零件 ID {part_id}")
part.shape = _rotated_shape(part.shape, str(plan["rotation_axis"]), float(plan["rotation_angle_degrees"]), plan["rotation_center"])
_ensure_valid_shape(part.shape)
self.refresh_topology()
return (
f"Part rotated: part {part_id}, axis={plan['rotation_axis']}, "
f"零件已旋转: 零件 {part_id}, axis={plan['rotation_axis']}, "
f"angle={float(plan['rotation_angle_degrees']):g}, center={_format_tuple(plan['rotation_center'])}, "
f"risk={plan['risk']}."
)
+22 -2
View File
@@ -358,7 +358,7 @@ INFO_LABELS = {
"name": "名称",
"path": "层级路径",
"file": "文件",
"part_id": "Part ID",
"part_id": "零件 ID",
"solid_id": "Solid ID",
"logical_face_id": "逻辑 Face ID",
"face_region_logical_id": "面区域逻辑 ID",
@@ -694,7 +694,7 @@ INFO_LABELS = {
"translate_warnings": "平移警告",
"translate_blockers": "平移阻止原因",
"translate_note": "平移说明",
"part_solid_count": "Part 内 Solid 数",
"part_solid_count": "零件内 Solid 数",
"rotate_status": "旋转状态",
"rotate_risk": "旋转风险",
"rotate_warnings": "旋转警告",
@@ -713,6 +713,26 @@ EDITABLE_ACTION_ROLE = Qt.UserRole + 1
EDITABLE_TARGET_KIND_ROLE = Qt.UserRole + 2
SELECTION_MODE_LABELS = {
"Part": "零件",
"Solid": "Solid",
"Face": "Face",
"Edge": "Edge",
"Feature": "特征",
}
SELECTION_MODE_VALUES = {label: mode for mode, label in SELECTION_MODE_LABELS.items()}
def _selection_mode_label(mode: object) -> str:
mode_text = str(mode or "")
return SELECTION_MODE_LABELS.get(mode_text, mode_text or "Face")
def _selection_mode_value(mode_or_label: object) -> str:
mode_text = str(mode_or_label or "")
return SELECTION_MODE_VALUES.get(mode_text, mode_text or "Face")
def _format_float(value: float) -> str:
return f"{value:.6g}"
+418 -199
View File
@@ -105,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 或特征模式并选择一个 Face。")
return
if not self._confirm_export_quality("face", self.selected_face_id):
return
@@ -128,7 +128,7 @@ class WindowActionMixin:
if self._edit_busy("请等待当前编辑完成后再导出。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择特征", "请先切换到 Feature 模式并选择一个局部特征。")
QMessageBox.information(self, "未选择特征", "请先切换到特征模式并选择一个特征。")
return
if not self._confirm_export_quality("feature", self.selected_face_id):
return
@@ -281,7 +281,7 @@ class WindowActionMixin:
self._run_edit_action(
action,
operation_name="修复选中零件",
target=f"Part {part_id}",
target=f"零件 {part_id}",
parameters={
"scope": "part",
"part_id": part_id,
@@ -292,7 +292,7 @@ class WindowActionMixin:
)
return
QMessageBox.information(self, "未选择对象", "请先选择 Part、Solid、Face 或 Edge。")
QMessageBox.information(self, "未选择对象", "请先选择零件、Solid、Face 或 Edge。")
self.statusBar().showMessage("未选择可修复对象")
def push_pull_face(self) -> None:
@@ -436,6 +436,203 @@ class WindowActionMixin:
target_id=face_id,
)
@staticmethod
def _max_quick_risk(current: str, candidate: str) -> str:
order = {"low": 0, "medium": 1, "high": 2, "blocked": 3}
return current if order.get(current, 0) >= order.get(candidate, 0) else candidate
def _selected_face_info_snapshot(self) -> dict[str, object]:
info = dict(getattr(self, "current_info_values", {}) or {})
if self.model is None or self.selected_face_id is None:
return info
selected_face_id = int(self.selected_face_id)
possible_ids = (
info.get("face_id"),
info.get("topological_face_id"),
info.get("feature_source_face_id"),
info.get("face_region_source_face_id"),
)
has_selected_id = False
for value in possible_ids:
try:
if value is not None and int(value) == selected_face_id:
has_selected_id = True
break
except (TypeError, ValueError):
pass
has_any_face_id = any(value is not None for value in possible_ids)
if info and has_any_face_id and not has_selected_id:
info = {}
if info and str(info.get("kind", "")) in {"edge", "part", "solid"} and not has_selected_id:
info = {}
if "surface" not in info and "diameter" not in info:
try:
info = dict(self.model.face_info(selected_face_id))
except Exception:
pass
return info
def _quick_cylinder_resize_plan(
self,
face_id: int,
target_diameter: float,
info: dict[str, object] | None = None,
) -> dict[str, object]:
info = dict(info or self._selected_face_info_snapshot())
warnings: list[str] = []
blockers: list[str] = []
risk = "low"
status = "ready"
surface = str(info.get("surface", ""))
current_diameter = _float_or_none(info.get("diameter"))
height_estimate = _float_or_none(info.get("height_estimate"))
if height_estimate is None:
height_estimate = _float_or_none(info.get("same_domain_height_estimate"))
if height_estimate is None:
height_estimate = _float_or_none(info.get("hole_depth_estimate"))
guess = str(info.get("feature_guess", "cylindrical face"))
confidence = str(info.get("confidence", "low"))
angular_span = _float_or_none(info.get("angular_span"))
if surface != "cylinder" or current_diameter is None or current_diameter <= 1e-9:
blockers.append("当前选中对象不是可识别的圆柱面,不能调整孔/槽直径。")
if target_diameter <= 0:
blockers.append("目标直径必须大于 0。")
if guess == "round/fillet candidate":
risk = self._max_quick_risk(risk, "high")
warnings.append("当前圆柱面更像圆角/倒圆,调整孔径可能误切圆角。")
elif guess == "boss/outer-round candidate":
risk = self._max_quick_risk(risk, "high")
warnings.append("当前圆柱面更像凸台或外圆,调整孔径可能切掉外部结构。")
elif guess != "hole/groove candidate":
risk = self._max_quick_risk(risk, "high")
warnings.append("当前圆柱面还没有被稳定识别为孔/槽候选。")
if guess == "hole/groove candidate" and confidence == "low":
risk = self._max_quick_risk(risk, "medium")
warnings.append("孔/槽判断置信度较低。")
if (
guess == "hole/groove candidate"
and angular_span is not None
and angular_span < math.tau * 0.92
):
risk = self._max_quick_risk(risk, "medium")
warnings.append("这是局部圆柱面,更像槽或半孔,不是完整圆孔。")
delta_diameter = None
delta_ratio = None
target_to_height_ratio = None
resize_mode = "unknown"
if current_diameter is not None and current_diameter > 1e-9:
delta_diameter = target_diameter - current_diameter
delta_ratio = abs(delta_diameter) / current_diameter
resize_mode = "expand" if delta_diameter > 0 else "shrink"
if abs(delta_diameter) <= max(current_diameter * 1e-5, 1e-6):
blockers.append("目标直径与当前直径几乎相同,不需要修改。")
elif target_diameter > current_diameter * 2.0:
blockers.append(
"目标直径超过当前直径的 2 倍;当前版本会阻止这种极端放大,避免布尔计算长时间卡死。"
)
elif delta_ratio > 1.0:
risk = self._max_quick_risk(risk, "high")
warnings.append("目标直径变化超过当前直径的 100%,很可能导致大范围误切或布尔失败。")
elif delta_ratio > 0.35:
risk = self._max_quick_risk(risk, "medium")
warnings.append("目标直径变化超过当前直径的 35%")
if target_diameter < current_diameter:
if guess != "hole/groove candidate":
blockers.append("缩小孔径当前版本只支持明确的孔/槽候选。")
else:
risk = self._max_quick_risk(risk, "high")
warnings.append("缩小孔径需要先补料再重切,属于高风险实验路径。")
if height_estimate is not None and height_estimate > 1e-9:
target_to_height_ratio = target_diameter / height_estimate
if target_diameter > height_estimate * 2.0:
blockers.append("目标直径超过圆柱面估算高度的 2 倍,当前版本暂不放行。")
elif target_diameter > height_estimate:
risk = self._max_quick_risk(risk, "medium")
warnings.append("目标直径大于圆柱面估算高度,请确认单位和目标。")
if blockers:
status = "blocked"
risk = "blocked"
elif risk in {"medium", "high"}:
status = "caution"
message = "".join(blockers + warnings) if blockers or warnings else "可以尝试调整孔/槽直径。"
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"),
"current_diameter": current_diameter,
"target_diameter": target_diameter,
"delta_diameter": delta_diameter,
"diameter_delta_ratio": delta_ratio,
"target_to_height_ratio": target_to_height_ratio,
"resize_mode": resize_mode,
"feature_type": info.get("feature_type"),
"feature_guess": guess,
"confidence": confidence,
"angular_span": angular_span,
"height_estimate": height_estimate,
"material_vote_summary": info.get("material_vote_summary"),
"cylinder_end_type": info.get("cylinder_end_type"),
"hole_depth_estimate": info.get("hole_depth_estimate"),
"feature_bottom_face_ids": info.get("feature_bottom_face_ids"),
"feature_opening_face_ids": info.get("feature_opening_face_ids"),
"quick_preflight": True,
}
def _confirm_quick_edit_plan(
self,
title: str,
plan: dict[str, object],
lines: list[str],
blocked_status: str,
cancelled_status: str,
) -> bool:
if plan.get("status") == "blocked":
QMessageBox.information(self, f"不能{title}", str(plan.get("message", "")))
self.statusBar().showMessage(blocked_status)
return False
if self._block_unisolated_high_risk_operation(f"已阻止高风险{title}", plan):
return False
if plan.get("risk") == "low":
return True
warnings = str(plan.get("warnings", ""))
warnings_line = f"\n警告: {warnings}\n" if warnings else ""
result = QMessageBox.question(
self,
f"确认{title}",
(
"\n".join(lines)
+ f"\n风险: {plan.get('risk')}\n"
+ warnings_line
+ f"\n{plan.get('message', '')}\n\n"
"为避免复杂 STEP 在界面线程卡死,本次不会先生成红/绿预览;"
"详细几何方案会进入后台计算,完成后自动刷新模型。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage(cancelled_status)
return False
return True
def resize_hole(self) -> None:
if self.model is None:
return
@@ -449,64 +646,29 @@ class WindowActionMixin:
except ValueError:
QMessageBox.critical(self, "直径无效", "请输入数字形式的目标直径。")
return
info = self.model.face_info(self.selected_face_id)
if "diameter" not in info:
QMessageBox.information(self, "不是圆柱面", "当前选中的 Face 不是圆柱面,不能调整圆柱孔径。")
return
plan = self.model.cylindrical_resize_plan(self.selected_face_id, diameter)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能调整孔径", str(plan["message"]))
self.statusBar().showMessage("圆柱孔径调整已阻止")
return
guess = str(info.get("feature_guess", "cylindrical face"))
if plan["risk"] != "low":
target_to_height_ratio = plan.get("target_to_height_ratio", "")
target_to_height_line = (
""
if target_to_height_ratio is None or target_to_height_ratio == ""
else f"目标直径/估算高度: {_format_value(target_to_height_ratio)}\n"
)
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['current_diameter'])}\n"
f"目标直径: {_format_value(plan['target_diameter'])}\n"
f"直径变化量: {_format_value(plan['delta_diameter'])}\n"
f"直径变化比例: {_format_percent(plan['diameter_delta_ratio'])}\n"
f"{target_to_height_line}"
f"调整模式: {plan['resize_mode']}\n"
f"候选判断: {plan['feature_guess']}\n"
f"置信度: {plan['confidence']}\n"
f"材料投票: {plan['material_vote_summary']}\n"
f"端部类型: {plan['cylinder_end_type']}\n"
f"深度估算: {_format_value(plan['hole_depth_estimate'])}\n"
f"疑似底面 Face: {_format_value(plan.get('feature_bottom_face_ids', ''))}\n"
f"开口端相邻 Face: {_format_value(plan.get('feature_opening_face_ids', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"Cutter: {plan['cutter_strategy']}\n"
f"Cutter 高度: {_format_value(plan['cutter_height'])}\n"
f"Cutter 起点/终点余量: {_format_value(plan['cutter_start_margin'])} / "
f"{_format_value(plan['cutter_end_margin'])}\n\n"
f"底面保护: {_format_value(plan.get('cutter_bottom_protection', ''))}\n"
f"{_format_value(plan.get('cutter_bottom_note', ''))}\n\n"
f"补料策略: {plan.get('fill_strategy', '')}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会对当前零件执行受限B-Rep布尔修改。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消圆柱孔径调整")
return
face_id = self.selected_face_id
self._show_cylinder_resize_preview(face_id, diameter)
info = self._selected_face_info_snapshot()
plan = self._quick_cylinder_resize_plan(face_id, diameter, info)
lines = [
f"Face: {face_id}",
f"当前直径: {_format_value(plan.get('current_diameter'))}",
f"目标直径: {_format_value(plan.get('target_diameter'))}",
f"直径变化量: {_format_value(plan.get('delta_diameter'))}",
f"直径变化比例: {_format_percent(plan.get('diameter_delta_ratio'))}",
f"候选判断: {plan.get('feature_guess')}",
f"置信度: {plan.get('confidence')}",
]
if plan.get("target_to_height_ratio") not in {None, ""}:
lines.append(f"目标直径/估算高度: {_format_value(plan.get('target_to_height_ratio'))}")
if not self._confirm_quick_edit_plan(
"圆柱孔径调整",
plan,
lines,
"圆柱孔径调整已阻止",
"已取消圆柱孔径调整",
):
return
self.clear_edit_preview(render=False)
def action():
return self.model.resize_cylindrical_hole(face_id, diameter)
@@ -519,7 +681,7 @@ class WindowActionMixin:
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"new_diameter": diameter,
"old_diameter": info.get("diameter"),
"old_diameter": plan.get("current_diameter"),
"delta_diameter": plan.get("delta_diameter"),
"diameter_delta_ratio": plan.get("diameter_delta_ratio"),
"target_to_height_ratio": plan.get("target_to_height_ratio"),
@@ -527,8 +689,7 @@ class WindowActionMixin:
"feature_type": plan.get("feature_type"),
"feature_bottom_face_ids": plan.get("feature_bottom_face_ids"),
"feature_opening_face_ids": plan.get("feature_opening_face_ids"),
"feature_bottom_note": plan.get("feature_bottom_note"),
"feature_guess": guess,
"feature_guess": plan.get("feature_guess"),
"confidence": plan.get("confidence"),
"resize_status": plan.get("status"),
"resize_risk": plan.get("risk"),
@@ -538,23 +699,8 @@ class WindowActionMixin:
"material_vote_summary": plan.get("material_vote_summary"),
"cylinder_end_type": plan.get("cylinder_end_type"),
"hole_depth_estimate": plan.get("hole_depth_estimate"),
"start_end_state": plan.get("start_end_state"),
"end_end_state": plan.get("end_end_state"),
"cutter_strategy": plan.get("cutter_strategy"),
"cutter_height": plan.get("cutter_height"),
"cutter_margin": plan.get("cutter_margin"),
"cutter_start_margin": plan.get("cutter_start_margin"),
"cutter_end_margin": plan.get("cutter_end_margin"),
"cutter_bottom_protection": plan.get("cutter_bottom_protection"),
"cutter_protected_bottom_face_ids": plan.get("cutter_protected_bottom_face_ids"),
"cutter_opening_face_ids": plan.get("cutter_opening_face_ids"),
"cutter_bottom_note": plan.get("cutter_bottom_note"),
"cutter_note": plan.get("cutter_note"),
"fill_strategy": plan.get("fill_strategy"),
"fill_height": plan.get("fill_height"),
"fill_radius": plan.get("fill_radius"),
"fill_radius_overlap": plan.get("fill_radius_overlap"),
"fill_note": plan.get("fill_note"),
"quick_preflight": True,
"ui_preview": "skipped-to-avoid-ui-freeze",
},
target_kind="face",
target_id=face_id,
@@ -580,7 +726,7 @@ class WindowActionMixin:
return
face_id = self.selected_face_id
info = self.model.feature_info(face_id)
info = self._selected_face_info_snapshot()
if info.get("surface") != "cylinder" or info.get("slot_kind") != "partial-cylindrical-groove":
QMessageBox.information(self, "不是槽/半孔候选", "当前选中Face没有被识别为槽/半孔候选。")
return
@@ -597,37 +743,24 @@ class WindowActionMixin:
target_diameter = target_width / sin_half_span
current_width = _float_or_none(info.get("slot_chord_width_estimate"))
plan = self.model.cylindrical_resize_plan(face_id, target_diameter)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能调整槽/半孔宽度", str(plan["message"]))
self.statusBar().showMessage("槽/半孔宽度调整已阻止")
plan = self._quick_cylinder_resize_plan(face_id, target_diameter, info)
lines = [
f"Face: {face_id}",
f"当前槽宽估算: {_format_value(current_width)}",
f"目标槽宽: {_format_value(target_width)}",
f"槽圆弧角度: {_format_value(angular_span)}",
f"换算目标圆柱直径: {_format_value(target_diameter)}",
"当前版本会把槽宽换算成圆柱直径后执行孔/槽重建,不是完整 CAD 槽参数编辑。",
]
if not self._confirm_quick_edit_plan(
"槽/半孔宽度调整",
plan,
lines,
"槽/半孔宽度调整已阻止",
"已取消槽/半孔宽度调整",
):
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: {face_id}\n"
f"当前槽宽估算: {_format_value(current_width)}\n"
f"目标槽宽: {_format_value(target_width)}\n"
f"槽圆弧角度: {_format_value(angular_span)}\n"
f"换算目标圆柱直径: {_format_value(target_diameter)}\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_cylinder_resize_preview(face_id, target_diameter)
self.clear_edit_preview(render=False)
def action():
return self.model.resize_cylindrical_hole(face_id, target_diameter)
@@ -644,7 +777,7 @@ class WindowActionMixin:
"slot_width_delta": None if current_width is None else target_width - current_width,
"slot_angular_span": angular_span,
"derived_new_diameter": target_diameter,
"old_diameter": info.get("diameter"),
"old_diameter": plan.get("current_diameter"),
"delta_diameter": plan.get("delta_diameter"),
"diameter_delta_ratio": plan.get("diameter_delta_ratio"),
"resize_mode": plan.get("resize_mode"),
@@ -664,9 +797,8 @@ class WindowActionMixin:
"hole_depth_estimate": plan.get("hole_depth_estimate"),
"feature_bottom_face_ids": plan.get("feature_bottom_face_ids"),
"feature_opening_face_ids": plan.get("feature_opening_face_ids"),
"cutter_strategy": plan.get("cutter_strategy"),
"cutter_height": plan.get("cutter_height"),
"fill_strategy": plan.get("fill_strategy"),
"quick_preflight": True,
"ui_preview": "skipped-to-avoid-ui-freeze",
},
target_kind="face",
target_id=face_id,
@@ -723,7 +855,7 @@ class WindowActionMixin:
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会对当前零件执行受限B-Rep布尔修改。\n\n"
"继续操作会对当前零件的局部几何执行受限B-Rep布尔修改。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
@@ -877,6 +1009,111 @@ class WindowActionMixin:
raise ValueError(f"底面 Face ID {bottom_face_id} 不存在。")
return bottom_face_id
def _quick_cylinder_depth_plan(
self,
face_id: int,
target_depth: float,
bottom_face_id: int | None = None,
info: dict[str, object] | None = None,
) -> dict[str, object]:
info = dict(info or self._selected_face_info_snapshot())
warnings: list[str] = []
blockers: list[str] = []
risk = "low"
status = "ready"
surface = str(info.get("surface", ""))
guess = str(info.get("feature_guess", "cylindrical face"))
confidence = str(info.get("confidence", "low"))
angular_span = _float_or_none(info.get("angular_span"))
end_type = str(info.get("cylinder_end_type", "unknown"))
current_depth = _float_or_none(info.get("hole_depth_estimate"))
if current_depth is None:
current_depth = _float_or_none(info.get("same_domain_height_estimate"))
manual_bottom = bottom_face_id is not None
if surface != "cylinder":
blockers.append("当前选中对象不是圆柱孔/槽,不能调整孔深。")
if guess != "hole/groove candidate":
blockers.append("孔深调整当前版本只支持明确的孔/槽候选。")
if end_type != "blind" and not manual_bottom:
blockers.append("孔深调整需要识别到盲孔/盲槽底面,或手动填写底面 Face ID。")
elif end_type != "blind" and manual_bottom:
risk = self._max_quick_risk(risk, "medium")
warnings.append("端部类型不是明确 blind,当前会按手动底面 Face ID 尝试推断孔深方向。")
if current_depth is None or current_depth <= 1e-9:
blockers.append("当前圆柱面没有可靠的深度估算。")
if target_depth <= 0:
blockers.append("目标深度必须大于 0。")
if guess == "hole/groove candidate" and confidence == "low":
risk = self._max_quick_risk(risk, "medium")
warnings.append("孔/槽判断置信度较低。")
if (
guess == "hole/groove candidate"
and angular_span is not None
and angular_span < math.tau * 0.92
):
risk = self._max_quick_risk(risk, "medium")
warnings.append("这是局部圆柱面,更像槽或半孔,孔深调整会按局部槽处理。")
delta_depth = None
delta_ratio = None
depth_mode = "unknown"
if current_depth is not None and current_depth > 1e-9:
delta_depth = target_depth - current_depth
delta_ratio = abs(delta_depth) / current_depth
depth_mode = "deepen" if delta_depth > 0 else "shallow"
if abs(delta_depth) <= max(current_depth * 1e-5, 1e-6):
blockers.append("目标深度与当前深度几乎相同,不需要修改。")
elif delta_ratio > 1.0:
risk = self._max_quick_risk(risk, "high")
warnings.append("目标深度变化超过当前深度的 100%,很可能贯穿、误切或布尔失败。")
elif delta_ratio > 0.35:
risk = self._max_quick_risk(risk, "medium")
warnings.append("目标深度变化超过当前深度的 35%")
if target_depth < current_depth * 0.08:
risk = self._max_quick_risk(risk, "high")
warnings.append("目标深度非常浅,补料后可能生成很薄的局部面。")
if blockers:
status = "blocked"
risk = "blocked"
elif risk in {"medium", "high"}:
status = "caution"
message = "".join(blockers + warnings) if blockers or warnings else "可以尝试调整盲孔/盲槽深度。"
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"),
"current_depth": current_depth,
"target_depth": target_depth,
"delta_depth": delta_depth,
"depth_delta_ratio": delta_ratio,
"depth_mode": depth_mode,
"diameter": info.get("diameter"),
"radius": info.get("radius"),
"feature_type": info.get("feature_type"),
"feature_guess": guess,
"confidence": confidence,
"angular_span": angular_span,
"cylinder_end_type": end_type,
"feature_bottom_face_ids": info.get("feature_bottom_face_ids"),
"manual_bottom_face_id": "" if bottom_face_id is None else bottom_face_id,
"manual_bottom_face_used": manual_bottom,
"feature_opening_face_ids": info.get("feature_opening_face_ids"),
"feature_bottom_confidence": info.get("feature_bottom_confidence"),
"feature_bottom_detection": info.get("feature_bottom_detection"),
"feature_bottom_note": info.get("feature_bottom_note"),
"quick_preflight": True,
}
def resize_hole_depth(self) -> None:
if self.model is None:
return
@@ -896,62 +1133,30 @@ class WindowActionMixin:
QMessageBox.critical(self, "底面 Face ID 无效", str(exc))
return
info = self.model.face_info(self.selected_face_id)
if info.get("surface") != "cylinder":
QMessageBox.information(self, "不是圆柱孔/槽", "当前选中的 Face 不是可调整孔深的圆柱孔/槽。")
return
plan = self.model.cylindrical_depth_plan(
self.selected_face_id,
target_depth,
bottom_face_id=bottom_face_id,
)
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['current_depth'])}\n"
f"目标深度: {_format_value(plan['target_depth'])}\n"
f"深度变化量: {_format_value(plan['delta_depth'])}\n"
f"深度变化比例: {_format_percent(plan['depth_delta_ratio'])}\n"
f"调整模式: {plan['depth_mode']}\n"
f"候选判断: {plan['feature_guess']}\n"
f"置信度: {plan['confidence']}\n"
f"材料投票: {plan['material_vote_summary']}\n"
f"端部类型: {plan['cylinder_end_type']}\n"
f"疑似底面 Face: {_format_value(plan.get('feature_bottom_face_ids', ''))}\n"
f"手动底面 Face: {_format_value(plan.get('manual_bottom_face_id', ''))}\n"
f"底面识别来源: {_format_value(plan.get('feature_bottom_detection', ''))}\n"
f"当前深度来源: {_format_value(plan.get('depth_current_depth_source', ''))}\n"
f"开口方向来源: {_format_value(plan.get('depth_open_direction_source', ''))}\n"
f"开口端相邻 Face: {_format_value(plan.get('feature_opening_face_ids', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"工具策略: {plan['depth_tool_strategy']}\n"
f"工具类型: {plan['depth_tool_role']}\n"
f"工具高度: {_format_value(plan['depth_tool_height'])}\n"
f"工具半径: {_format_value(plan['depth_tool_radius'])}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会对当前零件执行受限B-Rep布尔修改。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消盲孔深度调整")
return
face_id = self.selected_face_id
self._show_cylinder_depth_preview(face_id, target_depth, bottom_face_id=bottom_face_id)
info = self._selected_face_info_snapshot()
plan = self._quick_cylinder_depth_plan(face_id, target_depth, bottom_face_id=bottom_face_id, info=info)
lines = [
f"Face: {face_id}",
f"当前深度: {_format_value(plan.get('current_depth'))}",
f"目标深度: {_format_value(plan.get('target_depth'))}",
f"深度变化量: {_format_value(plan.get('delta_depth'))}",
f"深度变化比例: {_format_percent(plan.get('depth_delta_ratio'))}",
f"候选判断: {plan.get('feature_guess')}",
f"置信度: {plan.get('confidence')}",
f"端部类型: {plan.get('cylinder_end_type')}",
]
if bottom_face_id is not None:
lines.append(f"手动底面 Face: {bottom_face_id}")
if not self._confirm_quick_edit_plan(
"盲孔深度调整",
plan,
lines,
"盲孔深度调整已阻止",
"已取消盲孔深度调整",
):
return
self.clear_edit_preview(render=False)
def action():
return self.model.resize_cylindrical_depth(
@@ -989,18 +1194,8 @@ class WindowActionMixin:
"feature_bottom_confidence": plan.get("feature_bottom_confidence"),
"feature_bottom_detection": plan.get("feature_bottom_detection"),
"feature_bottom_note": plan.get("feature_bottom_note"),
"depth_tool_strategy": plan.get("depth_tool_strategy"),
"depth_tool_role": plan.get("depth_tool_role"),
"depth_tool_height": plan.get("depth_tool_height"),
"depth_tool_radius": plan.get("depth_tool_radius"),
"depth_tool_radius_overlap": plan.get("depth_tool_radius_overlap"),
"depth_current_depth": plan.get("depth_current_depth"),
"depth_current_depth_source": plan.get("depth_current_depth_source"),
"depth_bottom_parameter_source": plan.get("depth_bottom_parameter_source"),
"depth_open_direction_source": plan.get("depth_open_direction_source"),
"depth_open_point": plan.get("depth_open_point"),
"depth_current_bottom_point": plan.get("depth_current_bottom_point"),
"depth_target_bottom_point": plan.get("depth_target_bottom_point"),
"quick_preflight": True,
"ui_preview": "skipped-to-avoid-ui-freeze",
},
target_kind="face",
target_id=face_id,
@@ -1135,7 +1330,7 @@ class WindowActionMixin:
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,
@@ -1212,7 +1407,7 @@ class WindowActionMixin:
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,
@@ -1546,7 +1741,7 @@ class WindowActionMixin:
if self._edit_busy("请等待当前编辑完成后再平移零件。"):
return
if self.selected_part_id is None:
QMessageBox.information(self, "未选择零件", "请先选择一个Part,或选择属于某个Part的对象。")
QMessageBox.information(self, "未选择零件", "请先选择一个零件,或选择属于某个零件的对象。")
return
try:
vector = self._translation_vector_from_inputs()
@@ -1569,7 +1764,7 @@ class WindowActionMixin:
self._run_edit_action(
action,
operation_name="平移零件",
target=f"Part {part_id}",
target=f"零件 {part_id}",
parameters={
"part_id": part_id,
"translation_vector": vector,
@@ -1652,7 +1847,7 @@ class WindowActionMixin:
title,
(
f"对象: {plan.get('target_kind', '')}\n"
f"Part: {_format_value(plan.get('part_id', ''))}\n"
f"零件: {_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"
@@ -1674,7 +1869,7 @@ class WindowActionMixin:
if self._edit_busy("请等待当前编辑完成后再旋转零件。"):
return
if self.selected_part_id is None:
QMessageBox.information(self, "未选择零件", "请先选择一个Part,或选择属于某个Part的对象。")
QMessageBox.information(self, "未选择零件", "请先选择一个零件,或选择属于某个零件的对象。")
return
try:
axis, angle = self._rotation_values_from_inputs()
@@ -1697,7 +1892,7 @@ class WindowActionMixin:
self._run_edit_action(
action,
operation_name="旋转零件",
target=f"Part {part_id}",
target=f"零件 {part_id}",
parameters={
"part_id": part_id,
"rotation_axis": plan.get("rotation_axis"),
@@ -1779,7 +1974,7 @@ class WindowActionMixin:
title,
(
f"对象: {plan.get('target_kind', '')}\n"
f"Part: {_format_value(plan.get('part_id', ''))}\n"
f"零件: {_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"
@@ -1923,6 +2118,10 @@ class WindowActionMixin:
scan_label: str,
limit: int,
) -> None:
if not hasattr(self, "editable_table"):
if show_info:
self.set_plain_info("可编辑对象面板当前未启用。")
return
self.editable_table.setRowCount(len(candidates))
lines = [f"可编辑对象:显示 {len(candidates)} 个({scan_label}上限 {limit}"]
for row, item in enumerate(candidates):
@@ -1956,7 +2155,7 @@ 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"零件={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']}, "
@@ -2006,6 +2205,10 @@ class WindowActionMixin:
self.statusBar().showMessage("圆柱面候选扫描失败")
def _filter_cached_cylinder_candidates(self, show_info: bool = False) -> None:
if not hasattr(self, "cylinder_table"):
if show_info:
self.set_plain_info("圆柱面候选面板当前未启用。")
return
if not self.cylinder_candidates_loaded:
self.cylinder_table.setRowCount(0)
if show_info:
@@ -2019,6 +2222,10 @@ class WindowActionMixin:
self._populate_cylinder_table(candidates, show_info=show_info)
def _populate_cylinder_table(self, candidates: list[dict[str, object]], show_info: bool = True) -> None:
if not hasattr(self, "cylinder_table"):
if show_info:
self.set_plain_info("圆柱面候选面板当前未启用。")
return
self.cylinder_table.setRowCount(len(candidates))
lines = [f"圆柱面候选:显示 {len(candidates)}"]
for row, item in enumerate(candidates):
@@ -2040,7 +2247,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']}: 零件 {item['part_id']}, "
f"guess={item['feature_guess']}, "
f"diameter={_format_float(float(item['diameter']))}, "
f"height={_format_float(float(item['height_estimate']))}, "
@@ -2053,6 +2260,8 @@ class WindowActionMixin:
self.set_plain_info("\n".join(lines))
def _candidate_matches_filter(self, feature_guess: str) -> bool:
if not hasattr(self, "candidate_filter_combo"):
return True
current = self.candidate_filter_combo.currentText()
if current == "All":
return True
@@ -2243,13 +2452,15 @@ class WindowActionMixin:
if "推拉" in operation_name:
progress_text = f"{operation_name} 正在后台计算,半透明预览会持续渲染,3D 视图仍可旋转查看。"
elif "孔径" in operation_name:
progress_text = f"{operation_name} 正在后台计算,红色预览表示切削范围,绿色预览表示补料范围"
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:
progress_text = f"{operation_name} 正在后台计算,绿色预览表示封堵补料范围。"
elif "孔深" in operation_name:
progress_text = f"{operation_name} 正在后台计算,红色预览表示加深切削范围,绿色预览表示变浅补料范围"
progress_text = f"{operation_name} 正在后台计算;为避免复杂模型卡顿,已跳过同步孔深预览"
elif "圆角" in operation_name:
if "已有" in operation_name:
progress_text = f"{operation_name} 正在后台计算,蓝色预览表示将移除并重建的已有圆角面。"
@@ -2276,6 +2487,9 @@ class WindowActionMixin:
@Slot(object)
def _finish_edit_action(self, result: object) -> None:
if hasattr(self, "_is_ui_thread") and not self._is_ui_thread():
self._invoke_on_ui_thread(lambda result=result: self._finish_edit_action(result))
return
context = self.pending_edit_context
if context is None:
self._end_edit_task(clear_preview=True)
@@ -2319,9 +2533,9 @@ class WindowActionMixin:
show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False))
)
self.clear_edit_preview(render=False)
self._reset_selection()
self._populate_part_tree()
self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=False)
locator_note = self._locate_operation_record(record)
except Exception as exc:
rollback_message = self._restore_failed_edit_snapshot(result.get("snapshot") if isinstance(result, dict) else None)
self._end_edit_task(clear_preview=True)
@@ -2340,11 +2554,16 @@ class WindowActionMixin:
if result.get("quality_warnings"):
self.statusBar().showMessage("编辑完成,但有质量警告,请查看操作历史详情")
else:
self.statusBar().showMessage(message)
self.set_plain_info(record.detail)
selection_note = ";已保持当前选择" if self.selected_kind is not None else ""
self.statusBar().showMessage(f"{message}{selection_note}")
if self.selected_kind is None:
self.set_plain_info(f"{record.detail}\n\n{locator_note}")
@Slot(str)
def _fail_edit_action(self, message: str) -> None:
if hasattr(self, "_is_ui_thread") and not self._is_ui_thread():
self._invoke_on_ui_thread(lambda message=message: self._fail_edit_action(message))
return
self._end_edit_task(clear_preview=True)
QMessageBox.critical(self, "操作失败", message)
self._clear_editable_candidates()
+109 -44
View File
@@ -21,7 +21,24 @@ from .ui_helpers import * # noqa: F403
from .workers import EditWorker, LoadWorker, ScanWorker
VIEW_BACKGROUND_COLOR = (226 / 255.0, 238 / 255.0, 247 / 255.0)
class WindowCoreMixin:
@Slot(object)
def _run_ui_task(self, callback) -> None:
callback()
def _is_ui_thread(self) -> bool:
return QThread.currentThread() == self.thread()
def _invoke_on_ui_thread(self, callback) -> None:
if self._is_ui_thread():
callback()
return
if hasattr(self, "ui_task_requested"):
self.ui_task_requested.emit(callback)
def eventFilter(self, watched, event):
if watched is getattr(self, "vtk_widget", None):
event_type = event.type()
@@ -37,7 +54,7 @@ class WindowCoreMixin:
def _build_vtk(self) -> None:
self.renderer = vtk.vtkRenderer()
self.renderer.SetBackground(0.11, 0.13, 0.15)
self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR)
self.render_window = self.vtk_widget.GetRenderWindow()
self.render_window.AddRenderer(self.renderer)
@@ -185,7 +202,10 @@ class WindowCoreMixin:
self._clear_history()
if hasattr(self, "measure_text"):
self.clear_measurement()
self.path_label.setText(str(self.step_path))
path_text = str(self.step_path)
self.path_label.setText(path_text)
self.path_label.setToolTip(path_text)
self.path_label.setCursorPosition(0)
self._populate_part_tree()
self._reset_selection()
model_polydata = result.get("model_polydata")
@@ -314,10 +334,13 @@ class WindowCoreMixin:
self.clear_diff_preview(render=False)
if hasattr(self, "history_list"):
self.history_list.clear()
if hasattr(self, "export_all_button"):
if hasattr(self, "mode_combo"):
self._update_action_states()
def _refresh_history_list(self) -> None:
if not hasattr(self, "history_list"):
self._update_action_states()
return
was_blocked = self.history_list.blockSignals(True)
try:
self.history_list.clear()
@@ -346,6 +369,9 @@ class WindowCoreMixin:
return
if self._edit_busy("编辑计算中,暂时不能导出差异报告。"):
return
if not hasattr(self, "history_list"):
QMessageBox.information(self, "操作历史未启用", "当前界面没有构建操作历史面板。")
return
row = self.history_list.currentRow()
if row < 0 or row >= len(self.operation_history):
QMessageBox.information(self, "未选择历史记录", "请先选择一条操作历史。")
@@ -436,6 +462,8 @@ class WindowCoreMixin:
self.set_plain_info(f"已导出编辑历史:{target}\n\n记录数:{len(records)}")
def _populate_part_tree(self) -> None:
if not hasattr(self, "part_tree"):
return
was_blocked = self.part_tree.blockSignals(True)
try:
self.part_tree.clear()
@@ -501,11 +529,11 @@ class WindowCoreMixin:
def _part_tree_part_tooltip(self, part_id: int) -> str:
if self.model is None:
return f"Part ID: {part_id}"
return f"零件 ID: {part_id}"
try:
info = self.model.part_info(part_id)
except Exception:
return f"Part ID: {part_id}"
return f"零件 ID: {part_id}"
lines = [
f"ID: {part_id}",
f"类型: {_part_tree_kind_label(str(info.get('kind', '')))}",
@@ -560,7 +588,7 @@ 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"零件 {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)
@@ -569,7 +597,7 @@ class WindowCoreMixin:
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"特征 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)
@@ -580,7 +608,7 @@ class WindowCoreMixin:
edge_ids = [self.selected_edge_id]
label = f"Edge {self.selected_edge_id}"
else:
QMessageBox.information(self, "未选择对象", "请先选择 Part、Solid、Face、Edge 或 Feature")
QMessageBox.information(self, "未选择对象", "请先选择零件、Solid、Face、Edge 或特征")
return
model_polydata = self.model.build_face_polydata(face_ids=face_ids, part_ids=part_ids)
@@ -610,7 +638,7 @@ class WindowCoreMixin:
return
bounds = self._selected_focus_bounds()
if bounds is None:
QMessageBox.information(self, "未选择对象", "请先选择 Part、Solid、Face、Edge 或 Feature")
QMessageBox.information(self, "未选择对象", "请先选择零件、Solid、Face、Edge 或特征")
return
self._fit_camera_to_bounds(bounds)
self.render_window.Render()
@@ -758,7 +786,7 @@ class WindowCoreMixin:
def _rebuild_scene_from_polydata(self, model_polydata, edge_polydata, reset_camera: bool = False) -> None:
self._clear_overlay_polydata_cache()
self.renderer.RemoveAllViewProps()
self.renderer.SetBackground(0.11, 0.13, 0.15)
self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR)
self.scene_isolated = False
self.model_polydata = _smooth_surface_polydata(model_polydata)
@@ -808,10 +836,20 @@ class WindowCoreMixin:
def _on_mode_changed(self, mode: str) -> None:
self._clear_hover(render=True)
if hasattr(self, "id_mode_display"):
self.id_mode_display.setText(mode)
if hasattr(self, "_update_id_select_title"):
self._update_id_select_title(mode)
def on_left_click(self, _obj, _event) -> None:
try:
x, y = self.interactor.GetEventPosition()
except Exception:
x, y = 0, 0
if not self._is_ui_thread():
self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._handle_left_click(x, y))
return
self._handle_left_click(int(x), int(y))
def _handle_left_click(self, x: int, y: int) -> None:
self.pointer_button_down = True
self.pending_hover_position = None
self.last_hover_pick_position = None
@@ -827,8 +865,7 @@ class WindowCoreMixin:
return
if self.model is None:
return
mode = self.mode_combo.currentText()
x, y = self.interactor.GetEventPosition()
mode = self._current_selection_mode()
target = self._pick_selection_target(mode, x, y)
if target is None:
self.statusBar().showMessage("未选中对象")
@@ -838,6 +875,12 @@ class WindowCoreMixin:
self._select_pick_target(target)
def on_pointer_button_press(self, _obj, _event) -> None:
if not self._is_ui_thread():
self._invoke_on_ui_thread(self._handle_pointer_button_press)
return
self._handle_pointer_button_press()
def _handle_pointer_button_press(self) -> None:
self.pointer_button_down = True
self.pending_hover_position = None
self.last_hover_pick_position = None
@@ -845,11 +888,27 @@ class WindowCoreMixin:
self._clear_hover(render=True)
def on_pointer_button_release(self, _obj, _event) -> None:
if not self._is_ui_thread():
self._invoke_on_ui_thread(self._handle_pointer_button_release)
return
self._handle_pointer_button_release()
def _handle_pointer_button_release(self) -> None:
self.pointer_button_down = False
self.pending_hover_position = None
self.last_hover_pick_position = None
def on_mouse_move(self, _obj, _event) -> None:
try:
x, y = self.interactor.GetEventPosition()
except Exception:
x, y = 0, 0
if not self._is_ui_thread():
self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._handle_mouse_move(x, y))
return
self._handle_mouse_move(int(x), int(y))
def _handle_mouse_move(self, x: int, y: int) -> None:
if QApplication.mouseButtons() != Qt.MouseButton.NoButton:
self.pointer_button_down = True
return
@@ -863,8 +922,7 @@ class WindowCoreMixin:
):
self._clear_hover(render=True)
return
x, y = self.interactor.GetEventPosition()
self._queue_hover_position(int(x), int(y))
self._queue_hover_position(x, y)
def _queue_hover_from_qt_event(self, event) -> None:
if (
@@ -883,6 +941,9 @@ class WindowCoreMixin:
self._queue_hover_position(x, y)
def _queue_hover_position(self, x: int, y: int) -> None:
if not self._is_ui_thread():
self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._queue_hover_position(x, y))
return
position = (int(x), int(y))
threshold = int(getattr(self, "hover_move_threshold_px", 0) or 0)
if threshold > 0:
@@ -909,7 +970,7 @@ class WindowCoreMixin:
return
x, y = self.pending_hover_position
self.last_hover_pick_position = (x, y)
target = self._pick_selection_target(self.mode_combo.currentText(), x, y)
target = self._pick_selection_target(self._current_selection_mode(), x, y)
self._show_hover_target(target)
def _pick_selection_target(self, mode: str, x: int, y: int) -> dict[str, object] | None:
@@ -1101,7 +1162,7 @@ class WindowCoreMixin:
self.select_edge(edge_id, pick_position=pick_position)
def on_part_tree_select(self, current: QTreeWidgetItem | None, _previous: QTreeWidgetItem | None) -> None:
if self._edit_busy("编辑计算中,暂时不能切换零件树选择。"):
if self._edit_busy("编辑计算中,暂时不能切换结构树选择。"):
return
if current is None:
return
@@ -1110,14 +1171,16 @@ class WindowCoreMixin:
if node_kind == "solid" and target_id is not None:
part_id = current.data(0, PART_TREE_PART_ID_ROLE)
if part_id is not None:
self.mode_combo.setCurrentText("Solid")
self._set_selection_mode("Solid")
self.select_solid(int(target_id), int(part_id))
return
if node_kind in {"part", "assembly"} and target_id is not None:
self.mode_combo.setCurrentText("Part")
self._set_selection_mode("Part")
self.select_part(int(target_id))
def on_cylinder_row_clicked(self, row: int, _column: int) -> None:
if not hasattr(self, "cylinder_table"):
return
if self._edit_busy("编辑计算中,暂时不能切换圆柱面候选。"):
return
item = self.cylinder_table.item(row, 0)
@@ -1126,10 +1189,12 @@ class WindowCoreMixin:
face_id = item.data(Qt.UserRole)
if face_id is None:
return
self.mode_combo.setCurrentText("Feature")
self._set_selection_mode("Feature")
self.select_feature(int(face_id))
def on_editable_row_clicked(self, row: int, _column: int) -> None:
if not hasattr(self, "editable_table"):
return
if self._edit_busy("编辑计算中,暂时不能切换可编辑对象。"):
return
item = self.editable_table.item(row, 0)
@@ -1141,51 +1206,51 @@ class WindowCoreMixin:
if target_id is None:
return
if action == "resize_cylinder":
self.mode_combo.setCurrentText("Feature")
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整孔径候选Face {target_id}")
elif action == "resize_boss":
self.mode_combo.setCurrentText("Feature")
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整凸台直径候选Face {target_id}")
elif action == "resize_slot_width":
self.mode_combo.setCurrentText("Feature")
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整槽/半孔宽度候选Face {target_id}")
elif action == "resize_shell_thickness":
self.mode_combo.setCurrentText("Feature")
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整薄壁厚度候选Face {target_id}")
elif action == "suppress_cylinder":
self.mode_combo.setCurrentText("Feature")
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可封堵圆柱孔Face {target_id}")
elif action == "resize_depth":
self.mode_combo.setCurrentText("Feature")
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整盲孔深度候选Face {target_id}")
elif action == "inspect_existing_fillet":
self.mode_combo.setCurrentText("Feature")
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择已有圆角/倒圆候选Face {target_id}")
elif action == "fillet_edge":
self.mode_combo.setCurrentText("Edge")
self._set_selection_mode("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可添加圆角Edge {target_id}")
elif action == "chamfer_edge":
self.mode_combo.setCurrentText("Edge")
self._set_selection_mode("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可添加倒角Edge {target_id}")
elif action == "resize_edge_length":
self.mode_combo.setCurrentText("Edge")
self._set_selection_mode("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可尝试调整长度的Edge {target_id}")
elif target_kind == "edge":
self.mode_combo.setCurrentText("Edge")
self._set_selection_mode("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择Edge {target_id}")
else:
self.mode_combo.setCurrentText("Face")
self._set_selection_mode("Face")
self.select_face(int(target_id))
self.statusBar().showMessage(f"已选择可推拉平面Face {target_id}")
@@ -1197,38 +1262,38 @@ class WindowCoreMixin:
try:
target_id = int(self.id_input.text())
except ValueError:
QMessageBox.information(self, "ID 无效", "请输入整数 ID。Part ID 从 1 开始,Solid/Face/Edge ID 从 0 开始。")
QMessageBox.information(self, "ID 无效", "请输入整数 ID。零件 ID 从 1 开始,Solid/Face/Edge ID 从 0 开始。")
return
kind = kind or self.mode_combo.currentText()
kind = _selection_mode_value(kind or self._current_selection_mode())
try:
if kind == "Part":
if self.model.part_by_id(target_id) is None:
raise ValueError(f"不存在 Part ID {target_id}")
self.mode_combo.setCurrentText("Part")
raise ValueError(f"不存在零件 ID {target_id}")
self._set_selection_mode("Part")
self.select_part(target_id)
elif kind == "Solid":
if target_id < 0 or target_id >= len(self.model.solids):
raise ValueError(f"不存在 Solid ID {target_id}")
part_id = self.model.solids[target_id][0]
self.mode_combo.setCurrentText("Solid")
self._set_selection_mode("Solid")
self.select_solid(target_id, part_id)
elif kind == "Face":
resolved_face_id = self.model.resolve_face_selection_id(target_id)
if resolved_face_id is None:
raise ValueError(f"不存在 Face/逻辑 Face ID {target_id}")
self.mode_combo.setCurrentText("Face")
self._set_selection_mode("Face")
self.select_face(resolved_face_id)
elif kind == "Feature":
resolved_face_id = self.model.resolve_face_selection_id(target_id)
if resolved_face_id is None:
raise ValueError(f"不存在 Feature 来源 Face/逻辑 Face ID {target_id}")
self.mode_combo.setCurrentText("Feature")
raise ValueError(f"不存在特征来源 Face/逻辑 Face ID {target_id}")
self._set_selection_mode("Feature")
self.select_feature(resolved_face_id)
elif kind == "Edge":
if target_id < 0 or target_id >= len(self.model.edges):
raise ValueError(f"不存在 Edge ID {target_id}")
self.mode_combo.setCurrentText("Edge")
self._set_selection_mode("Edge")
self.select_edge(target_id)
self.last_id_kind = kind
except ValueError as exc:
@@ -1333,7 +1398,7 @@ class WindowCoreMixin:
self._show_pick_marker(pick_position)
self._sync_id_picker("Feature", face_id)
self.set_info(self._with_pick_info(info, pick_position))
feature_type = str(info.get("feature_type", "局部特征候选"))
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))
@@ -1859,7 +1924,7 @@ class WindowCoreMixin:
if render and hasattr(self, "render_window"):
self.render_window.Render()
self.statusBar().showMessage("已清除差异预览")
if hasattr(self, "export_all_button"):
if hasattr(self, "mode_combo"):
self._update_action_states()
def _show_operation_diff(self, record: OperationRecord) -> str:
File diff suppressed because it is too large Load Diff