feat: 完善参数化编辑和孔修改稳定性
This commit is contained in:
@@ -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
@@ -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, "复制最近一次鼠标点到模型上的三维坐标。")
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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("目标直径超过圆柱面估算高度,可能不是常规孔径修改。")
|
||||
|
||||
@@ -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
@@ -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
@@ -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":
|
||||
|
||||
@@ -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']}."
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
+931
-39
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user