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

This commit is contained in:
2026-07-28 18:30:58 +08:00
parent 7c263d1e96
commit 720c2dc970
13 changed files with 2190 additions and 690 deletions
+53 -53
View File
@@ -1,4 +1,4 @@
# STEP 零件查看编辑原型 # STEP 零件与特征查看编辑原型
这是一个基于 Python、pythonocc-core/OCCT、VTK 和 PySide6/Qt 的 STEP 模型查看、选择、局部编辑与导出原型。 这是一个基于 Python、pythonocc-core/OCCT、VTK 和 PySide6/Qt 的 STEP 模型查看、选择、局部编辑与导出原型。
@@ -19,13 +19,13 @@
- 加载 `.step` / `.stp` 模型文件。 - 加载 `.step` / `.stp` 模型文件。
- 展示三维模型,支持旋转、缩放、平移。 - 展示三维模型,支持旋转、缩放、平移。
- 读取 STEP 中的零件、装配、Solid 等结构。 - 读取 STEP 中的零件、装配、Solid 等结构。
- 用户可以选择零件、Solid、Face、Edge 和局部几何特征。 - 用户可以选择零件、Solid、Face、Edge 和几何特征。
- 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。 - 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。
- 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。 - 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。
- 用户可以修改可控的局部特征,比如平面推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、凸台直径调整、Edge倒圆、Edge倒角和修改Edge长度。 - 用户可以修改可控的特征,比如平面推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、凸台直径调整、Edge倒圆、Edge倒角和修改Edge长度。
- 用户可以撤销/重做修改,避免一步编辑做坏。 - 用户可以撤销/重做修改,避免一步编辑做坏。
- 用户可以导出修改后的完整模型。 - 用户可以导出修改后的完整模型。
- 如果 STEP 文件里存在多个零件,用户可以只导出选中的某个零件。 - 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。
需要注意:STEP 文件通常是 B-Rep 几何数据,不是带完整建模历史的参数化 CAD 原文件。所以“随便选一条边改长度”不一定能稳定实现,实际会转化成更可靠的操作,比如移动某个面、重切某个孔、调整某个圆角等。 需要注意:STEP 文件通常是 B-Rep 几何数据,不是带完整建模历史的参数化 CAD 原文件。所以“随便选一条边改长度”不一定能稳定实现,实际会转化成更可靠的操作,比如移动某个面、重切某个孔、调整某个圆角等。
@@ -36,7 +36,7 @@
- 能打开 STEP。 - 能打开 STEP。
- 能显示模型。 - 能显示模型。
- 能读取 STEP 中的零件/装配标签。 - 能读取 STEP 中的零件/装配标签。
- 能选择 Part、Solid、Face、Edge。 - 能选择零件、Solid、Face、Edge 和特征
- 能显示选中对象的基本属性。 - 能显示选中对象的基本属性。
- 能高亮选中对象。 - 能高亮选中对象。
- 能导出整个当前模型。 - 能导出整个当前模型。
@@ -51,28 +51,28 @@
- 默认加载 `assets/models/geom_extract.step` - 默认加载 `assets/models/geom_extract.step`
- 支持打开其他 `.step` / `.stp` 文件。 - 支持打开其他 `.step` / `.stp` 文件。
- 打开 STEP 时会先在临时模型里完成读取和显示网格生成;失败时当前模型保持不变。 - 打开 STEP 时会先在临时模型里完成读取和显示网格生成;失败时当前模型保持不变。
- 使用 XCAF 读取 STEP 中的 Part / Assembly 标签。 - 使用 XCAF 读取 STEP 中的零件 / Assembly 标签;代码内部仍沿用 `PartNode` / `part_id` 作为结构名
- 模型结构树会用中文显示 STEP 里的装配、零件和实体层级,并在对应零件下面列出实体子节点。 - 模型结构树会用中文显示 STEP 里的装配、零件和实体层级,并在对应零件下面列出实体子节点。
- 点击模型结构树中的实体子节点,可以直接选中、高亮、查看属性并用于导出该实体。 - 点击模型结构树中的实体子节点,可以直接选中、高亮、查看属性并用于导出该实体。
- 使用 OCCT 读取真实 B-Rep 拓扑。 - 使用 OCCT 读取真实 B-Rep 拓扑。
- 使用 VTK 显示模型,并通过 PySide6/Qt 承载桌面界面。 - 使用 VTK 显示模型,并通过 PySide6/Qt 承载桌面界面。
- 左侧操作面板按功能模块分组显示,并使用四边同色的轻量彩色边框和标题层级区分文件、模型树、选择、编辑、导出、显示、测量、候选、历史和属性信息。 - 左侧操作面板按功能模块分组显示,并使用四边同色的轻量彩色边框和标题层级区分文件、模型树、选择、编辑、导出、显示、测量、候选、历史和属性信息。
- 支持选择模式: - 支持选择模式:
- Part - 零件
- Solid - Solid
- Face - Face
- Edge - Edge
- Feature - 特征
- 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。 - 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。
- `Face` 模式会把属于同一几何面的连续区域一起高亮;平面会按共面且投影范围相接/重叠查找,圆柱面会按同一实体内同轴、同半径且轴向连续/重叠查找,不再要求旧段和新段必须共享同一条拓扑Edge。例如推拉后侧壁被 OCCT 拆成上下多段Face,点击任意一段时都会把整片同域区域一起高亮。 - `Face` 模式会把属于同一几何面的连续区域一起高亮;平面会按共面且投影范围相接/重叠查找,圆柱面会按同一实体内同轴、同半径且轴向连续/重叠查找,不再要求旧段和新段必须共享同一条拓扑Edge。例如推拉后侧壁被 OCCT 拆成上下多段Face,点击任意一段时都会把整片同域区域一起高亮。
- 推拉后程序会尽量保留侧壁面区域的 `逻辑 Face ID`:即使 OCCT 把原来的一个侧壁拓扑Face重建成上下两段,新旧两段也会绑定回推拉前那片侧壁的逻辑 ID。属性表会同时显示 `逻辑 Face ID``拓扑 Face ID`;前者面向用户选择和按 ID 定位,后者用于调试当前 B-Rep 拓扑。 - 推拉后程序会尽量保留侧壁面区域的 `逻辑 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。 - 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界Edge。
- 支持按当前选择模式输入 ID 直接选择 Part / Solid / Face / Edge / Feature - 支持按当前选择模式输入 ID 直接选择零件 / Solid / Face / Edge / 特征
- Face / Feature 按 ID 选择会优先解析 `逻辑 Face ID`。例如推拉前侧壁是 558,推拉后当前拓扑里变成 1556 和 1802,只要它们被绑定到逻辑 558,输入 558 就会选中这片面区域。 - Face / 特征按 ID 选择会优先解析 `逻辑 Face ID`。例如推拉前侧壁是 558,推拉后当前拓扑里变成 1556 和 1802,只要它们被绑定到逻辑 558,输入 558 就会选中这片面区域。
- `鼠标选择模式` 下拉框关闭时会把鼠标滚轮交还给左侧滚动面板,避免鼠标经过时误切换模式;打开下拉列表后仍可用滚轮滚动选项。 - `鼠标选择模式` 下拉框关闭时会把鼠标滚轮交还给左侧滚动面板,避免鼠标经过时误切换模式;打开下拉列表后仍可用滚轮滚动选项。
- `按 ID 选择` 会在输入框和 `选择` 按钮之间同步显示当前鼠标选择模式。 - `按 ID 选择` 会在输入框和 `选择` 按钮之间同步显示当前鼠标选择模式。
- `Feature` 模式现在会把点到的Face解释为局部几何特征候选: - `特征` 模式现在会把点到的Face解释为几何特征候选:
- 平面会识别为可推拉平面候选。 - 平面会识别为可推拉平面候选。
- 平面会尝试查找同一Solid内投影重叠的相对平面,用于估算薄壁/壳体局部区域厚度。 - 平面会尝试查找同一Solid内投影重叠的相对平面,用于估算薄壁/壳体局部区域厚度。
- 圆柱面会识别为圆柱孔候选、槽/半孔候选、圆角/倒圆候选、凸台/外圆候选或未明确圆柱特征。 - 圆柱面会识别为圆柱孔候选、槽/半孔候选、圆角/倒圆候选、凸台/外圆候选或未明确圆柱特征。
@@ -92,7 +92,7 @@
- 边长度 - 边长度
- 圆边半径/直径 - 圆边半径/直径
- 属性面板现在还会显示更多几何调试信息: - 属性面板现在还会显示更多几何调试信息:
- Part / Solid 的包围盒、尺寸、体积、重心、表面积。 - 零件 / Solid 的包围盒、尺寸、体积、重心、表面积。
- Face 的方向、面积中心、UV 参数范围、边界边数量、包围盒。 - Face 的方向、面积中心、UV 参数范围、边界边数量、包围盒。
- 平面Face的几何法向和按拓扑方向修正后的法向。 - 平面Face的几何法向和按拓扑方向修正后的法向。
- 平面Face的推拉向外方向、向内方向和方向判断置信度。 - 平面Face的推拉向外方向、向内方向和方向判断置信度。
@@ -101,23 +101,23 @@
- 直线Edge的方向,圆弧Edge的圆心、轴线、半径和直径。 - 直线Edge的方向,圆弧Edge的圆心、轴线、半径和直径。
- 属性面板支持复制当前对象 ID、拾取坐标和完整信息。 - 属性面板支持复制当前对象 ID、拾取坐标和完整信息。
- 面/边属性查询带缓存;重复选择同一个对象会复用上次计算结果,模型编辑、撤销、重做或重新加载后缓存会自动清空。 - 面/边属性查询带缓存;重复选择同一个对象会复用上次计算结果,模型编辑、撤销、重做或重新加载后缓存会自动清空。
- 支持高亮选中的零件、Solid、Face、Edge或局部特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face 选择会对共面平面和同轴同半径圆柱面做同域区域高亮。 - 支持高亮选中的零件、Solid、Face、Edge或特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face 选择会对共面平面和同轴同半径圆柱面做同域区域高亮。
- 默认模型边线渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔推拉后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部Edge,仍然可以单独高亮查看。 - 默认模型边线渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔推拉后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部Edge,仍然可以单独高亮查看。
- 支持只显示当前选中对象、对准当前选中对象,并可一键恢复显示完整模型。 - 支持只显示当前选中对象、对准当前选中对象,并可一键恢复显示完整模型。
- 支持两点测量: - 支持两点测量:
- 可以把当前选中对象的拾取点设为 A 或 B;如果没有拾取点,会退回使用对象中心、面积中心、长度中心、重心或包围盒中心。 - 可以把当前选中对象的拾取点设为 A 或 B;如果没有拾取点,会退回使用对象中心、面积中心、长度中心、重心或包围盒中心。
- 设置 A/B 后会显示两点距离和 X/Y/Z 方向差值。 - 设置 A/B 后会显示两点距离和 X/Y/Z 方向差值。
- 3D 视图中会显示一条青色测量线;测量结果可以复制,加载新模型时会自动清除旧测量。 - 3D 视图中会显示一条青色测量线;测量结果可以复制,加载新模型时会自动清除旧测量。
- Feature 模式会在属性表里显示特征来源Face、侧壁Face、同域圆柱范围、端面Face、疑似底面Face、开口端相邻Face、边界Edge和当前可用操作。 - 特征模式会在属性表里显示特征来源Face、侧壁Face、同域圆柱范围、端面Face、疑似底面Face、开口端相邻Face、边界Edge和当前可用操作。
- 支持导出当前完整模型为 STEP。 - 支持导出当前完整模型为 STEP。
- 支持导出选中的Part为 STEP。 - 支持导出选中的零件为 STEP。
- 支持导出选中的Solid为 STEP。 - 支持导出选中的Solid为 STEP。
- 支持导出选中的Face / 同域面区域为 STEP;如果当前Face与共面或同轴同半径的相邻面一起高亮,导出也会包含整片黄色区域。 - 支持导出选中的Face / 同域面区域为 STEP;如果当前Face与共面或同轴同半径的相邻面一起高亮,导出也会包含整片黄色区域。
- 支持导出选中特征区域为 STEP。 - 支持导出选中特征区域为 STEP。
- 支持导出选中的Edge为 STEP。 - 支持导出选中的Edge为 STEP。
- 支持导出质量检查:导出前会检查 B-Rep 有效性、Solid / Face / Edge 数量、体积和包围盒;如果发现风险,会先弹窗确认。 - 支持导出质量检查:导出前会检查 B-Rep 有效性、Solid / Face / Edge 数量、体积和包围盒;如果发现风险,会先弹窗确认。
- 支持一键修复当前模型或选中零件/Solid:执行 ShapeFix 和同域面/边合并,修复会进入后台编辑流程,并支持撤销/重做。 - 支持一键修复当前模型或选中零件/Solid:执行 ShapeFix 和同域面/边合并,修复会进入后台编辑流程,并支持撤销/重做。
- 导出完整模型、Part 或 Solid 前,会先尝试执行 ShapeFix 和同域面/边合并,减少外部 CAD 打开时出现碎面、坏体或明显拼接痕迹的概率。 - 导出完整模型、零件或 Solid 前,会先尝试执行 ShapeFix 和同域面/边合并,减少外部 CAD 打开时出现碎面、坏体或明显拼接痕迹的概率。
- 编辑按钮会根据当前选中对象自动启用或禁用,并通过悬停提示说明为什么当前操作不可用,减少 Face、Edge、孔、凸台、盲孔等对象混用导致的误操作。 - 编辑按钮会根据当前选中对象自动启用或禁用,并通过悬停提示说明为什么当前操作不可用,减少 Face、Edge、孔、凸台、盲孔等对象混用导致的误操作。
- 支持扫描可编辑对象: - 支持扫描可编辑对象:
- 默认扫描优先保证界面响应速度,会列出一组有代表性的候选。 - 默认扫描优先保证界面响应速度,会列出一组有代表性的候选。
@@ -166,7 +166,7 @@
- 当前版本支持槽/半孔候选结构化识别: - 当前版本支持槽/半孔候选结构化识别:
- 对局部圆柱形 `hole/groove candidate`,会标记为 `partial-cylindrical-groove` - 对局部圆柱形 `hole/groove candidate`,会标记为 `partial-cylindrical-groove`
- 会估算槽宽、圆弧长度和槽深。 - 会估算槽宽、圆弧长度和槽深。
- 会识别槽圆柱Face和槽边界相邻Face,并在 Feature 模式下一起高亮。 - 会识别槽圆柱Face和槽边界相邻Face,并在特征模式下一起高亮。
- 这些仍是 B-Rep 几何估算,不是 CAD 历史里的槽特征参数。 - 这些仍是 B-Rep 几何估算,不是 CAD 历史里的槽特征参数。
- 当前版本支持槽/半孔宽度调整: - 当前版本支持槽/半孔宽度调整:
- 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。 - 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。
@@ -180,7 +180,7 @@
- 当前版本支持已有圆角/倒圆候选结构化识别: - 当前版本支持已有圆角/倒圆候选结构化识别:
- 对局部小半径圆柱面,会标记为 `round/fillet candidate` - 对局部小半径圆柱面,会标记为 `round/fillet candidate`
- 会估算已有圆角半径、圆弧角度和圆弧长度。 - 会估算已有圆角半径、圆弧角度和圆弧长度。
- 会识别已有圆角Face和相邻支撑Face,并在 Feature 模式下一起高亮。 - 会识别已有圆角Face和相邻支撑Face,并在特征模式下一起高亮。
- `可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径`,点击后会选中该圆角 Face,并把圆角半径输入框预填为当前估算半径的参考目标值。 - `可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径`,点击后会选中该圆角 Face,并把圆角半径输入框预填为当前估算半径的参考目标值。
- 选中已有圆角候选时,不会再误把它填入 `孔直径`,避免把圆角当孔径修改。 - 选中已有圆角候选时,不会再误把它填入 `孔直径`,避免把圆角当孔径修改。
- 当前版本的已有圆角半径修改会先尝试移除圆角Face,再在恢复出的锐边上重新倒圆;只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。 - 当前版本的已有圆角半径修改会先尝试移除圆角Face,再在恢复出的锐边上重新倒圆;只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。
@@ -222,17 +222,17 @@
- 椭圆边和可稳定采样为平面的 B-spline / Bezier 等非直线边,也会尝试围绕曲线所在平面法向做径向缩放;如果采样点不够平面,会退回更保守的缩放 fallback。 - 椭圆边和可稳定采样为平面的 B-spline / Bezier 等非直线边,也会尝试围绕曲线所在平面法向做径向缩放;如果采样点不够平面,会退回更保守的缩放 fallback。
- 几何缩放 fallback 会先只预变换目标Edge来估算结果长度,并在必要时微调缩放比例,让实际边长更接近输入值。 - 几何缩放 fallback 会先只预变换目标Edge来估算结果长度,并在必要时微调缩放比例,让实际边长更接近输入值。
- `中心`、没有明确端面、或非圆边没有更明确局部路径时,会尝试整体/轴向几何缩放 fallback,以覆盖更多任意Edge的最小可用场景。 - `中心`、没有明确端面、或非圆边没有更明确局部路径时,会尝试整体/轴向几何缩放 fallback,以覆盖更多任意Edge的最小可用场景。
- 该 fallback 会影响所属Part/Solid的其它尺寸,因此会按高风险确认,并依赖失败回滚/撤销兜底。 - 该 fallback 会影响所属零件/Solid的其它尺寸,因此会按高风险确认,并依赖失败回滚/撤销兜底。
- 这仍不是通用参数化边长编辑;复杂拓扑、斜面过渡、圆角链或布尔失败场景仍可能不能稳定修改。 - 这仍不是通用参数化边长编辑;复杂拓扑、斜面过渡、圆角链或布尔失败场景仍可能不能稳定修改。
- 支持 Part / Solid 级平移: - 支持零件 / Solid 级平移:
- 输入 X/Y/Z 平移量后,可以平移当前选中零件。 - 输入 X/Y/Z 平移量后,可以平移当前选中零件。
- 选中Solid、或选中属于某个Solid的Face/Edge后,可以平移当前Solid。 - 选中Solid、或选中属于某个Solid的Face/Edge后,可以平移当前Solid。
- 单Solid零件中平移Solid实际等同于移动整个Part shape,程序会按中风险提示。 - 单Solid零件中平移Solid实际等同于移动整个零件 shape,程序会按中风险提示。
- 平移操作会写入历史记录,并支持撤销/重做。 - 平移操作会写入历史记录,并支持撤销/重做。
- 支持 Part / Solid 级旋转: - 支持零件 / Solid 级旋转:
- 选择 X/Y/Z 旋转轴并输入角度后,可以旋转当前选中零件或Solid。 - 选择 X/Y/Z 旋转轴并输入角度后,可以旋转当前选中零件或Solid。
- 当前版本的旋转中心取目标对象的包围盒中心。 - 当前版本的旋转中心取目标对象的包围盒中心。
- 单Solid零件中旋转Solid实际等同于旋转整个Part shape,程序会按中风险提示。 - 单Solid零件中旋转Solid实际等同于旋转整个零件 shape,程序会按中风险提示。
- 旋转操作会写入历史记录,并支持撤销/重做。 - 旋转操作会写入历史记录,并支持撤销/重做。
- 后台编辑计算时不再弹出模态进度框,也不再把主界面整体置灰;UI 线程只负责渲染和响应基础视图交互,真实 B-Rep 编辑在后台线程执行。 - 后台编辑计算时不再弹出模态进度框,也不再把主界面整体置灰;UI 线程只负责渲染和响应基础视图交互,真实 B-Rep 编辑在后台线程执行。
- 编辑计算期间,半透明预览会继续在 3D 视图里渲染,用户可以旋转或缩放查看;会改变模型状态的操作仍会暂时禁用,避免同一模型被多个后台编辑同时修改。 - 编辑计算期间,半透明预览会继续在 3D 视图里渲染,用户可以旋转或缩放查看;会改变模型状态的操作仍会暂时禁用,避免同一模型被多个后台编辑同时修改。
@@ -292,7 +292,7 @@ edges: 4982
vertices: 3262 vertices: 3262
``` ```
所以当前测试文件大概率是单个零件,不是多零件装配。但代码已经按“将来可能有多个零件/装配”的方式组织。 所以当前测试文件大概率是单个零件,不是多零件装配。Face、Edge、圆柱面、孔、槽和圆角等,是这个零件里的几何特征;代码也按“将来可能有多个零件/装配”的方式组织。
## 还没有实现什么 ## 还没有实现什么
@@ -399,17 +399,17 @@ python scripts\verify_edge_length_resize.py
左侧操作面板按功能分区排列,每个模块都有四边同色的独立边框和颜色提示,顺序大致按工作流排列:先打开和选择模型,再做编辑或导出,然后进行显示控制和测量,后面继续放可编辑对象、圆柱面候选、操作历史和对象信息。 左侧操作面板按功能分区排列,每个模块都有四边同色的独立边框和颜色提示,顺序大致按工作流排列:先打开和选择模型,再做编辑或导出,然后进行显示控制和测量,后面继续放可编辑对象、圆柱面候选、操作历史和对象信息。
左侧 `鼠标选择模式` 用来切换选择模式: 左侧 `选择模式` 用来切换鼠标选择模式:
- `Part`:选择整个零件。 - `零件`:选择整个零件。
- `Solid`:选择实体。 - `Solid`:选择实体。
- `Face`:选择面。 - `Face`:选择面。
- `Edge`:选择边。 - `Edge`:选择边。
- `Feature`:按局部特征候选方式选择。平面会识别为可推拉平面候选;圆柱面会进一步识别为圆柱孔、槽/半孔、圆角/倒圆、凸台/外圆等候选。 - `特征`:按几何特征候选方式选择。平面会识别为可推拉平面候选;圆柱面会进一步识别为圆柱孔、槽/半孔、圆角/倒圆、凸台/外圆等候选。
圆柱面候选会额外显示侧壁 Face、端面 Face、疑似底面 Face、开口端相邻 Face 和边界 Edge。 圆柱面候选会额外显示侧壁 Face、端面 Face、疑似底面 Face、开口端相邻 Face 和边界 Edge。
- 鼠标悬停时,当前模式下将要选中的对象会显示为红色;真正选中后会显示为黄色。 - 鼠标悬停时,当前模式下将要选中的对象会显示为红色;真正选中后会显示为黄色。
- 如果当前模式是 `Edge`,即使鼠标点到的是面,程序也会从这个面的边界里选取距离鼠标拾取点最近的Edge。 - 如果当前模式是 `Edge`,即使鼠标点到的是面,程序也会从这个面的边界里选取距离鼠标拾取点最近的Edge。
- `鼠标选择模式` 下拉框关闭时不会吃掉滚轮,鼠标停在上面滚动会继续滚动左侧面板;点击打开下拉列表后,滚轮才用于滚动选项。 - `选择模式` 下拉框关闭时不会吃掉滚轮,鼠标停在上面滚动会继续滚动左侧面板;点击打开下拉列表后,滚轮才用于滚动选项。
左侧 `模型结构树` 会显示 STEP 里的装配、零件和实体层级;如果某个零件下有实体,会展开显示 `实体 0``实体 1` 这样的子节点。点击实体子节点会自动切换到 `Solid` 选择,并高亮对应实体。 左侧 `模型结构树` 会显示 STEP 里的装配、零件和实体层级;如果某个零件下有实体,会展开显示 `实体 0``实体 1` 这样的子节点。点击实体子节点会自动切换到 `Solid` 选择,并高亮对应实体。
@@ -424,17 +424,17 @@ python scripts\verify_edge_length_resize.py
左侧 `按 ID 选择` 可以直接输入编号选择对象: 左侧 `按 ID 选择` 可以直接输入编号选择对象:
- 按 ID 选择会复用上方 `鼠标选择模式` 的当前类型;先切换模式,再输入 ID,点击 `选择` - 按 ID 选择会复用上方 `选择模式` 的当前类型;先切换模式,再输入 ID,点击 `选择`
- 输入框右侧会同步显示当前类型,例如 `Face``Edge``Solid`,用于确认这个 ID 会按哪类对象解释。 - 输入框右侧会同步显示当前类型,例如 `Face``Edge``Solid`,用于确认这个 ID 会按哪类对象解释。
- `Part` 的 ID 从 1 开始。 - `零件` 的 ID 从 1 开始。
- `Solid``Face``Edge``Feature` 的 ID 从 0 开始;`Feature` 使用来源Face的 ID。 - `Solid``Face``Edge``特征` 的 ID 从 0 开始;`特征` 使用来源Face的 ID。
- 这个功能适合配合圆柱面候选表格、操作历史和属性面板使用。 - 这个功能适合配合圆柱面候选表格、操作历史和属性面板使用。
左侧 `显示` 用来临时缩小三维视图范围: 左侧 `显示` 用来临时缩小三维视图范围:
- `只显示选中`:只显示当前选中的 Part、Solid、Face、Edge 或 Feature 区域。 - `只显示选中`:只显示当前选中的零件、Solid、Face、Edge 或特征区域。
- Face 模式下 `只显示选中` 会隔离当前黄色同域面区域,并显示这片区域的外边界Edge。 - Face 模式下 `只显示选中` 会隔离当前黄色同域面区域,并显示这片区域的外边界Edge。
- `对准选中`:不改变显示范围,只把相机对准当前选中的 Part、Solid、Face、Edge 或 Feature,适合快速查看小孔、小边和局部特征。 - `对准选中`:不改变显示范围,只把相机对准当前选中的零件、Solid、Face、Edge 或特征,适合快速查看小孔、小边和局部特征。
- `显示全部`:恢复显示完整模型。 - `显示全部`:恢复显示完整模型。
- `显示同域内部边`:默认关闭,用来隐藏同一平面/同一圆柱面内部的拓扑分割边和几何重复拼接边;打开后可以查看完整 B-Rep 拓扑边线,适合调试布尔推拉后的分面情况。 - `显示同域内部边`:默认关闭,用来隐藏同一平面/同一圆柱面内部的拓扑分割边和几何重复拼接边;打开后可以查看完整 B-Rep 拓扑边线,适合调试布尔推拉后的分面情况。
- `导出选中面区域` 会导出当前 Face 模式黄色高亮的同域面区域,而不是只导出鼠标点到的单个拓扑Face。 - `导出选中面区域` 会导出当前 Face 模式黄色高亮的同域面区域,而不是只导出鼠标点到的单个拓扑Face。
@@ -465,7 +465,7 @@ python scripts\verify_edge_length_resize.py
- `调整盲孔深度` 行表示这个Face是较明确的盲孔/盲槽候选,可以配合 `孔深度``调整盲孔深度` 使用。 - `调整盲孔深度` 行表示这个Face是较明确的盲孔/盲槽候选,可以配合 `孔深度``调整盲孔深度` 使用。
- `给Edge添加圆角` 行表示这个对象是直线Edge,可以配合 `圆角半径``给Edge添加圆角` 使用。 - `给Edge添加圆角` 行表示这个对象是直线Edge,可以配合 `圆角半径``给Edge添加圆角` 使用。
- `给Edge添加倒角` 行表示这个对象是直线Edge,可以配合 `倒角距离``给Edge添加倒角` 使用。 - `给Edge添加倒角` 行表示这个对象是直线Edge,可以配合 `倒角距离``给Edge添加倒角` 使用。
- `修改已有圆角半径` 行表示这个Face是已有圆角/倒圆候选,点击后会切到 `Feature` 模式并预填圆角半径参考值;真正执行需要再点击编辑区里的 `修改已有圆角半径` 按钮并确认。 - `修改已有圆角半径` 行表示这个Face是已有圆角/倒圆候选,点击后会切到 `特征` 模式并预填圆角半径参考值;真正执行需要再点击编辑区里的 `修改已有圆角半径` 按钮并确认。
- `状态``ready` 表示比较适合尝试,`caution` 表示可以尝试但风险更高,`blocked` 表示当前参数或对象不适合执行。 - `状态``ready` 表示比较适合尝试,`caution` 表示可以尝试但风险更高,`blocked` 表示当前参数或对象不适合执行。
- `风险` 越高越应该谨慎,尤其是圆角、凸柱或未明确圆柱面,不要直接当孔修改。 - `风险` 越高越应该谨慎,尤其是圆角、凸柱或未明确圆柱面,不要直接当孔修改。
- 点击任意一行会自动选中并高亮对应Face或Edge。 - 点击任意一行会自动选中并高亮对应Face或Edge。
@@ -474,7 +474,7 @@ python scripts\verify_edge_length_resize.py
- 鼠标经过对象时会出现红色预高亮;被选对象会显示为黄色高亮。 - 鼠标经过对象时会出现红色预高亮;被选对象会显示为黄色高亮。
-`Face` 模式下,如果相邻Face属于同一几何面,并且投影范围或轴向范围相接/重叠,程序会把它们作为同域区域一起红色预高亮和黄色选中高亮;这主要用于缓解平面/圆柱推拉后视觉上被拓扑边分成多段的问题。 -`Face` 模式下,如果相邻Face属于同一几何面,并且投影范围或轴向范围相接/重叠,程序会把它们作为同域区域一起红色预高亮和黄色选中高亮;这主要用于缓解平面/圆柱推拉后视觉上被拓扑边分成多段的问题。
- 如果处于 `Feature` 模式,程序会尽量高亮这个特征相关的局部区域,例如圆柱侧壁、相邻端面和槽边界相邻面。 - 如果处于 `特征` 模式,程序会尽量高亮这个特征相关的局部区域,例如圆柱侧壁、相邻端面和槽边界相邻面。
- 对槽/半孔候选,属性表会显示 `槽宽估算``槽圆弧长度估算``槽深估算``槽边界相邻 Face` - 对槽/半孔候选,属性表会显示 `槽宽估算``槽圆弧长度估算``槽深估算``槽边界相邻 Face`
- 对圆角/倒圆候选,属性表会显示 `已有圆角半径估算``已有圆角圆弧角度``已有圆角圆弧长度估算``已有圆角支撑 Face` - 对圆角/倒圆候选,属性表会显示 `已有圆角半径估算``已有圆角圆弧角度``已有圆角圆弧长度估算``已有圆角支撑 Face`
- 对盲孔/槽候选,属性表中的 `疑似底面 Face` 是根据圆柱端部 inside / outside 采样和相邻平面位置推断的,不等于 CAD 原始建模历史里的孔深参数。 - 对盲孔/槽候选,属性表中的 `疑似底面 Face` 是根据圆柱端部 inside / outside 采样和相邻平面位置推断的,不等于 CAD 原始建模历史里的孔深参数。
@@ -496,7 +496,7 @@ python scripts\verify_edge_length_resize.py
- 类型下拉框关闭时不会吃掉滚轮,鼠标停在上面滚动会继续滚动左侧面板;点击打开下拉列表后,滚轮才用于滚动选项。 - 类型下拉框关闭时不会吃掉滚轮,鼠标停在上面滚动会继续滚动左侧面板;点击打开下拉列表后,滚轮才用于滚动选项。
- 当前默认显示前 60 个圆柱面候选;如果模型很复杂,扫描仍可能需要等待一会儿,但扫描过程中界面会尽量保持响应。 - 当前默认显示前 60 个圆柱面候选;如果模型很复杂,扫描仍可能需要等待一会儿,但扫描过程中界面会尽量保持响应。
- 表格里的每一行对应一个圆柱面候选。 - 表格里的每一行对应一个圆柱面候选。
- 点击候选行会自动切到 `Feature` 模式,并选中对应Face。 - 点击候选行会自动切到 `特征` 模式,并选中对应Face。
- `guess` 列是程序通过几何采样得到的初步判断,不是 STEP 文件里自带的 CAD 历史特征。 - `guess` 列是程序通过几何采样得到的初步判断,不是 STEP 文件里自带的 CAD 历史特征。
- `round/fillet candidate` 是圆角/倒圆候选;部分规整圆柱圆角可以通过 `修改已有圆角半径` 进行当前版本受限修改。 - `round/fillet candidate` 是圆角/倒圆候选;部分规整圆柱圆角可以通过 `修改已有圆角半径` 进行当前版本受限修改。
- `diameter` 是圆柱直径。 - `diameter` 是圆柱直径。
@@ -541,7 +541,7 @@ python scripts\verify_edge_length_resize.py
- 端部类型是通过轴线端部 inside / outside 采样得到的估算,不等于 CAD 原始建模历史里的“孔深”参数。 - 端部类型是通过轴线端部 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 判断为不适合倒圆;失败时程序会提示并尝试恢复编辑前状态。 - 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒圆;失败时程序会提示并尝试恢复编辑前状态。
- `圆角半径` + `修改已有圆角半径` - `圆角半径` + `修改已有圆角半径`
- 先切换到 `Feature` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。 - 先切换到 `特征` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。
- 选中已有圆角候选后,`圆角半径` 会自动填入一个基于当前估算半径的参考目标值。 - 选中已有圆角候选后,`圆角半径` 会自动填入一个基于当前估算半径的参考目标值。
- 输入目标半径后点击 `修改已有圆角半径` - 输入目标半径后点击 `修改已有圆角半径`
- 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。 - 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。
@@ -617,25 +617,25 @@ python scripts\verify_edge_length_resize.py
- 如果圆边没有可复用圆柱面,但能读取圆心和轴线,会走 `围绕圆边轴线径向缩放` 后备策略,尽量不改变轴向厚度/高度;执行后部分解析几何可能变成 B-spline。 - 如果圆边没有可复用圆柱面,但能读取圆心和轴线,会走 `围绕圆边轴线径向缩放` 后备策略,尽量不改变轴向厚度/高度;执行后部分解析几何可能变成 B-spline。
- 椭圆边、平面 B-spline / Bezier 边会尝试走 `围绕平面曲线法向径向缩放`;确认窗口会显示平面采样偏差,偏差不稳定时会回退到更保守的缩放 fallback。 - 椭圆边、平面 B-spline / Bezier 边会尝试走 `围绕平面曲线法向径向缩放`;确认窗口会显示平面采样偏差,偏差不稳定时会回退到更保守的缩放 fallback。
- `固定起点` 会让起点不动、移动终点;`固定终点` 相反;`中心` 会让两端各移动一半,只有进入缩放 fallback 时才会变成以Edge中心为基准缩放。 - `固定起点` 会让起点不动、移动终点;`固定终点` 相反;`中心` 会让两端各移动一半,只有进入缩放 fallback 时才会变成以Edge中心为基准缩放。
- 如果没有明确端面或可复用圆柱面,程序会按所选基准尝试整体/轴向几何缩放 fallback;这会影响所属Part/Solid的其它尺寸,因此会弹出高风险确认。 - 如果没有明确端面或可复用圆柱面,程序会按所选基准尝试整体/轴向几何缩放 fallback;这会影响所属零件/Solid的其它尺寸,因此会弹出高风险确认。
- 执行成功后会做后置边长校验:在刷新后的Part/Solid范围内寻找最接近目标长度的Edge,并把实际最近边长、误差和相对误差写入操作结果。 - 执行成功后会做后置边长校验:在刷新后的零件/Solid范围内寻找最接近目标长度的Edge,并把实际最近边长、误差和相对误差写入操作结果。
- 通过检查后,会显示蓝绿色半透明预览,表示将要移动或缩放的范围。 - 通过检查后,会显示蓝绿色半透明预览,表示将要移动或缩放的范围。
- 真实编辑在后台执行,失败时会回滚到编辑前状态。 - 真实编辑在后台执行,失败时会回滚到编辑前状态。
- 这是当前版本的 B-Rep 结果几何修改,不是任意边参数化改长。 - 这是当前版本的 B-Rep 结果几何修改,不是任意边参数化改长。
- `平移 X/Y/Z` + `平移选中零件` / `平移选中Solid` - `平移 X/Y/Z` + `平移选中零件` / `平移选中Solid`
- 输入 X、Y、Z 三个方向的平移量。 - 输入 X、Y、Z 三个方向的平移量。
- 选择 `Part` 后点击 `平移选中零件`,会移动整个Part - 选择 `零件` 后点击 `平移选中零件`,会移动整个零件
- 选择 `Solid`,或选择属于某个Solid的Face/Edge后,点击 `平移选中Solid`,会移动当前Solid。 - 选择 `Solid`,或选择属于某个Solid的Face/Edge后,点击 `平移选中Solid`,会移动当前Solid。
- 如果当前Part只有一个Solid,平移Solid实际会移动整个Part shape,程序会提示中风险。 - 如果当前零件只有一个Solid,平移Solid实际会移动整个零件 shape,程序会提示中风险。
- 平移向量为 0 会直接阻止。 - 平移向量为 0 会直接阻止。
- 如果平移距离相对对象包围盒过大,程序会提高风险并要求确认。 - 如果平移距离相对对象包围盒过大,程序会提高风险并要求确认。
- 平移属于真实 B-Rep 变换,会写入操作历史,可以撤销/重做。 - 平移属于真实 B-Rep 变换,会写入操作历史,可以撤销/重做。
- `旋转轴/角度` + `旋转选中零件` / `旋转选中Solid` - `旋转轴/角度` + `旋转选中零件` / `旋转选中Solid`
- 选择 X、Y 或 Z 旋转轴。 - 选择 X、Y 或 Z 旋转轴。
- 输入旋转角度,单位是度。 - 输入旋转角度,单位是度。
- 选择 `Part` 后点击 `旋转选中零件`,会绕该Part的包围盒中心旋转整个Part - 选择 `零件` 后点击 `旋转选中零件`,会绕该零件的包围盒中心旋转整个零件
- 选择 `Solid`,或选择属于某个Solid的Face/Edge后,点击 `旋转选中Solid`,会绕该Solid的包围盒中心旋转当前Solid。 - 选择 `Solid`,或选择属于某个Solid的Face/Edge后,点击 `旋转选中Solid`,会绕该Solid的包围盒中心旋转当前Solid。
- 如果当前Part只有一个Solid,旋转Solid实际会旋转整个Part shape,程序会提示中风险。 - 如果当前零件只有一个Solid,旋转Solid实际会旋转整个零件 shape,程序会提示中风险。
- 旋转角度为 0 会直接阻止;角度超过 360 度会提高风险并要求确认。 - 旋转角度为 0 会直接阻止;角度超过 360 度会提高风险并要求确认。
- 旋转属于真实 B-Rep 变换,会写入操作历史,可以撤销/重做。 - 旋转属于真实 B-Rep 变换,会写入操作历史,可以撤销/重做。
- `撤销` - `撤销`
@@ -664,7 +664,7 @@ python scripts\verify_edge_length_resize.py
- `导出选中零件`:导出当前选中的零件。 - `导出选中零件`:导出当前选中的零件。
- `导出选中Solid`:先选中一个Solid,再导出该Solid。 - `导出选中Solid`:先选中一个Solid,再导出该Solid。
- `导出选中面区域`:先切换到 `Face` 选择模式并选中一个Face,再导出该Face或同域面区域。 - `导出选中面区域`:先切换到 `Face` 选择模式并选中一个Face,再导出该Face或同域面区域。
- `导出选中特征区域`:先切换到 `Feature` 选择模式并选中局部特征,再导出当前特征高亮的Face集合。 - `导出选中特征区域`:先切换到 `特征` 选择模式并选中特征,再导出当前特征高亮的Face集合。
- `导出选中Edge`:先切换到 `Edge` 选择模式并选中一个Edge,再导出该Edge。 - `导出选中Edge`:先切换到 `Edge` 选择模式并选中一个Edge,再导出该Edge。
- `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。 - `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。
- `修复当前模型`:对当前完整模型执行 ShapeFix 和同域面/边合并,完成后会刷新显示并写入操作历史;如果修复失败,会尝试回滚到修复前状态。 - `修复当前模型`:对当前完整模型执行 ShapeFix 和同域面/边合并,完成后会刷新显示并写入操作历史;如果修复失败,会尝试回滚到修复前状态。
@@ -701,7 +701,7 @@ pythonocc-step-editor/
polydata.py # OCC Shape / Edge / Face 到 VTK polydata 的显示数据生成 polydata.py # OCC Shape / Edge / Face 到 VTK polydata 的显示数据生成
records.py # 操作历史记录数据结构 records.py # 操作历史记录数据结构
step_io.py # STEP/XCAF 读取、产品名解析和 STEP 写出 step_io.py # STEP/XCAF 读取、产品名解析和 STEP 写出
transforms.py # Part / Solid 平移和旋转 transforms.py # 零件 / Solid 平移和旋转
ui_helpers.py # UI 常量、格式化函数和 VTK 显示小工具 ui_helpers.py # UI 常量、格式化函数和 VTK 显示小工具
widgets.py # 自定义 Qt 小组件 widgets.py # 自定义 Qt 小组件
window_actions.py # 导出、编辑按钮、扫描、后台编辑调度等窗口动作 window_actions.py # 导出、编辑按钮、扫描、后台编辑调度等窗口动作
@@ -739,7 +739,7 @@ pythonocc-step-editor/
本项目是一个基于 Python、pythonocc-core/OCCT、VTK 和 PySide6/Qt 的 STEP 查看与局部编辑原型。它处理的是 STEP 里的 B-Rep 结果几何,不是原 CAD 软件的完整参数化建模历史。 本项目是一个基于 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 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。 - STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。
- 选择:支持 Part、Solid、Face、Edge、Feature。鼠标悬停红色预高亮,选中黄色高亮。 - 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮。
- 同域面:Face/Feature 会尽量把共面或同轴同半径的连续碎面合成逻辑区域。 - 同域面:Face/特征会尽量把共面或同轴同半径的连续碎面合成逻辑区域。
- 编辑:平面推拉、孔径、槽/半孔宽度、盲孔/盲槽深度、凸台直径、已有圆角半径当前版本修改、Edge圆角、Edge倒角、薄壁厚度、修改Edge长度、Part/Solid 平移和旋转。 - 编辑:平面推拉、孔径、槽/半孔宽度、盲孔/盲槽深度、凸台直径、已有圆角半径当前版本修改、Edge圆角、Edge倒角、薄壁厚度、修改Edge长度、零件/Solid 平移和旋转。
- 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。 - 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。
- 导出:完整模型、Part、Solid、Face、Feature 区域和 Edge,导出前做基础质量检查。 - 导出:完整模型、零件、Solid、Face、特征区域和 Edge,导出前做基础质量检查。
### 修改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`:如果不适合局部形变,尝试找端点附近、方向匹配的平面端面,把改边长转成端面推拉。 2. `move-edge-end-plane-by-push-pull`:如果不适合局部形变,尝试找端点附近、方向匹配的平面端面,把改边长转成端面推拉。
3. `resize-adjacent-cylinder-from-circular-edge-length`:圆形/圆弧Edge尝试复用相邻圆柱孔/槽或凸台直径编辑。 3. `resize-adjacent-cylinder-from-circular-edge-length`:圆形/圆弧Edge尝试复用相邻圆柱孔/槽或凸台直径编辑。
4. `scale-owning-shape-from-edge`:圆边、椭圆边和可采样为平面的非直线Edge会优先用 `radial-affine` 围绕曲线平面法向做径向缩放;其他无明确局部路径的Edge才按Edge轴向或整体做几何缩放 fallback,风险高,会影响其它尺寸。 4. `scale-owning-shape-from-edge`:圆边、椭圆边和可采样为平面的非直线Edge会优先用 `radial-affine` 围绕曲线平面法向做径向缩放;其他无明确局部路径的Edge才按Edge轴向或整体做几何缩放 fallback,风险高,会影响其它尺寸。
+252 -84
View File
@@ -7,15 +7,17 @@ import sys
from pathlib import Path from pathlib import Path
import vtk import vtk
from PySide6.QtCore import Qt, QThread, QTimer, Slot from PySide6.QtCore import Qt, QThread, QTimer, Signal, Slot
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QApplication, QApplication,
QCheckBox, QCheckBox,
QComboBox, QComboBox,
QFileDialog, QFileDialog,
QFrame,
QGridLayout, QGridLayout,
QGroupBox, QGroupBox,
QHeaderView,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit, QLineEdit,
@@ -25,6 +27,7 @@ from PySide6.QtWidgets import (
QPushButton, QPushButton,
QPlainTextEdit, QPlainTextEdit,
QScrollArea, QScrollArea,
QSizePolicy,
QTableWidget, QTableWidget,
QTableWidgetItem, QTableWidgetItem,
QTabWidget, QTabWidget,
@@ -52,6 +55,16 @@ _CRASH_LOG_HANDLE = None
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "geom_extract.step" 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: def _enable_crash_log() -> None:
global _CRASH_LOG_HANDLE global _CRASH_LOG_HANDLE
@@ -66,9 +79,12 @@ def _enable_crash_log() -> None:
class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, InfoPanelMixin, QMainWindow): class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, InfoPanelMixin, QMainWindow):
ui_task_requested = Signal(object)
def __init__(self, step_path: str | Path, *, background_load: bool = True): def __init__(self, step_path: str | Path, *, background_load: bool = True):
super().__init__() super().__init__()
self.setWindowTitle("STEP 零件查看与编辑原型") self.ui_task_requested.connect(self._run_ui_task, Qt.ConnectionType.QueuedConnection)
self.setWindowTitle("几何参数化")
self.resize(1280, 820) self.resize(1280, 820)
self.model: StepModel | None = None self.model: StepModel | None = None
@@ -143,6 +159,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.pending_load_path: Path | None = None self.pending_load_path: Path | None = None
self.initial_load_deflection = 2.0 self.initial_load_deflection = 2.0
self.last_id_kind = "Face" 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_ui()
self._build_vtk() self._build_vtk()
@@ -237,17 +257,31 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
border-color: #b8dfd6; border-color: #b8dfd6;
color: #0f766e; color: #0f766e;
} }
QGroupBox#modeSection, QFrame#modeSection {
QGroupBox#idSection { background: #f6f3ff;
background: #f7f5ff; border: 1px solid #a99bff;
border: 1px solid #cbc4ff; border-left: 5px solid #5b4bdb;
border-left: 5px solid #6b5cff; border-radius: 7px;
} }
QGroupBox#modeSection::title, QLabel#modeSectionTitle {
QGroupBox#idSection::title { background: #ebe7ff;
background: #eeeaff; border: 1px solid #a99bff;
border-color: #cbc4ff; border-radius: 5px;
color: #5642c7; 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 { QGroupBox#viewSection {
background: #f1faff; background: #f1faff;
@@ -337,12 +371,6 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
QComboBox:focus { QComboBox:focus {
border: 1px solid #3478f6; border: 1px solid #3478f6;
} }
QLineEdit#idModeDisplay {
background: #eef2ff;
border-color: #93a5e8;
color: #2d3a8c;
font-weight: 700;
}
QPushButton { QPushButton {
background: #f8fafc; background: #f8fafc;
border: 1px solid #c8d2df; border: 1px solid #c8d2df;
@@ -365,6 +393,24 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
color: #8f99a8; color: #8f99a8;
font-weight: 500; 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, QLineEdit:disabled,
QComboBox:disabled, QComboBox:disabled,
QPlainTextEdit:disabled { QPlainTextEdit:disabled {
@@ -372,6 +418,26 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
border: 1px dashed #bcc7d4; border: 1px dashed #bcc7d4;
color: #8f99a8; 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, QCheckBox:disabled,
QTabWidget:disabled, QTabWidget:disabled,
QTreeWidget:disabled, QTreeWidget:disabled,
@@ -423,26 +489,32 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
file_box = QGroupBox("STEP 文件") file_box = QGroupBox("STEP 文件")
file_box.setObjectName("fileSection") file_box.setObjectName("fileSection")
help_tip(file_box, "打开、重新加载和查看当前 STEP 文件路径") help_tip(file_box, "导入、查看路径并读取当前 STEP 几何模型")
file_layout = QVBoxLayout(file_box) 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() file_buttons = QHBoxLayout()
self.open_button = QPushButton("打开") file_buttons.setSpacing(6)
help_tip(self.open_button, "选择并打开一个 .step 或 .stp 文件。打开失败时会保留当前模型") 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.open_button.clicked.connect(self.open_step)
self.reload_button = QPushButton("重新加载") self.path_label = QLineEdit(str(self.step_path))
help_tip(self.reload_button, "从磁盘重新读取当前 STEP 文件,用于放弃本次会话里的临时查看状态。") 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) self.reload_button.clicked.connect(self.reload_step)
file_buttons.addWidget(self.open_button) file_buttons.addWidget(self.open_button)
file_buttons.addWidget(self.path_label, stretch=1)
file_buttons.addWidget(self.reload_button) file_buttons.addWidget(self.reload_button)
file_layout.addLayout(file_buttons) file_layout.addLayout(file_buttons)
panel_layout.addWidget(file_box) panel_layout.addWidget(file_box)
if ENABLE_STRUCTURE_TREE_PANEL:
tree_box = QGroupBox("模型结构树") tree_box = QGroupBox("模型结构树")
tree_box.setObjectName("treeSection") tree_box.setObjectName("treeSection")
help_tip(tree_box, "查看 STEP 里的装配、零件和实体层级,并从树上直接选中对象。") help_tip(tree_box, "查看 STEP 里的装配、零件和实体层级,并从树上直接选中对象。")
@@ -456,53 +528,92 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
tree_layout.addWidget(self.part_tree) tree_layout.addWidget(self.part_tree)
panel_layout.addWidget(tree_box) panel_layout.addWidget(tree_box)
mode_box = QGroupBox("鼠标选择模式") mode_box = QWidget()
mode_box.setObjectName("modeSection") mode_box.setMinimumHeight(62)
help_tip(mode_box, "决定鼠标点模型时选中 Part、Solid、Face、Edge,还是识别局部特征。") help_tip(mode_box, "决定鼠标点模型时选中零件、Solid、Face、Edge,还是识别几何特征。")
mode_layout = QVBoxLayout(mode_box) 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 = NoWheelComboBox()
self.mode_combo.addItems(["Part", "Solid", "Face", "Edge", "Feature"]) for mode in ("Part", "Solid", "Face", "Edge", "Feature"):
self.mode_combo.setCurrentText("Face") 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( help_tip(
self.mode_combo, self.mode_combo,
"选择鼠标点击模型时要选什么:Part、Solid、Face、Edge,或把 Face 解释成孔/槽/圆角等特征候选。", "选择模式决定鼠标点击模型时要选什么:零件、Solid、Face、Edge,或把 Face 解释成孔/槽/圆角等几何特征候选。",
) )
self.mode_combo.currentTextChanged.connect(self._on_mode_changed) self.mode_combo.currentIndexChanged.connect(lambda _index: self._on_mode_changed(self._current_selection_mode()))
mode_layout.addWidget(self.mode_combo) 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) panel_layout.addWidget(mode_box)
select_box = QGroupBox("按 ID 选择") if ENABLE_VIEW_PANEL:
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)
view_box = QGroupBox("显示") view_box = QGroupBox("显示")
view_box.setObjectName("viewSection") view_box.setObjectName("viewSection")
help_tip(view_box, "只改变视图显示方式,不会修改 STEP 几何。") help_tip(view_box, "只改变视图显示方式,不会修改 STEP 几何。")
view_layout = QVBoxLayout(view_box) view_layout = QVBoxLayout(view_box)
view_buttons = QHBoxLayout() view_buttons = QHBoxLayout()
self.isolate_button = QPushButton("只显示选中") 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.isolate_button.clicked.connect(self.isolate_selected)
self.fit_button = QPushButton("对准选中") self.fit_button = QPushButton("对准选中")
help_tip(self.fit_button, "把相机移动到当前选中对象附近,方便看清细节。不会修改模型。") 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) self.show_internal_edges_checkbox.toggled.connect(self._on_internal_edges_toggled)
view_layout.addWidget(self.show_internal_edges_checkbox) view_layout.addWidget(self.show_internal_edges_checkbox)
if ENABLE_MEASURE_PANEL:
measure_box = QGroupBox("测量") measure_box = QGroupBox("测量")
measure_box.setObjectName("measureSection") measure_box.setObjectName("measureSection")
help_tip(measure_box, "把当前选中对象的拾取点或中心设为 A/B,计算两点距离和 X/Y/Z 差值。不会修改模型。") help_tip(measure_box, "把当前选中对象的拾取点或中心设为 A/B,计算两点距离和 X/Y/Z 差值。不会修改模型。")
@@ -551,6 +663,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
measure_layout.addLayout(measure_buttons) measure_layout.addLayout(measure_buttons)
self._refresh_measurement_panel() self._refresh_measurement_panel()
if ENABLE_EXPORT_PANEL:
export_box = QGroupBox("导出") export_box = QGroupBox("导出")
export_box.setObjectName("exportSection") export_box.setObjectName("exportSection")
help_tip(export_box, "把当前模型或选中对象导出为 STEP,也可以做基础质量检查和修复。") help_tip(export_box, "把当前模型或选中对象导出为 STEP,也可以做基础质量检查和修复。")
@@ -568,7 +681,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
help_tip(self.export_face_button, "导出当前选中的面区域;同域高亮的共面/同圆柱区域也会一起导出。") help_tip(self.export_face_button, "导出当前选中的面区域;同域高亮的共面/同圆柱区域也会一起导出。")
self.export_face_button.clicked.connect(self.export_selected_face) self.export_face_button.clicked.connect(self.export_selected_face)
self.export_feature_button = QPushButton("导出选中特征区域") 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_feature_button.clicked.connect(self.export_selected_feature)
self.export_edge_button = QPushButton("导出选中Edge") self.export_edge_button = QPushButton("导出选中Edge")
help_tip(self.export_edge_button, "导出当前选中的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_model_button)
export_layout.addWidget(self.repair_selected_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") 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 = QGridLayout(edit_box)
edit_layout.addWidget(QLabel("Face偏移"), 0, 0) edit_layout.addWidget(QLabel("Face偏移"), 0, 0)
self.offset_input = QLineEdit("5.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_part_button, 24, 0, 1, 2)
edit_layout.addWidget(self.rotate_solid_button, 25, 0, 1, 2) edit_layout.addWidget(self.rotate_solid_button, 25, 0, 1, 2)
self.cylinders_button = QPushButton("扫描圆柱面") panel_layout.addWidget(self.object_edit_box)
help_tip(self.cylinders_button, "扫描模型里的圆柱面候选,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。") if ENABLE_EXPORT_PANEL:
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(export_box) panel_layout.addWidget(export_box)
if ENABLE_VIEW_PANEL:
panel_layout.addWidget(view_box) panel_layout.addWidget(view_box)
if ENABLE_MEASURE_PANEL:
panel_layout.addWidget(measure_box) panel_layout.addWidget(measure_box)
if ENABLE_EDITABLE_OBJECTS_PANEL:
editable_box = QGroupBox("可编辑对象") editable_box = QGroupBox("可编辑对象")
editable_box.setObjectName("editableSection") editable_box.setObjectName("editableSection")
help_tip(editable_box, "自动找出当前版本可以尝试编辑的Face或Edge,方便不用手动到处点。") help_tip(editable_box, "自动找出当前版本可以尝试编辑的Face或Edge,方便不用手动到处点。")
@@ -795,7 +947,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
editable_button_row = QHBoxLayout() editable_button_row = QHBoxLayout()
self.editable_refresh_button = QPushButton("扫描对象") self.editable_refresh_button = QPushButton("扫描对象")
help_tip(self.editable_refresh_button, "快速扫描一批可以尝试编辑的对象,并列在下面表格里。") 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("深度扫描") self.editable_deep_scan_button = QPushButton("深度扫描")
help_tip(self.editable_deep_scan_button, "扫描更多候选对象,结果更全但更慢。默认列表找不到目标时再用。") help_tip(self.editable_deep_scan_button, "扫描更多候选对象,结果更全但更慢。默认列表找不到目标时再用。")
self.editable_deep_scan_button.clicked.connect( self.editable_deep_scan_button.clicked.connect(
@@ -818,11 +972,16 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
editable_layout.addWidget(self.editable_table) editable_layout.addWidget(self.editable_table)
panel_layout.addWidget(editable_box) panel_layout.addWidget(editable_box)
if ENABLE_CYLINDER_CANDIDATES_PANEL:
candidate_box = QGroupBox("圆柱面候选") candidate_box = QGroupBox("圆柱面候选")
candidate_box.setObjectName("candidateSection") candidate_box.setObjectName("candidateSection")
help_tip(candidate_box, "列出圆柱面候选,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。") help_tip(candidate_box, "列出圆柱面候选,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。")
candidate_layout = QVBoxLayout(candidate_box) candidate_layout = QVBoxLayout(candidate_box)
filter_layout = QHBoxLayout() 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("类型")) filter_layout.addWidget(QLabel("类型"))
self.candidate_filter_combo = NoWheelComboBox() self.candidate_filter_combo = NoWheelComboBox()
self.candidate_filter_combo.addItems( self.candidate_filter_combo.addItems(
@@ -845,7 +1004,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
candidate_layout.addLayout(filter_layout) candidate_layout.addLayout(filter_layout)
self.cylinder_table = QTableWidget(0, 8) self.cylinder_table = QTableWidget(0, 8)
self.cylinder_table.setHorizontalHeaderLabels( 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.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.cylinder_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.cylinder_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
@@ -857,6 +1016,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
candidate_layout.addWidget(self.cylinder_table) candidate_layout.addWidget(self.cylinder_table)
panel_layout.addWidget(candidate_box) panel_layout.addWidget(candidate_box)
if ENABLE_HISTORY_PANEL:
history_box = QGroupBox("操作历史") history_box = QGroupBox("操作历史")
history_box.setObjectName("historySection") history_box.setObjectName("historySection")
help_tip(history_box, "查看已经成功执行的编辑、差异预览,并导出报告或 JSON 历史。") 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(self.on_history_row_changed)
self.history_list.currentRowChanged.connect(lambda _row: self._update_action_states()) self.history_list.currentRowChanged.connect(lambda _row: self._update_action_states())
history_buttons = QHBoxLayout() 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("清除差异预览") self.clear_diff_button = QPushButton("清除差异预览")
help_tip(self.clear_diff_button, "关闭历史记录产生的红/绿差异叠加和热力图显示。不会修改模型。") help_tip(self.clear_diff_button, "关闭历史记录产生的红/绿差异叠加和热力图显示。不会修改模型。")
self.clear_diff_button.clicked.connect(lambda _checked=False: self.clear_diff_preview()) 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,方便留档或后续复盘。") help_tip(self.export_history_button, "把本次会话里的所有编辑记录导出为 JSON,方便留档或后续复盘。")
self.export_history_button.clicked.connect(self.export_operation_history) self.export_history_button.clicked.connect(self.export_operation_history)
history_layout.addWidget(self.history_list) 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.clear_diff_button)
history_buttons.addWidget(self.export_diff_button) history_buttons.addWidget(self.export_diff_button)
history_buttons.addWidget(self.export_history_button) history_buttons.addWidget(self.export_history_button)
@@ -889,7 +1057,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
info_layout = QVBoxLayout(info_box) info_layout = QVBoxLayout(info_box)
info_buttons = QHBoxLayout() info_buttons = QHBoxLayout()
self.copy_id_button = QPushButton("复制 ID") 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_id_button.clicked.connect(self.copy_selected_id)
self.copy_pick_button = QPushButton("复制坐标") self.copy_pick_button = QPushButton("复制坐标")
help_tip(self.copy_pick_button, "复制最近一次鼠标点到模型上的三维坐标。") help_tip(self.copy_pick_button, "复制最近一次鼠标点到模型上的三维坐标。")
+1 -1
View File
@@ -80,7 +80,7 @@ class ExportMixin:
part = self.part_by_id(target_id) part = self.part_by_id(target_id)
if part is None: if part is None:
raise ValueError(f"Unknown part id {target_id}") 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 info["part_id"] = part.id
return info return info
if scope == "solid": if scope == "solid":
+1 -1
View File
@@ -986,7 +986,7 @@ class FeatureMixin:
"cutter_strategy": "bounded-to-selected-cylinder-v-range", "cutter_strategy": "bounded-to-selected-cylinder-v-range",
"cutter_note": ( "cutter_note": (
"有限长度切削:优先按同域圆柱侧壁整体 V 范围生成 cutter," "有限长度切削:优先按同域圆柱侧壁整体 V 范围生成 cutter,"
"如果没有同域拆分则退回选中Face范围,减少贯穿整个零件的误切风险。" "如果没有同域拆分则退回选中Face范围,减少贯穿整个特征的误切风险。"
), ),
"cutter_scope_face_ids": axis_range["same_domain_face_ids"], "cutter_scope_face_ids": axis_range["same_domain_face_ids"],
"cutter_scope_face_count": axis_range["same_domain_face_count"], "cutter_scope_face_count": axis_range["same_domain_face_count"],
+16 -5
View File
@@ -197,7 +197,7 @@ def _shape_quality_info(label: str, shape: TopoDS_Shape, expect_solid: bool) ->
elif expect_solid and solids > 1: elif expect_solid and solids > 1:
warnings.append( warnings.append(
f"检测到 {solids} 个Solid。" f"检测到 {solids} 个Solid。"
"如果这不是有意的多实体零件,导出后可能看起来像多个体叠在一起或彼此分离。" "如果这不是有意的多实体特征,导出后可能看起来像多个体叠在一起或彼此分离。"
) )
geometry_info = _shape_volume_info(shape) 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: def _finalize_boolean_result(op, operation_name: str) -> TopoDS_Shape:
op.SetNonDestructive(True) op.SetNonDestructive(True)
if hasattr(op, "SetGlue"): use_glue = "cut" not in operation_name.lower()
if use_glue and hasattr(op, "SetGlue"):
try: try:
op.SetGlue(BOPAlgo_GlueFull) op.SetGlue(BOPAlgo_GlueFull)
except Exception: except Exception:
@@ -764,7 +765,14 @@ def _cylinder_resize_readiness(
else: else:
diameter_delta = abs(new_diameter - current_diameter) diameter_delta = abs(new_diameter - current_diameter)
delta_ratio = diameter_delta / max(current_diameter, 1e-9) 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") risk = _max_risk(risk, "high")
warnings.append("目标直径变化超过当前直径的 100%,很可能导致大范围误切或布尔失败。") warnings.append("目标直径变化超过当前直径的 100%,很可能导致大范围误切或布尔失败。")
elif delta_ratio > 0.35: elif delta_ratio > 0.35:
@@ -772,8 +780,11 @@ def _cylinder_resize_readiness(
warnings.append("目标直径变化超过当前直径的 35%,请确认预览范围。") warnings.append("目标直径变化超过当前直径的 35%,请确认预览范围。")
if height_estimate > 0 and new_diameter > height_estimate * 2.0: if height_estimate > 0 and new_diameter > height_estimate * 2.0:
risk = _max_risk(risk, "high") status = "blocked"
warnings.append("目标直径超过圆柱面估算高度的 2 倍,几何比例异常。") risk = "blocked"
blockers.append(
"目标直径超过圆柱面估算高度的 2 倍;当前版本无法稳定判断周边材料余量,已阻止。"
)
elif height_estimate > 0 and new_diameter > height_estimate: elif height_estimate > 0 and new_diameter > height_estimate:
risk = _max_risk(risk, "medium") risk = _max_risk(risk, "medium")
warnings.append("目标直径超过圆柱面估算高度,可能不是常规孔径修改。") warnings.append("目标直径超过圆柱面估算高度,可能不是常规孔径修改。")
+6 -2
View File
@@ -27,7 +27,9 @@ class InfoPanelMixin:
self.info_text.setPlainText(self.current_info_text) self.info_text.setPlainText(self.current_info_text)
self._populate_info_tree(info) self._populate_info_tree(info)
self.info_tabs.setCurrentWidget(self.info_tree) 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() self._update_action_states()
def set_plain_info(self, text: str) -> None: def set_plain_info(self, text: str) -> None:
@@ -36,7 +38,9 @@ class InfoPanelMixin:
self.info_tree.clear() self.info_tree.clear()
self.info_text.setPlainText(text) self.info_text.setPlainText(text)
self.info_tabs.setCurrentWidget(self.info_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() self._update_action_states()
def _populate_info_tree(self, info: dict[str, object]) -> None: def _populate_info_tree(self, info: dict[str, object]) -> None:
+36 -1
View File
@@ -9,7 +9,8 @@ from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse
from OCC.Core.BRepBndLib import brepbndlib from OCC.Core.BRepBndLib import brepbndlib
from OCC.Core.BOPAlgo import BOPAlgo_GlueFull 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.BRepCheck import BRepCheck_Analyzer
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet 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 best_edge_id = edge_id
return best_edge_id if best_edge_id is not None else valid_edge_ids[0] 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]: def _adjacent_face_ids_for_edges(self, edge_ids: list[int], face_id: int) -> list[int]:
if not edge_ids: if not edge_ids:
return [] return []
+100 -14
View File
@@ -106,18 +106,18 @@ class OperationMixin:
def repair_part(self, part_id: int) -> str: def repair_part(self, part_id: int) -> str:
part = self.part_by_id(part_id) part = self.part_by_id(part_id)
if part is None: if part is None:
raise ValueError(f"Unknown part id {part_id}") raise ValueError(f"未知零件 ID {part_id}")
if part.shape.IsNull(): 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) before_stats = self.part_topology_stats(part_id)
repaired = _prepare_shape_for_step_export(part.shape) repaired = _prepare_shape_for_step_export(part.shape)
if repaired.IsNull(): if repaired.IsNull():
raise RuntimeError(f"Part {part_id} repair returned a null shape.") raise RuntimeError(f"零件 {part_id} 修复结果为空 shape")
part.shape = repaired part.shape = repaired
self.refresh_topology() self.refresh_topology()
after_stats = self.part_topology_stats(part_id) after_stats = self.part_topology_stats(part_id)
return ( return (
f"Part repair completed: part {part_id}, " f"零件修复完成: 零件 {part_id}, "
f"solids {before_stats.solids}->{after_stats.solids}, " f"solids {before_stats.solids}->{after_stats.solids}, "
f"faces {before_stats.faces}->{after_stats.faces}, " f"faces {before_stats.faces}->{after_stats.faces}, "
f"edges {before_stats.edges}->{after_stats.edges}." 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_risk"] = _max_risk(str(readiness["fillet_risk"]), "high")
readiness["fillet_warnings"] = _join_nonempty( readiness["fillet_warnings"] = _join_nonempty(
readiness["fillet_warnings"], 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"]) 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_risk"] = _max_risk(str(readiness["chamfer_risk"]), "high")
readiness["chamfer_warnings"] = _join_nonempty( readiness["chamfer_warnings"] = _join_nonempty(
readiness["chamfer_warnings"], 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"]) 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_id = int(edge_info.get("part_id", -1))
part = self.part_by_id(part_id) part = self.part_by_id(part_id)
if part is None: if part is None:
return None, "局部边形变不可用:找不到所属Part" return None, "局部边形变不可用:找不到所属零件"
solid = self.solids[solid_id][1] solid = self.solids[solid_id][1]
solid_faces = _explore(solid, TopAbs_FACE) solid_faces = _explore(solid, TopAbs_FACE)
if not solid_faces: if not solid_faces:
@@ -760,7 +760,7 @@ class OperationMixin:
solid_id = int(edge_info.get("solid_id", -1)) solid_id = int(edge_info.get("solid_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None part = self.part_by_id(part_id) if part_id >= 0 else None
if part is None: if part is None:
return None, "平面曲线径向缩放不可用:找不到所属Part" return None, "平面曲线径向缩放不可用:找不到所属零件"
part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) part_solid_count = len(_explore(part.shape, TopAbs_SOLID))
target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part"
scale = float(target_length) / max(current_length, 1e-9) scale = float(target_length) / max(current_length, 1e-9)
@@ -1634,7 +1634,7 @@ class OperationMixin:
or ("solid" if solid_id >= 0 else "part") 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] 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: 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] solid_edge_ids = [edge_id for edge_id in edge_ids if self.edge_solid_ids[edge_id] == solid_id]
if solid_edge_ids: if solid_edge_ids:
@@ -2246,6 +2246,8 @@ class OperationMixin:
return None return None
if face_id < 0 or face_id >= len(self.faces): if face_id < 0 or face_id >= len(self.faces):
return None return None
if len(_explore(self.faces[face_id], TopAbs_WIRE)) > 1:
return None
cap_center = _surface_center(self.faces[face_id]) cap_center = _surface_center(self.faces[face_id])
boundary_edge_ids = self._face_boundary_edge_ids(face_id) boundary_edge_ids = self._face_boundary_edge_ids(face_id)
@@ -2256,7 +2258,7 @@ class OperationMixin:
diagonal = _shape_diagonal(self.shape) diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
outward_dir = gp_Dir(float(outward[0]), float(outward[1]), float(outward[2])) 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: for adjacent_id in adjacent_face_ids:
side_surf = BRepAdaptor_Surface(self.faces[adjacent_id]) side_surf = BRepAdaptor_Surface(self.faces[adjacent_id])
@@ -2312,10 +2314,18 @@ class OperationMixin:
"start_parameter": new_min, "start_parameter": new_min,
"end_parameter": new_max, "end_parameter": new_max,
} }
if best is None or score < best[0]: candidates.append((score, candidate))
best = (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: def resize_existing_fillet(self, face_id: int, target_radius: float) -> str:
plan = self.existing_fillet_resize_plan(face_id, target_radius) plan = self.existing_fillet_resize_plan(face_id, target_radius)
@@ -2441,6 +2451,7 @@ class OperationMixin:
raise ValueError(f"Unknown part id {part_id}") raise ValueError(f"Unknown part id {part_id}")
direction = cyl.Axis().Direction() direction = cyl.Axis().Direction()
old_part_shape = part.shape
source_shape = part.shape source_shape = part.shape
if plan["resize_mode"] == "shrink": if plan["resize_mode"] == "shrink":
fill_start = gp_Pnt(*plan["fill_start_point"]) fill_start = gp_Pnt(*plan["fill_start_point"])
@@ -2461,14 +2472,89 @@ class OperationMixin:
result = _finalize_boolean_result(op, "cylinder cut") result = _finalize_boolean_result(op, "cylinder cut")
part.shape = result part.shape = result
self.refresh_topology() 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" action = "enlarged by bounded cut" if plan["resize_mode"] == "enlarge" else "shrunk by fill and recut"
return ( return (
f"Cylindrical resize completed: diameter {old_radius * 2.0:g} -> {new_diameter:g}, " f"Cylindrical resize completed: diameter {old_radius * 2.0:g} -> {new_diameter:g}, "
f"mode={plan['resize_mode']}, action={action}, " f"mode={plan['resize_mode']}, action={action}, "
f"risk={plan['risk']}, feature={plan['feature_guess']}, " 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: def resize_cylindrical_boss(self, face_id: int, new_diameter: float) -> str:
plan = self.cylindrical_boss_resize_plan(face_id, new_diameter) plan = self.cylindrical_boss_resize_plan(face_id, new_diameter)
if plan["status"] == "blocked": if plan["status"] == "blocked":
+6 -6
View File
@@ -94,7 +94,7 @@ class TransformMixin:
risk = readiness["translate_risk"] risk = readiness["translate_risk"]
status = readiness["translate_status"] status = readiness["translate_status"]
if part_solid_count <= 1 and status != "blocked": 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": if risk == "low":
risk = "medium" risk = "medium"
status = "caution" status = "caution"
@@ -119,12 +119,12 @@ class TransformMixin:
raise ValueError(str(plan["message"])) raise ValueError(str(plan["message"]))
part = self.part_by_id(part_id) part = self.part_by_id(part_id)
if part is None: 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) part.shape = _translated_shape_by_vector(part.shape, vector)
_ensure_valid_shape(part.shape) _ensure_valid_shape(part.shape)
self.refresh_topology() self.refresh_topology()
return ( 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']}." f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}."
) )
@@ -192,7 +192,7 @@ class TransformMixin:
risk = readiness["rotate_risk"] risk = readiness["rotate_risk"]
status = readiness["rotate_status"] status = readiness["rotate_status"]
if part_solid_count <= 1 and status != "blocked": 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": if risk == "low":
risk = "medium" risk = "medium"
status = "caution" status = "caution"
@@ -218,12 +218,12 @@ class TransformMixin:
raise ValueError(str(plan["message"])) raise ValueError(str(plan["message"]))
part = self.part_by_id(part_id) part = self.part_by_id(part_id)
if part is None: 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"]) part.shape = _rotated_shape(part.shape, str(plan["rotation_axis"]), float(plan["rotation_angle_degrees"]), plan["rotation_center"])
_ensure_valid_shape(part.shape) _ensure_valid_shape(part.shape)
self.refresh_topology() self.refresh_topology()
return ( 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"angle={float(plan['rotation_angle_degrees']):g}, center={_format_tuple(plan['rotation_center'])}, "
f"risk={plan['risk']}." f"risk={plan['risk']}."
) )
+22 -2
View File
@@ -358,7 +358,7 @@ INFO_LABELS = {
"name": "名称", "name": "名称",
"path": "层级路径", "path": "层级路径",
"file": "文件", "file": "文件",
"part_id": "Part ID", "part_id": "零件 ID",
"solid_id": "Solid ID", "solid_id": "Solid ID",
"logical_face_id": "逻辑 Face ID", "logical_face_id": "逻辑 Face ID",
"face_region_logical_id": "面区域逻辑 ID", "face_region_logical_id": "面区域逻辑 ID",
@@ -694,7 +694,7 @@ INFO_LABELS = {
"translate_warnings": "平移警告", "translate_warnings": "平移警告",
"translate_blockers": "平移阻止原因", "translate_blockers": "平移阻止原因",
"translate_note": "平移说明", "translate_note": "平移说明",
"part_solid_count": "Part 内 Solid 数", "part_solid_count": "零件内 Solid 数",
"rotate_status": "旋转状态", "rotate_status": "旋转状态",
"rotate_risk": "旋转风险", "rotate_risk": "旋转风险",
"rotate_warnings": "旋转警告", "rotate_warnings": "旋转警告",
@@ -713,6 +713,26 @@ EDITABLE_ACTION_ROLE = Qt.UserRole + 1
EDITABLE_TARGET_KIND_ROLE = Qt.UserRole + 2 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: def _format_float(value: float) -> str:
return f"{value:.6g}" return f"{value:.6g}"
+418 -199
View File
@@ -105,7 +105,7 @@ class WindowActionMixin:
if self._edit_busy("请等待当前编辑完成后再导出。"): if self._edit_busy("请等待当前编辑完成后再导出。"):
return return
if self.selected_face_id is None: if self.selected_face_id is None:
QMessageBox.information(self, "未选择 Face", "请先切换到 Face 或 Feature 模式并选择一个 Face。") QMessageBox.information(self, "未选择 Face", "请先切换到 Face 或特征模式并选择一个 Face。")
return return
if not self._confirm_export_quality("face", self.selected_face_id): if not self._confirm_export_quality("face", self.selected_face_id):
return return
@@ -128,7 +128,7 @@ class WindowActionMixin:
if self._edit_busy("请等待当前编辑完成后再导出。"): if self._edit_busy("请等待当前编辑完成后再导出。"):
return return
if self.selected_face_id is None: if self.selected_face_id is None:
QMessageBox.information(self, "未选择特征", "请先切换到 Feature 模式并选择一个局部特征。") QMessageBox.information(self, "未选择特征", "请先切换到特征模式并选择一个特征。")
return return
if not self._confirm_export_quality("feature", self.selected_face_id): if not self._confirm_export_quality("feature", self.selected_face_id):
return return
@@ -281,7 +281,7 @@ class WindowActionMixin:
self._run_edit_action( self._run_edit_action(
action, action,
operation_name="修复选中零件", operation_name="修复选中零件",
target=f"Part {part_id}", target=f"零件 {part_id}",
parameters={ parameters={
"scope": "part", "scope": "part",
"part_id": part_id, "part_id": part_id,
@@ -292,7 +292,7 @@ class WindowActionMixin:
) )
return return
QMessageBox.information(self, "未选择对象", "请先选择 Part、Solid、Face 或 Edge。") QMessageBox.information(self, "未选择对象", "请先选择零件、Solid、Face 或 Edge。")
self.statusBar().showMessage("未选择可修复对象") self.statusBar().showMessage("未选择可修复对象")
def push_pull_face(self) -> None: def push_pull_face(self) -> None:
@@ -436,6 +436,203 @@ class WindowActionMixin:
target_id=face_id, 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: def resize_hole(self) -> None:
if self.model is None: if self.model is None:
return return
@@ -449,64 +646,29 @@ class WindowActionMixin:
except ValueError: except ValueError:
QMessageBox.critical(self, "直径无效", "请输入数字形式的目标直径。") QMessageBox.critical(self, "直径无效", "请输入数字形式的目标直径。")
return 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 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(): def action():
return self.model.resize_cylindrical_hole(face_id, diameter) return self.model.resize_cylindrical_hole(face_id, diameter)
@@ -519,7 +681,7 @@ class WindowActionMixin:
"part_id": plan.get("part_id"), "part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"), "solid_id": plan.get("solid_id"),
"new_diameter": diameter, "new_diameter": diameter,
"old_diameter": info.get("diameter"), "old_diameter": plan.get("current_diameter"),
"delta_diameter": plan.get("delta_diameter"), "delta_diameter": plan.get("delta_diameter"),
"diameter_delta_ratio": plan.get("diameter_delta_ratio"), "diameter_delta_ratio": plan.get("diameter_delta_ratio"),
"target_to_height_ratio": plan.get("target_to_height_ratio"), "target_to_height_ratio": plan.get("target_to_height_ratio"),
@@ -527,8 +689,7 @@ class WindowActionMixin:
"feature_type": plan.get("feature_type"), "feature_type": plan.get("feature_type"),
"feature_bottom_face_ids": plan.get("feature_bottom_face_ids"), "feature_bottom_face_ids": plan.get("feature_bottom_face_ids"),
"feature_opening_face_ids": plan.get("feature_opening_face_ids"), "feature_opening_face_ids": plan.get("feature_opening_face_ids"),
"feature_bottom_note": plan.get("feature_bottom_note"), "feature_guess": plan.get("feature_guess"),
"feature_guess": guess,
"confidence": plan.get("confidence"), "confidence": plan.get("confidence"),
"resize_status": plan.get("status"), "resize_status": plan.get("status"),
"resize_risk": plan.get("risk"), "resize_risk": plan.get("risk"),
@@ -538,23 +699,8 @@ class WindowActionMixin:
"material_vote_summary": plan.get("material_vote_summary"), "material_vote_summary": plan.get("material_vote_summary"),
"cylinder_end_type": plan.get("cylinder_end_type"), "cylinder_end_type": plan.get("cylinder_end_type"),
"hole_depth_estimate": plan.get("hole_depth_estimate"), "hole_depth_estimate": plan.get("hole_depth_estimate"),
"start_end_state": plan.get("start_end_state"), "quick_preflight": True,
"end_end_state": plan.get("end_end_state"), "ui_preview": "skipped-to-avoid-ui-freeze",
"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"),
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
@@ -580,7 +726,7 @@ class WindowActionMixin:
return return
face_id = self.selected_face_id 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": if info.get("surface") != "cylinder" or info.get("slot_kind") != "partial-cylindrical-groove":
QMessageBox.information(self, "不是槽/半孔候选", "当前选中Face没有被识别为槽/半孔候选。") QMessageBox.information(self, "不是槽/半孔候选", "当前选中Face没有被识别为槽/半孔候选。")
return return
@@ -597,37 +743,24 @@ class WindowActionMixin:
target_diameter = target_width / sin_half_span target_diameter = target_width / sin_half_span
current_width = _float_or_none(info.get("slot_chord_width_estimate")) current_width = _float_or_none(info.get("slot_chord_width_estimate"))
plan = self.model.cylindrical_resize_plan(face_id, target_diameter) plan = self._quick_cylinder_resize_plan(face_id, target_diameter, info)
if plan["status"] == "blocked": lines = [
QMessageBox.information(self, "不能调整槽/半孔宽度", str(plan["message"])) f"Face: {face_id}",
self.statusBar().showMessage("槽/半孔宽度调整已阻止") 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 return
self.clear_edit_preview(render=False)
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)
def action(): def action():
return self.model.resize_cylindrical_hole(face_id, target_diameter) 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_width_delta": None if current_width is None else target_width - current_width,
"slot_angular_span": angular_span, "slot_angular_span": angular_span,
"derived_new_diameter": target_diameter, "derived_new_diameter": target_diameter,
"old_diameter": info.get("diameter"), "old_diameter": plan.get("current_diameter"),
"delta_diameter": plan.get("delta_diameter"), "delta_diameter": plan.get("delta_diameter"),
"diameter_delta_ratio": plan.get("diameter_delta_ratio"), "diameter_delta_ratio": plan.get("diameter_delta_ratio"),
"resize_mode": plan.get("resize_mode"), "resize_mode": plan.get("resize_mode"),
@@ -664,9 +797,8 @@ class WindowActionMixin:
"hole_depth_estimate": plan.get("hole_depth_estimate"), "hole_depth_estimate": plan.get("hole_depth_estimate"),
"feature_bottom_face_ids": plan.get("feature_bottom_face_ids"), "feature_bottom_face_ids": plan.get("feature_bottom_face_ids"),
"feature_opening_face_ids": plan.get("feature_opening_face_ids"), "feature_opening_face_ids": plan.get("feature_opening_face_ids"),
"cutter_strategy": plan.get("cutter_strategy"), "quick_preflight": True,
"cutter_height": plan.get("cutter_height"), "ui_preview": "skipped-to-avoid-ui-freeze",
"fill_strategy": plan.get("fill_strategy"),
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
@@ -723,7 +855,7 @@ class WindowActionMixin:
f"风险: {plan['risk']}\n\n" f"风险: {plan['risk']}\n\n"
f"{warnings_line}" f"{warnings_line}"
f"{plan['message']}\n\n" f"{plan['message']}\n\n"
"继续操作会对当前零件执行受限B-Rep布尔修改。\n\n" "继续操作会对当前零件的局部几何执行受限B-Rep布尔修改。\n\n"
"确定继续吗?" "确定继续吗?"
), ),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
@@ -877,6 +1009,111 @@ class WindowActionMixin:
raise ValueError(f"底面 Face ID {bottom_face_id} 不存在。") raise ValueError(f"底面 Face ID {bottom_face_id} 不存在。")
return 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: def resize_hole_depth(self) -> None:
if self.model is None: if self.model is None:
return return
@@ -896,62 +1133,30 @@ class WindowActionMixin:
QMessageBox.critical(self, "底面 Face ID 无效", str(exc)) QMessageBox.critical(self, "底面 Face ID 无效", str(exc))
return 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 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(): def action():
return self.model.resize_cylindrical_depth( return self.model.resize_cylindrical_depth(
@@ -989,18 +1194,8 @@ class WindowActionMixin:
"feature_bottom_confidence": plan.get("feature_bottom_confidence"), "feature_bottom_confidence": plan.get("feature_bottom_confidence"),
"feature_bottom_detection": plan.get("feature_bottom_detection"), "feature_bottom_detection": plan.get("feature_bottom_detection"),
"feature_bottom_note": plan.get("feature_bottom_note"), "feature_bottom_note": plan.get("feature_bottom_note"),
"depth_tool_strategy": plan.get("depth_tool_strategy"), "quick_preflight": True,
"depth_tool_role": plan.get("depth_tool_role"), "ui_preview": "skipped-to-avoid-ui-freeze",
"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"),
}, },
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
@@ -1135,7 +1330,7 @@ class WindowActionMixin:
f"风险: {plan['risk']}\n\n" f"风险: {plan['risk']}\n\n"
f"{warnings_line}" f"{warnings_line}"
f"{plan['message']}\n\n" f"{plan['message']}\n\n"
"继续操作会调用OCCT倒圆并真实修改当前零件。\n\n" "继续操作会调用OCCT倒圆并真实修改当前零件的局部几何\n\n"
"确定继续吗?" "确定继续吗?"
), ),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
@@ -1212,7 +1407,7 @@ class WindowActionMixin:
f"风险: {plan['risk']}\n\n" f"风险: {plan['risk']}\n\n"
f"{warnings_line}" f"{warnings_line}"
f"{plan['message']}\n\n" f"{plan['message']}\n\n"
"继续操作会调用OCCT倒角并真实修改当前零件。\n\n" "继续操作会调用OCCT倒角并真实修改当前零件的局部几何\n\n"
"确定继续吗?" "确定继续吗?"
), ),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
@@ -1546,7 +1741,7 @@ class WindowActionMixin:
if self._edit_busy("请等待当前编辑完成后再平移零件。"): if self._edit_busy("请等待当前编辑完成后再平移零件。"):
return return
if self.selected_part_id is None: if self.selected_part_id is None:
QMessageBox.information(self, "未选择零件", "请先选择一个Part,或选择属于某个Part的对象。") QMessageBox.information(self, "未选择零件", "请先选择一个零件,或选择属于某个零件的对象。")
return return
try: try:
vector = self._translation_vector_from_inputs() vector = self._translation_vector_from_inputs()
@@ -1569,7 +1764,7 @@ class WindowActionMixin:
self._run_edit_action( self._run_edit_action(
action, action,
operation_name="平移零件", operation_name="平移零件",
target=f"Part {part_id}", target=f"零件 {part_id}",
parameters={ parameters={
"part_id": part_id, "part_id": part_id,
"translation_vector": vector, "translation_vector": vector,
@@ -1652,7 +1847,7 @@ class WindowActionMixin:
title, title,
( (
f"对象: {plan.get('target_kind', '')}\n" 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"Solid: {_format_value(plan.get('solid_id', ''))}\n"
f"平移向量: {_format_value(plan['translation_vector'])}\n" f"平移向量: {_format_value(plan['translation_vector'])}\n"
f"平移距离: {_format_value(plan['translation_distance'])}\n" f"平移距离: {_format_value(plan['translation_distance'])}\n"
@@ -1674,7 +1869,7 @@ class WindowActionMixin:
if self._edit_busy("请等待当前编辑完成后再旋转零件。"): if self._edit_busy("请等待当前编辑完成后再旋转零件。"):
return return
if self.selected_part_id is None: if self.selected_part_id is None:
QMessageBox.information(self, "未选择零件", "请先选择一个Part,或选择属于某个Part的对象。") QMessageBox.information(self, "未选择零件", "请先选择一个零件,或选择属于某个零件的对象。")
return return
try: try:
axis, angle = self._rotation_values_from_inputs() axis, angle = self._rotation_values_from_inputs()
@@ -1697,7 +1892,7 @@ class WindowActionMixin:
self._run_edit_action( self._run_edit_action(
action, action,
operation_name="旋转零件", operation_name="旋转零件",
target=f"Part {part_id}", target=f"零件 {part_id}",
parameters={ parameters={
"part_id": part_id, "part_id": part_id,
"rotation_axis": plan.get("rotation_axis"), "rotation_axis": plan.get("rotation_axis"),
@@ -1779,7 +1974,7 @@ class WindowActionMixin:
title, title,
( (
f"对象: {plan.get('target_kind', '')}\n" 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"Solid: {_format_value(plan.get('solid_id', ''))}\n"
f"旋转轴: {_format_value(plan['rotation_axis'])}\n" f"旋转轴: {_format_value(plan['rotation_axis'])}\n"
f"旋转角度: {_format_value(plan['rotation_angle_degrees'])}\n" f"旋转角度: {_format_value(plan['rotation_angle_degrees'])}\n"
@@ -1923,6 +2118,10 @@ class WindowActionMixin:
scan_label: str, scan_label: str,
limit: int, limit: int,
) -> None: ) -> None:
if not hasattr(self, "editable_table"):
if show_info:
self.set_plain_info("可编辑对象面板当前未启用。")
return
self.editable_table.setRowCount(len(candidates)) self.editable_table.setRowCount(len(candidates))
lines = [f"可编辑对象:显示 {len(candidates)} 个({scan_label}上限 {limit}"] lines = [f"可编辑对象:显示 {len(candidates)} 个({scan_label}上限 {limit}"]
for row, item in enumerate(candidates): for row, item in enumerate(candidates):
@@ -1956,7 +2155,7 @@ class WindowActionMixin:
self.editable_table.setItem(row, column, table_item) self.editable_table.setItem(row, column, table_item)
lines.append( lines.append(
f"{target_kind} {target_id}: {item['operation']}, " 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"object={item['feature_guess']}, "
f"{item['current_value_label']}={_format_value(item['current_value'])}, " f"{item['current_value_label']}={_format_value(item['current_value'])}, "
f"status={item['status']}, risk={item['risk']}, " f"status={item['status']}, risk={item['risk']}, "
@@ -2006,6 +2205,10 @@ class WindowActionMixin:
self.statusBar().showMessage("圆柱面候选扫描失败") self.statusBar().showMessage("圆柱面候选扫描失败")
def _filter_cached_cylinder_candidates(self, show_info: bool = False) -> None: 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: if not self.cylinder_candidates_loaded:
self.cylinder_table.setRowCount(0) self.cylinder_table.setRowCount(0)
if show_info: if show_info:
@@ -2019,6 +2222,10 @@ class WindowActionMixin:
self._populate_cylinder_table(candidates, show_info=show_info) self._populate_cylinder_table(candidates, show_info=show_info)
def _populate_cylinder_table(self, candidates: list[dict[str, object]], show_info: bool = True) -> None: 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)) self.cylinder_table.setRowCount(len(candidates))
lines = [f"圆柱面候选:显示 {len(candidates)}"] lines = [f"圆柱面候选:显示 {len(candidates)}"]
for row, item in enumerate(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, 6, risk_item)
self.cylinder_table.setItem(row, 7, part_item) self.cylinder_table.setItem(row, 7, part_item)
lines.append( 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"guess={item['feature_guess']}, "
f"diameter={_format_float(float(item['diameter']))}, " f"diameter={_format_float(float(item['diameter']))}, "
f"height={_format_float(float(item['height_estimate']))}, " f"height={_format_float(float(item['height_estimate']))}, "
@@ -2053,6 +2260,8 @@ class WindowActionMixin:
self.set_plain_info("\n".join(lines)) self.set_plain_info("\n".join(lines))
def _candidate_matches_filter(self, feature_guess: str) -> bool: def _candidate_matches_filter(self, feature_guess: str) -> bool:
if not hasattr(self, "candidate_filter_combo"):
return True
current = self.candidate_filter_combo.currentText() current = self.candidate_filter_combo.currentText()
if current == "All": if current == "All":
return True return True
@@ -2243,13 +2452,15 @@ class WindowActionMixin:
if "推拉" in operation_name: if "推拉" in operation_name:
progress_text = f"{operation_name} 正在后台计算,半透明预览会持续渲染,3D 视图仍可旋转查看。" progress_text = f"{operation_name} 正在后台计算,半透明预览会持续渲染,3D 视图仍可旋转查看。"
elif "孔径" in operation_name: 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: elif "凸台" in operation_name:
progress_text = f"{operation_name} 正在后台计算,绿色预览表示扩大补料范围,红色预览表示缩小切削范围。" progress_text = f"{operation_name} 正在后台计算,绿色预览表示扩大补料范围,红色预览表示缩小切削范围。"
elif "封堵" in operation_name: elif "封堵" in operation_name:
progress_text = f"{operation_name} 正在后台计算,绿色预览表示封堵补料范围。" progress_text = f"{operation_name} 正在后台计算,绿色预览表示封堵补料范围。"
elif "孔深" in operation_name: elif "孔深" in operation_name:
progress_text = f"{operation_name} 正在后台计算,红色预览表示加深切削范围,绿色预览表示变浅补料范围" progress_text = f"{operation_name} 正在后台计算;为避免复杂模型卡顿,已跳过同步孔深预览"
elif "圆角" in operation_name: elif "圆角" in operation_name:
if "已有" in operation_name: if "已有" in operation_name:
progress_text = f"{operation_name} 正在后台计算,蓝色预览表示将移除并重建的已有圆角面。" progress_text = f"{operation_name} 正在后台计算,蓝色预览表示将移除并重建的已有圆角面。"
@@ -2276,6 +2487,9 @@ class WindowActionMixin:
@Slot(object) @Slot(object)
def _finish_edit_action(self, result: object) -> None: 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 context = self.pending_edit_context
if context is None: if context is None:
self._end_edit_task(clear_preview=True) 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)) show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False))
) )
self.clear_edit_preview(render=False) self.clear_edit_preview(render=False)
self._reset_selection()
self._populate_part_tree() self._populate_part_tree()
self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=False) self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=False)
locator_note = self._locate_operation_record(record)
except Exception as exc: except Exception as exc:
rollback_message = self._restore_failed_edit_snapshot(result.get("snapshot") if isinstance(result, dict) else None) rollback_message = self._restore_failed_edit_snapshot(result.get("snapshot") if isinstance(result, dict) else None)
self._end_edit_task(clear_preview=True) self._end_edit_task(clear_preview=True)
@@ -2340,11 +2554,16 @@ class WindowActionMixin:
if result.get("quality_warnings"): if result.get("quality_warnings"):
self.statusBar().showMessage("编辑完成,但有质量警告,请查看操作历史详情") self.statusBar().showMessage("编辑完成,但有质量警告,请查看操作历史详情")
else: else:
self.statusBar().showMessage(message) selection_note = ";已保持当前选择" if self.selected_kind is not None else ""
self.set_plain_info(record.detail) 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) @Slot(str)
def _fail_edit_action(self, message: str) -> None: 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) self._end_edit_task(clear_preview=True)
QMessageBox.critical(self, "操作失败", message) QMessageBox.critical(self, "操作失败", message)
self._clear_editable_candidates() self._clear_editable_candidates()
+109 -44
View File
@@ -21,7 +21,24 @@ from .ui_helpers import * # noqa: F403
from .workers import EditWorker, LoadWorker, ScanWorker from .workers import EditWorker, LoadWorker, ScanWorker
VIEW_BACKGROUND_COLOR = (226 / 255.0, 238 / 255.0, 247 / 255.0)
class WindowCoreMixin: 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): def eventFilter(self, watched, event):
if watched is getattr(self, "vtk_widget", None): if watched is getattr(self, "vtk_widget", None):
event_type = event.type() event_type = event.type()
@@ -37,7 +54,7 @@ class WindowCoreMixin:
def _build_vtk(self) -> None: def _build_vtk(self) -> None:
self.renderer = vtk.vtkRenderer() 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 = self.vtk_widget.GetRenderWindow()
self.render_window.AddRenderer(self.renderer) self.render_window.AddRenderer(self.renderer)
@@ -185,7 +202,10 @@ class WindowCoreMixin:
self._clear_history() self._clear_history()
if hasattr(self, "measure_text"): if hasattr(self, "measure_text"):
self.clear_measurement() 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._populate_part_tree()
self._reset_selection() self._reset_selection()
model_polydata = result.get("model_polydata") model_polydata = result.get("model_polydata")
@@ -314,10 +334,13 @@ class WindowCoreMixin:
self.clear_diff_preview(render=False) self.clear_diff_preview(render=False)
if hasattr(self, "history_list"): if hasattr(self, "history_list"):
self.history_list.clear() self.history_list.clear()
if hasattr(self, "export_all_button"): if hasattr(self, "mode_combo"):
self._update_action_states() self._update_action_states()
def _refresh_history_list(self) -> None: def _refresh_history_list(self) -> None:
if not hasattr(self, "history_list"):
self._update_action_states()
return
was_blocked = self.history_list.blockSignals(True) was_blocked = self.history_list.blockSignals(True)
try: try:
self.history_list.clear() self.history_list.clear()
@@ -346,6 +369,9 @@ class WindowCoreMixin:
return return
if self._edit_busy("编辑计算中,暂时不能导出差异报告。"): if self._edit_busy("编辑计算中,暂时不能导出差异报告。"):
return return
if not hasattr(self, "history_list"):
QMessageBox.information(self, "操作历史未启用", "当前界面没有构建操作历史面板。")
return
row = self.history_list.currentRow() row = self.history_list.currentRow()
if row < 0 or row >= len(self.operation_history): if row < 0 or row >= len(self.operation_history):
QMessageBox.information(self, "未选择历史记录", "请先选择一条操作历史。") QMessageBox.information(self, "未选择历史记录", "请先选择一条操作历史。")
@@ -436,6 +462,8 @@ class WindowCoreMixin:
self.set_plain_info(f"已导出编辑历史:{target}\n\n记录数:{len(records)}") self.set_plain_info(f"已导出编辑历史:{target}\n\n记录数:{len(records)}")
def _populate_part_tree(self) -> None: def _populate_part_tree(self) -> None:
if not hasattr(self, "part_tree"):
return
was_blocked = self.part_tree.blockSignals(True) was_blocked = self.part_tree.blockSignals(True)
try: try:
self.part_tree.clear() self.part_tree.clear()
@@ -501,11 +529,11 @@ class WindowCoreMixin:
def _part_tree_part_tooltip(self, part_id: int) -> str: def _part_tree_part_tooltip(self, part_id: int) -> str:
if self.model is None: if self.model is None:
return f"Part ID: {part_id}" return f"零件 ID: {part_id}"
try: try:
info = self.model.part_info(part_id) info = self.model.part_info(part_id)
except Exception: except Exception:
return f"Part ID: {part_id}" return f"零件 ID: {part_id}"
lines = [ lines = [
f"ID: {part_id}", f"ID: {part_id}",
f"类型: {_part_tree_kind_label(str(info.get('kind', '')))}", 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: if self.selected_kind == "part" and self.selected_part_id is not None:
part_ids = [self.selected_part_id] 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: 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] 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) 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) info = self.model.feature_info(self.selected_face_id)
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [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")) 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: 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] 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) 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] edge_ids = [self.selected_edge_id]
label = f"Edge {self.selected_edge_id}" label = f"Edge {self.selected_edge_id}"
else: else:
QMessageBox.information(self, "未选择对象", "请先选择 Part、Solid、Face、Edge 或 Feature") QMessageBox.information(self, "未选择对象", "请先选择零件、Solid、Face、Edge 或特征")
return return
model_polydata = self.model.build_face_polydata(face_ids=face_ids, part_ids=part_ids) model_polydata = self.model.build_face_polydata(face_ids=face_ids, part_ids=part_ids)
@@ -610,7 +638,7 @@ class WindowCoreMixin:
return return
bounds = self._selected_focus_bounds() bounds = self._selected_focus_bounds()
if bounds is None: if bounds is None:
QMessageBox.information(self, "未选择对象", "请先选择 Part、Solid、Face、Edge 或 Feature") QMessageBox.information(self, "未选择对象", "请先选择零件、Solid、Face、Edge 或特征")
return return
self._fit_camera_to_bounds(bounds) self._fit_camera_to_bounds(bounds)
self.render_window.Render() 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: def _rebuild_scene_from_polydata(self, model_polydata, edge_polydata, reset_camera: bool = False) -> None:
self._clear_overlay_polydata_cache() self._clear_overlay_polydata_cache()
self.renderer.RemoveAllViewProps() self.renderer.RemoveAllViewProps()
self.renderer.SetBackground(0.11, 0.13, 0.15) self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR)
self.scene_isolated = False self.scene_isolated = False
self.model_polydata = _smooth_surface_polydata(model_polydata) self.model_polydata = _smooth_surface_polydata(model_polydata)
@@ -808,10 +836,20 @@ class WindowCoreMixin:
def _on_mode_changed(self, mode: str) -> None: def _on_mode_changed(self, mode: str) -> None:
self._clear_hover(render=True) self._clear_hover(render=True)
if hasattr(self, "id_mode_display"): if hasattr(self, "_update_id_select_title"):
self.id_mode_display.setText(mode) self._update_id_select_title(mode)
def on_left_click(self, _obj, _event) -> None: 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.pointer_button_down = True
self.pending_hover_position = None self.pending_hover_position = None
self.last_hover_pick_position = None self.last_hover_pick_position = None
@@ -827,8 +865,7 @@ class WindowCoreMixin:
return return
if self.model is None: if self.model is None:
return return
mode = self.mode_combo.currentText() mode = self._current_selection_mode()
x, y = self.interactor.GetEventPosition()
target = self._pick_selection_target(mode, x, y) target = self._pick_selection_target(mode, x, y)
if target is None: if target is None:
self.statusBar().showMessage("未选中对象") self.statusBar().showMessage("未选中对象")
@@ -838,6 +875,12 @@ class WindowCoreMixin:
self._select_pick_target(target) self._select_pick_target(target)
def on_pointer_button_press(self, _obj, _event) -> None: 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.pointer_button_down = True
self.pending_hover_position = None self.pending_hover_position = None
self.last_hover_pick_position = None self.last_hover_pick_position = None
@@ -845,11 +888,27 @@ class WindowCoreMixin:
self._clear_hover(render=True) self._clear_hover(render=True)
def on_pointer_button_release(self, _obj, _event) -> None: 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.pointer_button_down = False
self.pending_hover_position = None self.pending_hover_position = None
self.last_hover_pick_position = None self.last_hover_pick_position = None
def on_mouse_move(self, _obj, _event) -> 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: if QApplication.mouseButtons() != Qt.MouseButton.NoButton:
self.pointer_button_down = True self.pointer_button_down = True
return return
@@ -863,8 +922,7 @@ class WindowCoreMixin:
): ):
self._clear_hover(render=True) self._clear_hover(render=True)
return return
x, y = self.interactor.GetEventPosition() self._queue_hover_position(x, y)
self._queue_hover_position(int(x), int(y))
def _queue_hover_from_qt_event(self, event) -> None: def _queue_hover_from_qt_event(self, event) -> None:
if ( if (
@@ -883,6 +941,9 @@ class WindowCoreMixin:
self._queue_hover_position(x, y) self._queue_hover_position(x, y)
def _queue_hover_position(self, x: int, y: int) -> None: 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)) position = (int(x), int(y))
threshold = int(getattr(self, "hover_move_threshold_px", 0) or 0) threshold = int(getattr(self, "hover_move_threshold_px", 0) or 0)
if threshold > 0: if threshold > 0:
@@ -909,7 +970,7 @@ class WindowCoreMixin:
return return
x, y = self.pending_hover_position x, y = self.pending_hover_position
self.last_hover_pick_position = (x, y) 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) self._show_hover_target(target)
def _pick_selection_target(self, mode: str, x: int, y: int) -> dict[str, object] | None: 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) self.select_edge(edge_id, pick_position=pick_position)
def on_part_tree_select(self, current: QTreeWidgetItem | None, _previous: QTreeWidgetItem | None) -> None: def on_part_tree_select(self, current: QTreeWidgetItem | None, _previous: QTreeWidgetItem | None) -> None:
if self._edit_busy("编辑计算中,暂时不能切换零件树选择。"): if self._edit_busy("编辑计算中,暂时不能切换结构树选择。"):
return return
if current is None: if current is None:
return return
@@ -1110,14 +1171,16 @@ class WindowCoreMixin:
if node_kind == "solid" and target_id is not None: if node_kind == "solid" and target_id is not None:
part_id = current.data(0, PART_TREE_PART_ID_ROLE) part_id = current.data(0, PART_TREE_PART_ID_ROLE)
if part_id is not None: 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)) self.select_solid(int(target_id), int(part_id))
return return
if node_kind in {"part", "assembly"} and target_id is not None: 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)) self.select_part(int(target_id))
def on_cylinder_row_clicked(self, row: int, _column: int) -> None: def on_cylinder_row_clicked(self, row: int, _column: int) -> None:
if not hasattr(self, "cylinder_table"):
return
if self._edit_busy("编辑计算中,暂时不能切换圆柱面候选。"): if self._edit_busy("编辑计算中,暂时不能切换圆柱面候选。"):
return return
item = self.cylinder_table.item(row, 0) item = self.cylinder_table.item(row, 0)
@@ -1126,10 +1189,12 @@ class WindowCoreMixin:
face_id = item.data(Qt.UserRole) face_id = item.data(Qt.UserRole)
if face_id is None: if face_id is None:
return return
self.mode_combo.setCurrentText("Feature") self._set_selection_mode("Feature")
self.select_feature(int(face_id)) self.select_feature(int(face_id))
def on_editable_row_clicked(self, row: int, _column: int) -> None: def on_editable_row_clicked(self, row: int, _column: int) -> None:
if not hasattr(self, "editable_table"):
return
if self._edit_busy("编辑计算中,暂时不能切换可编辑对象。"): if self._edit_busy("编辑计算中,暂时不能切换可编辑对象。"):
return return
item = self.editable_table.item(row, 0) item = self.editable_table.item(row, 0)
@@ -1141,51 +1206,51 @@ class WindowCoreMixin:
if target_id is None: if target_id is None:
return return
if action == "resize_cylinder": if action == "resize_cylinder":
self.mode_combo.setCurrentText("Feature") self._set_selection_mode("Feature")
self.select_feature(int(target_id)) self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整孔径候选Face {target_id}") self.statusBar().showMessage(f"已选择可调整孔径候选Face {target_id}")
elif action == "resize_boss": elif action == "resize_boss":
self.mode_combo.setCurrentText("Feature") self._set_selection_mode("Feature")
self.select_feature(int(target_id)) self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整凸台直径候选Face {target_id}") self.statusBar().showMessage(f"已选择可调整凸台直径候选Face {target_id}")
elif action == "resize_slot_width": elif action == "resize_slot_width":
self.mode_combo.setCurrentText("Feature") self._set_selection_mode("Feature")
self.select_feature(int(target_id)) self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整槽/半孔宽度候选Face {target_id}") self.statusBar().showMessage(f"已选择可调整槽/半孔宽度候选Face {target_id}")
elif action == "resize_shell_thickness": elif action == "resize_shell_thickness":
self.mode_combo.setCurrentText("Feature") self._set_selection_mode("Feature")
self.select_feature(int(target_id)) self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整薄壁厚度候选Face {target_id}") self.statusBar().showMessage(f"已选择可调整薄壁厚度候选Face {target_id}")
elif action == "suppress_cylinder": elif action == "suppress_cylinder":
self.mode_combo.setCurrentText("Feature") self._set_selection_mode("Feature")
self.select_feature(int(target_id)) self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可封堵圆柱孔Face {target_id}") self.statusBar().showMessage(f"已选择可封堵圆柱孔Face {target_id}")
elif action == "resize_depth": elif action == "resize_depth":
self.mode_combo.setCurrentText("Feature") self._set_selection_mode("Feature")
self.select_feature(int(target_id)) self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整盲孔深度候选Face {target_id}") self.statusBar().showMessage(f"已选择可调整盲孔深度候选Face {target_id}")
elif action == "inspect_existing_fillet": elif action == "inspect_existing_fillet":
self.mode_combo.setCurrentText("Feature") self._set_selection_mode("Feature")
self.select_feature(int(target_id)) self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择已有圆角/倒圆候选Face {target_id}") self.statusBar().showMessage(f"已选择已有圆角/倒圆候选Face {target_id}")
elif action == "fillet_edge": elif action == "fillet_edge":
self.mode_combo.setCurrentText("Edge") self._set_selection_mode("Edge")
self.select_edge(int(target_id)) self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可添加圆角Edge {target_id}") self.statusBar().showMessage(f"已选择可添加圆角Edge {target_id}")
elif action == "chamfer_edge": elif action == "chamfer_edge":
self.mode_combo.setCurrentText("Edge") self._set_selection_mode("Edge")
self.select_edge(int(target_id)) self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可添加倒角Edge {target_id}") self.statusBar().showMessage(f"已选择可添加倒角Edge {target_id}")
elif action == "resize_edge_length": elif action == "resize_edge_length":
self.mode_combo.setCurrentText("Edge") self._set_selection_mode("Edge")
self.select_edge(int(target_id)) self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可尝试调整长度的Edge {target_id}") self.statusBar().showMessage(f"已选择可尝试调整长度的Edge {target_id}")
elif target_kind == "edge": elif target_kind == "edge":
self.mode_combo.setCurrentText("Edge") self._set_selection_mode("Edge")
self.select_edge(int(target_id)) self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择Edge {target_id}") self.statusBar().showMessage(f"已选择Edge {target_id}")
else: else:
self.mode_combo.setCurrentText("Face") self._set_selection_mode("Face")
self.select_face(int(target_id)) self.select_face(int(target_id))
self.statusBar().showMessage(f"已选择可推拉平面Face {target_id}") self.statusBar().showMessage(f"已选择可推拉平面Face {target_id}")
@@ -1197,38 +1262,38 @@ class WindowCoreMixin:
try: try:
target_id = int(self.id_input.text()) target_id = int(self.id_input.text())
except ValueError: 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 return
kind = kind or self.mode_combo.currentText() kind = _selection_mode_value(kind or self._current_selection_mode())
try: try:
if kind == "Part": if kind == "Part":
if self.model.part_by_id(target_id) is None: if self.model.part_by_id(target_id) is None:
raise ValueError(f"不存在 Part ID {target_id}") raise ValueError(f"不存在零件 ID {target_id}")
self.mode_combo.setCurrentText("Part") self._set_selection_mode("Part")
self.select_part(target_id) self.select_part(target_id)
elif kind == "Solid": elif kind == "Solid":
if target_id < 0 or target_id >= len(self.model.solids): if target_id < 0 or target_id >= len(self.model.solids):
raise ValueError(f"不存在 Solid ID {target_id}") raise ValueError(f"不存在 Solid ID {target_id}")
part_id = self.model.solids[target_id][0] 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) self.select_solid(target_id, part_id)
elif kind == "Face": elif kind == "Face":
resolved_face_id = self.model.resolve_face_selection_id(target_id) resolved_face_id = self.model.resolve_face_selection_id(target_id)
if resolved_face_id is None: if resolved_face_id is None:
raise ValueError(f"不存在 Face/逻辑 Face ID {target_id}") raise ValueError(f"不存在 Face/逻辑 Face ID {target_id}")
self.mode_combo.setCurrentText("Face") self._set_selection_mode("Face")
self.select_face(resolved_face_id) self.select_face(resolved_face_id)
elif kind == "Feature": elif kind == "Feature":
resolved_face_id = self.model.resolve_face_selection_id(target_id) resolved_face_id = self.model.resolve_face_selection_id(target_id)
if resolved_face_id is None: if resolved_face_id is None:
raise ValueError(f"不存在 Feature 来源 Face/逻辑 Face ID {target_id}") raise ValueError(f"不存在特征来源 Face/逻辑 Face ID {target_id}")
self.mode_combo.setCurrentText("Feature") self._set_selection_mode("Feature")
self.select_feature(resolved_face_id) self.select_feature(resolved_face_id)
elif kind == "Edge": elif kind == "Edge":
if target_id < 0 or target_id >= len(self.model.edges): if target_id < 0 or target_id >= len(self.model.edges):
raise ValueError(f"不存在 Edge ID {target_id}") raise ValueError(f"不存在 Edge ID {target_id}")
self.mode_combo.setCurrentText("Edge") self._set_selection_mode("Edge")
self.select_edge(target_id) self.select_edge(target_id)
self.last_id_kind = kind self.last_id_kind = kind
except ValueError as exc: except ValueError as exc:
@@ -1333,7 +1398,7 @@ class WindowCoreMixin:
self._show_pick_marker(pick_position) self._show_pick_marker(pick_position)
self._sync_id_picker("Feature", face_id) self._sync_id_picker("Feature", face_id)
self.set_info(self._with_pick_info(info, pick_position)) 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._update_action_states()
self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源Face {face_id}", pick_position)) self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源Face {face_id}", pick_position))
@@ -1859,7 +1924,7 @@ class WindowCoreMixin:
if render and hasattr(self, "render_window"): if render and hasattr(self, "render_window"):
self.render_window.Render() self.render_window.Render()
self.statusBar().showMessage("已清除差异预览") self.statusBar().showMessage("已清除差异预览")
if hasattr(self, "export_all_button"): if hasattr(self, "mode_combo"):
self._update_action_states() self._update_action_states()
def _show_operation_diff(self, record: OperationRecord) -> str: def _show_operation_diff(self, record: OperationRecord) -> str:
File diff suppressed because it is too large Load Diff