diff --git a/README.md b/README.md index a46a88f..8297483 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# STEP 零件查看与编辑原型 +# STEP 零件与特征查看编辑原型 这是一个基于 Python、pythonocc-core/OCCT、VTK 和 PySide6/Qt 的 STEP 模型查看、选择、局部编辑与导出原型。 @@ -19,13 +19,13 @@ - 加载 `.step` / `.stp` 模型文件。 - 展示三维模型,支持旋转、缩放、平移。 - 读取 STEP 中的零件、装配、Solid 等结构。 -- 用户可以选择零件、Solid、Face、Edge 和局部几何特征。 +- 用户可以选择零件、Solid、Face、Edge 和几何特征。 - 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。 - 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。 -- 用户可以修改可控的局部特征,比如平面推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、凸台直径调整、Edge倒圆、Edge倒角和修改Edge长度。 +- 用户可以修改可控的特征,比如平面推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、凸台直径调整、Edge倒圆、Edge倒角和修改Edge长度。 - 用户可以撤销/重做修改,避免一步编辑做坏。 - 用户可以导出修改后的完整模型。 -- 如果 STEP 文件里存在多个零件,用户可以只导出选中的某个零件。 +- 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。 需要注意:STEP 文件通常是 B-Rep 几何数据,不是带完整建模历史的参数化 CAD 原文件。所以“随便选一条边改长度”不一定能稳定实现,实际会转化成更可靠的操作,比如移动某个面、重切某个孔、调整某个圆角等。 @@ -36,7 +36,7 @@ - 能打开 STEP。 - 能显示模型。 - 能读取 STEP 中的零件/装配标签。 -- 能选择 Part、Solid、Face、Edge。 +- 能选择零件、Solid、Face、Edge 和特征。 - 能显示选中对象的基本属性。 - 能高亮选中对象。 - 能导出整个当前模型。 @@ -51,28 +51,28 @@ - 默认加载 `assets/models/geom_extract.step`。 - 支持打开其他 `.step` / `.stp` 文件。 - 打开 STEP 时会先在临时模型里完成读取和显示网格生成;失败时当前模型保持不变。 -- 使用 XCAF 读取 STEP 中的 Part / Assembly 标签。 +- 使用 XCAF 读取 STEP 中的零件 / Assembly 标签;代码内部仍沿用 `PartNode` / `part_id` 作为结构名。 - 模型结构树会用中文显示 STEP 里的装配、零件和实体层级,并在对应零件下面列出实体子节点。 - 点击模型结构树中的实体子节点,可以直接选中、高亮、查看属性并用于导出该实体。 - 使用 OCCT 读取真实 B-Rep 拓扑。 - 使用 VTK 显示模型,并通过 PySide6/Qt 承载桌面界面。 - 左侧操作面板按功能模块分组显示,并使用四边同色的轻量彩色边框和标题层级区分文件、模型树、选择、编辑、导出、显示、测量、候选、历史和属性信息。 - 支持选择模式: - - Part + - 零件 - Solid - Face - Edge - - Feature + - 特征 - 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。 - `Face` 模式会把属于同一几何面的连续区域一起高亮;平面会按共面且投影范围相接/重叠查找,圆柱面会按同一实体内同轴、同半径且轴向连续/重叠查找,不再要求旧段和新段必须共享同一条拓扑Edge。例如推拉后侧壁被 OCCT 拆成上下多段Face,点击任意一段时都会把整片同域区域一起高亮。 - 推拉后程序会尽量保留侧壁面区域的 `逻辑 Face ID`:即使 OCCT 把原来的一个侧壁拓扑Face重建成上下两段,新旧两段也会绑定回推拉前那片侧壁的逻辑 ID。属性表会同时显示 `逻辑 Face ID` 和 `拓扑 Face ID`;前者面向用户选择和按 ID 定位,后者用于调试当前 B-Rep 拓扑。 -- 操作历史会记录 `target_logical_id` 和当时的拓扑Face ID。点击历史记录定位Face / Feature时,会优先用逻辑 ID 找回当前模型中的整片面区域;撤销/重做快照也会保留这层逻辑 ID 映射。 +- 操作历史会记录 `target_logical_id` 和当时的拓扑Face ID。点击历史记录定位Face / 特征时,会优先用逻辑 ID 找回当前模型中的整片面区域;撤销/重做快照也会保留这层逻辑 ID 映射。 - 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界Edge。 -- 支持按当前选择模式输入 ID 直接选择 Part / Solid / Face / Edge / Feature。 -- Face / Feature 按 ID 选择会优先解析 `逻辑 Face ID`。例如推拉前侧壁是 558,推拉后当前拓扑里变成 1556 和 1802,只要它们被绑定到逻辑 558,输入 558 就会选中这片面区域。 +- 支持按当前选择模式输入 ID 直接选择零件 / Solid / Face / Edge / 特征。 +- Face / 特征按 ID 选择会优先解析 `逻辑 Face ID`。例如推拉前侧壁是 558,推拉后当前拓扑里变成 1556 和 1802,只要它们被绑定到逻辑 558,输入 558 就会选中这片面区域。 - `鼠标选择模式` 下拉框关闭时会把鼠标滚轮交还给左侧滚动面板,避免鼠标经过时误切换模式;打开下拉列表后仍可用滚轮滚动选项。 - `按 ID 选择` 会在输入框和 `选择` 按钮之间同步显示当前鼠标选择模式。 -- `Feature` 模式现在会把点到的Face解释为局部几何特征候选: +- `特征` 模式现在会把点到的Face解释为几何特征候选: - 平面会识别为可推拉平面候选。 - 平面会尝试查找同一Solid内投影重叠的相对平面,用于估算薄壁/壳体局部区域厚度。 - 圆柱面会识别为圆柱孔候选、槽/半孔候选、圆角/倒圆候选、凸台/外圆候选或未明确圆柱特征。 @@ -92,7 +92,7 @@ - 边长度 - 圆边半径/直径 - 属性面板现在还会显示更多几何调试信息: - - Part / Solid 的包围盒、尺寸、体积、重心、表面积。 + - 零件 / Solid 的包围盒、尺寸、体积、重心、表面积。 - Face 的方向、面积中心、UV 参数范围、边界边数量、包围盒。 - 平面Face的几何法向和按拓扑方向修正后的法向。 - 平面Face的推拉向外方向、向内方向和方向判断置信度。 @@ -101,23 +101,23 @@ - 直线Edge的方向,圆弧Edge的圆心、轴线、半径和直径。 - 属性面板支持复制当前对象 ID、拾取坐标和完整信息。 - 面/边属性查询带缓存;重复选择同一个对象会复用上次计算结果,模型编辑、撤销、重做或重新加载后缓存会自动清空。 -- 支持高亮选中的零件、Solid、Face、Edge或局部特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face 选择会对共面平面和同轴同半径圆柱面做同域区域高亮。 +- 支持高亮选中的零件、Solid、Face、Edge或特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face 选择会对共面平面和同轴同半径圆柱面做同域区域高亮。 - 默认模型边线渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔推拉后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部Edge,仍然可以单独高亮查看。 - 支持只显示当前选中对象、对准当前选中对象,并可一键恢复显示完整模型。 - 支持两点测量: - 可以把当前选中对象的拾取点设为 A 或 B;如果没有拾取点,会退回使用对象中心、面积中心、长度中心、重心或包围盒中心。 - 设置 A/B 后会显示两点距离和 X/Y/Z 方向差值。 - 3D 视图中会显示一条青色测量线;测量结果可以复制,加载新模型时会自动清除旧测量。 -- Feature 模式会在属性表里显示特征来源Face、侧壁Face、同域圆柱范围、端面Face、疑似底面Face、开口端相邻Face、边界Edge和当前可用操作。 +- 特征模式会在属性表里显示特征来源Face、侧壁Face、同域圆柱范围、端面Face、疑似底面Face、开口端相邻Face、边界Edge和当前可用操作。 - 支持导出当前完整模型为 STEP。 -- 支持导出选中的Part为 STEP。 +- 支持导出选中的零件为 STEP。 - 支持导出选中的Solid为 STEP。 - 支持导出选中的Face / 同域面区域为 STEP;如果当前Face与共面或同轴同半径的相邻面一起高亮,导出也会包含整片黄色区域。 - 支持导出选中特征区域为 STEP。 - 支持导出选中的Edge为 STEP。 - 支持导出质量检查:导出前会检查 B-Rep 有效性、Solid / Face / Edge 数量、体积和包围盒;如果发现风险,会先弹窗确认。 - 支持一键修复当前模型或选中零件/Solid:执行 ShapeFix 和同域面/边合并,修复会进入后台编辑流程,并支持撤销/重做。 -- 导出完整模型、Part 或 Solid 前,会先尝试执行 ShapeFix 和同域面/边合并,减少外部 CAD 打开时出现碎面、坏体或明显拼接痕迹的概率。 +- 导出完整模型、零件或 Solid 前,会先尝试执行 ShapeFix 和同域面/边合并,减少外部 CAD 打开时出现碎面、坏体或明显拼接痕迹的概率。 - 编辑按钮会根据当前选中对象自动启用或禁用,并通过悬停提示说明为什么当前操作不可用,减少 Face、Edge、孔、凸台、盲孔等对象混用导致的误操作。 - 支持扫描可编辑对象: - 默认扫描优先保证界面响应速度,会列出一组有代表性的候选。 @@ -166,7 +166,7 @@ - 当前版本支持槽/半孔候选结构化识别: - 对局部圆柱形 `hole/groove candidate`,会标记为 `partial-cylindrical-groove`。 - 会估算槽宽、圆弧长度和槽深。 - - 会识别槽圆柱Face和槽边界相邻Face,并在 Feature 模式下一起高亮。 + - 会识别槽圆柱Face和槽边界相邻Face,并在特征模式下一起高亮。 - 这些仍是 B-Rep 几何估算,不是 CAD 历史里的槽特征参数。 - 当前版本支持槽/半孔宽度调整: - 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。 @@ -180,7 +180,7 @@ - 当前版本支持已有圆角/倒圆候选结构化识别: - 对局部小半径圆柱面,会标记为 `round/fillet candidate`。 - 会估算已有圆角半径、圆弧角度和圆弧长度。 - - 会识别已有圆角Face和相邻支撑Face,并在 Feature 模式下一起高亮。 + - 会识别已有圆角Face和相邻支撑Face,并在特征模式下一起高亮。 - `可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径`,点击后会选中该圆角 Face,并把圆角半径输入框预填为当前估算半径的参考目标值。 - 选中已有圆角候选时,不会再误把它填入 `孔直径`,避免把圆角当孔径修改。 - 当前版本的已有圆角半径修改会先尝试移除圆角Face,再在恢复出的锐边上重新倒圆;只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。 @@ -222,17 +222,17 @@ - 椭圆边和可稳定采样为平面的 B-spline / Bezier 等非直线边,也会尝试围绕曲线所在平面法向做径向缩放;如果采样点不够平面,会退回更保守的缩放 fallback。 - 几何缩放 fallback 会先只预变换目标Edge来估算结果长度,并在必要时微调缩放比例,让实际边长更接近输入值。 - `中心`、没有明确端面、或非圆边没有更明确局部路径时,会尝试整体/轴向几何缩放 fallback,以覆盖更多任意Edge的最小可用场景。 - - 该 fallback 会影响所属Part/Solid的其它尺寸,因此会按高风险确认,并依赖失败回滚/撤销兜底。 + - 该 fallback 会影响所属零件/Solid的其它尺寸,因此会按高风险确认,并依赖失败回滚/撤销兜底。 - 这仍不是通用参数化边长编辑;复杂拓扑、斜面过渡、圆角链或布尔失败场景仍可能不能稳定修改。 -- 支持 Part / Solid 级平移: +- 支持零件 / Solid 级平移: - 输入 X/Y/Z 平移量后,可以平移当前选中零件。 - 选中Solid、或选中属于某个Solid的Face/Edge后,可以平移当前Solid。 - - 单Solid零件中平移Solid实际等同于移动整个Part shape,程序会按中风险提示。 + - 单Solid零件中平移Solid实际等同于移动整个零件 shape,程序会按中风险提示。 - 平移操作会写入历史记录,并支持撤销/重做。 -- 支持 Part / Solid 级旋转: +- 支持零件 / Solid 级旋转: - 选择 X/Y/Z 旋转轴并输入角度后,可以旋转当前选中零件或Solid。 - 当前版本的旋转中心取目标对象的包围盒中心。 - - 单Solid零件中旋转Solid实际等同于旋转整个Part shape,程序会按中风险提示。 + - 单Solid零件中旋转Solid实际等同于旋转整个零件 shape,程序会按中风险提示。 - 旋转操作会写入历史记录,并支持撤销/重做。 - 后台编辑计算时不再弹出模态进度框,也不再把主界面整体置灰;UI 线程只负责渲染和响应基础视图交互,真实 B-Rep 编辑在后台线程执行。 - 编辑计算期间,半透明预览会继续在 3D 视图里渲染,用户可以旋转或缩放查看;会改变模型状态的操作仍会暂时禁用,避免同一模型被多个后台编辑同时修改。 @@ -292,7 +292,7 @@ edges: 4982 vertices: 3262 ``` -所以当前测试文件大概率是单个零件,不是多零件装配。但代码已经按“将来可能有多个零件/装配”的方式组织。 +所以当前测试文件大概率是单个零件,不是多零件装配。Face、Edge、圆柱面、孔、槽和圆角等,是这个零件里的几何特征;代码也按“将来可能有多个零件/装配”的方式组织。 ## 还没有实现什么 @@ -399,17 +399,17 @@ python scripts\verify_edge_length_resize.py 左侧操作面板按功能分区排列,每个模块都有四边同色的独立边框和颜色提示,顺序大致按工作流排列:先打开和选择模型,再做编辑或导出,然后进行显示控制和测量,后面继续放可编辑对象、圆柱面候选、操作历史和对象信息。 -左侧 `鼠标选择模式` 用来切换选择模式: +左侧 `选择模式` 用来切换鼠标选择模式: -- `Part`:选择整个零件。 +- `零件`:选择整个零件。 - `Solid`:选择实体。 - `Face`:选择面。 - `Edge`:选择边。 -- `Feature`:按局部特征候选方式选择。平面会识别为可推拉平面候选;圆柱面会进一步识别为圆柱孔、槽/半孔、圆角/倒圆、凸台/外圆等候选。 +- `特征`:按几何特征候选方式选择。平面会识别为可推拉平面候选;圆柱面会进一步识别为圆柱孔、槽/半孔、圆角/倒圆、凸台/外圆等候选。 圆柱面候选会额外显示侧壁 Face、端面 Face、疑似底面 Face、开口端相邻 Face 和边界 Edge。 - 鼠标悬停时,当前模式下将要选中的对象会显示为红色;真正选中后会显示为黄色。 - 如果当前模式是 `Edge`,即使鼠标点到的是面,程序也会从这个面的边界里选取距离鼠标拾取点最近的Edge。 -- `鼠标选择模式` 下拉框关闭时不会吃掉滚轮,鼠标停在上面滚动会继续滚动左侧面板;点击打开下拉列表后,滚轮才用于滚动选项。 +- `选择模式` 下拉框关闭时不会吃掉滚轮,鼠标停在上面滚动会继续滚动左侧面板;点击打开下拉列表后,滚轮才用于滚动选项。 左侧 `模型结构树` 会显示 STEP 里的装配、零件和实体层级;如果某个零件下有实体,会展开显示 `实体 0`、`实体 1` 这样的子节点。点击实体子节点会自动切换到 `Solid` 选择,并高亮对应实体。 @@ -424,17 +424,17 @@ python scripts\verify_edge_length_resize.py 左侧 `按 ID 选择` 可以直接输入编号选择对象: -- 按 ID 选择会复用上方 `鼠标选择模式` 的当前类型;先切换模式,再输入 ID,点击 `选择`。 +- 按 ID 选择会复用上方 `选择模式` 的当前类型;先切换模式,再输入 ID,点击 `选择`。 - 输入框右侧会同步显示当前类型,例如 `Face`、`Edge` 或 `Solid`,用于确认这个 ID 会按哪类对象解释。 -- `Part` 的 ID 从 1 开始。 -- `Solid`、`Face`、`Edge`、`Feature` 的 ID 从 0 开始;`Feature` 使用来源Face的 ID。 +- `零件` 的 ID 从 1 开始。 +- `Solid`、`Face`、`Edge`、`特征` 的 ID 从 0 开始;`特征` 使用来源Face的 ID。 - 这个功能适合配合圆柱面候选表格、操作历史和属性面板使用。 左侧 `显示` 用来临时缩小三维视图范围: -- `只显示选中`:只显示当前选中的 Part、Solid、Face、Edge 或 Feature 区域。 +- `只显示选中`:只显示当前选中的零件、Solid、Face、Edge 或特征区域。 - Face 模式下 `只显示选中` 会隔离当前黄色同域面区域,并显示这片区域的外边界Edge。 -- `对准选中`:不改变显示范围,只把相机对准当前选中的 Part、Solid、Face、Edge 或 Feature,适合快速查看小孔、小边和局部特征。 +- `对准选中`:不改变显示范围,只把相机对准当前选中的零件、Solid、Face、Edge 或特征,适合快速查看小孔、小边和局部特征。 - `显示全部`:恢复显示完整模型。 - `显示同域内部边`:默认关闭,用来隐藏同一平面/同一圆柱面内部的拓扑分割边和几何重复拼接边;打开后可以查看完整 B-Rep 拓扑边线,适合调试布尔推拉后的分面情况。 - `导出选中面区域` 会导出当前 Face 模式黄色高亮的同域面区域,而不是只导出鼠标点到的单个拓扑Face。 @@ -465,7 +465,7 @@ python scripts\verify_edge_length_resize.py - `调整盲孔深度` 行表示这个Face是较明确的盲孔/盲槽候选,可以配合 `孔深度` 和 `调整盲孔深度` 使用。 - `给Edge添加圆角` 行表示这个对象是直线Edge,可以配合 `圆角半径` 和 `给Edge添加圆角` 使用。 - `给Edge添加倒角` 行表示这个对象是直线Edge,可以配合 `倒角距离` 和 `给Edge添加倒角` 使用。 -- `修改已有圆角半径` 行表示这个Face是已有圆角/倒圆候选,点击后会切到 `Feature` 模式并预填圆角半径参考值;真正执行需要再点击编辑区里的 `修改已有圆角半径` 按钮并确认。 +- `修改已有圆角半径` 行表示这个Face是已有圆角/倒圆候选,点击后会切到 `特征` 模式并预填圆角半径参考值;真正执行需要再点击编辑区里的 `修改已有圆角半径` 按钮并确认。 - `状态` 为 `ready` 表示比较适合尝试,`caution` 表示可以尝试但风险更高,`blocked` 表示当前参数或对象不适合执行。 - `风险` 越高越应该谨慎,尤其是圆角、凸柱或未明确圆柱面,不要直接当孔修改。 - 点击任意一行会自动选中并高亮对应Face或Edge。 @@ -474,7 +474,7 @@ python scripts\verify_edge_length_resize.py - 鼠标经过对象时会出现红色预高亮;被选对象会显示为黄色高亮。 - 在 `Face` 模式下,如果相邻Face属于同一几何面,并且投影范围或轴向范围相接/重叠,程序会把它们作为同域区域一起红色预高亮和黄色选中高亮;这主要用于缓解平面/圆柱推拉后视觉上被拓扑边分成多段的问题。 -- 如果处于 `Feature` 模式,程序会尽量高亮这个特征相关的局部区域,例如圆柱侧壁、相邻端面和槽边界相邻面。 +- 如果处于 `特征` 模式,程序会尽量高亮这个特征相关的局部区域,例如圆柱侧壁、相邻端面和槽边界相邻面。 - 对槽/半孔候选,属性表会显示 `槽宽估算`、`槽圆弧长度估算`、`槽深估算` 和 `槽边界相邻 Face`。 - 对圆角/倒圆候选,属性表会显示 `已有圆角半径估算`、`已有圆角圆弧角度`、`已有圆角圆弧长度估算` 和 `已有圆角支撑 Face`。 - 对盲孔/槽候选,属性表中的 `疑似底面 Face` 是根据圆柱端部 inside / outside 采样和相邻平面位置推断的,不等于 CAD 原始建模历史里的孔深参数。 @@ -496,7 +496,7 @@ python scripts\verify_edge_length_resize.py - 类型下拉框关闭时不会吃掉滚轮,鼠标停在上面滚动会继续滚动左侧面板;点击打开下拉列表后,滚轮才用于滚动选项。 - 当前默认显示前 60 个圆柱面候选;如果模型很复杂,扫描仍可能需要等待一会儿,但扫描过程中界面会尽量保持响应。 - 表格里的每一行对应一个圆柱面候选。 -- 点击候选行会自动切到 `Feature` 模式,并选中对应Face。 +- 点击候选行会自动切到 `特征` 模式,并选中对应Face。 - `guess` 列是程序通过几何采样得到的初步判断,不是 STEP 文件里自带的 CAD 历史特征。 - `round/fillet candidate` 是圆角/倒圆候选;部分规整圆柱圆角可以通过 `修改已有圆角半径` 进行当前版本受限修改。 - `diameter` 是圆柱直径。 @@ -541,7 +541,7 @@ python scripts\verify_edge_length_resize.py - 端部类型是通过轴线端部 inside / outside 采样得到的估算,不等于 CAD 原始建模历史里的“孔深”参数。 - 如果选到的圆柱面不是孔,而是柱或圆角,结果可能不是你想要的,所以它目前仍是实验功能。 - `槽/半孔宽度` + `调整槽/半孔宽度`: - - 先切换到 `Feature` 选择模式并选择一个槽/半孔候选 Face,或从圆柱面候选表中选择局部圆柱形 `hole/groove candidate`。 + - 先切换到 `特征` 选择模式并选择一个槽/半孔候选 Face,或从圆柱面候选表中选择局部圆柱形 `hole/groove candidate`。 - 选中符合条件的槽/半孔后,`槽/半孔宽度` 会自动填入一个比当前槽宽估算略大的建议值。 - 输入目标槽宽后点击 `调整槽/半孔宽度`。 - 程序会用当前圆弧角度把目标槽宽换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 @@ -588,7 +588,7 @@ python scripts\verify_edge_length_resize.py - 这是“给直线边添加新圆角”,不是修改已有圆角面。 - 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒圆;失败时程序会提示并尝试恢复编辑前状态。 - `圆角半径` + `修改已有圆角半径`: - - 先切换到 `Feature` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。 + - 先切换到 `特征` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。 - 选中已有圆角候选后,`圆角半径` 会自动填入一个基于当前估算半径的参考目标值。 - 输入目标半径后点击 `修改已有圆角半径`。 - 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。 @@ -617,25 +617,25 @@ python scripts\verify_edge_length_resize.py - 如果圆边没有可复用圆柱面,但能读取圆心和轴线,会走 `围绕圆边轴线径向缩放` 后备策略,尽量不改变轴向厚度/高度;执行后部分解析几何可能变成 B-spline。 - 椭圆边、平面 B-spline / Bezier 边会尝试走 `围绕平面曲线法向径向缩放`;确认窗口会显示平面采样偏差,偏差不稳定时会回退到更保守的缩放 fallback。 - `固定起点` 会让起点不动、移动终点;`固定终点` 相反;`中心` 会让两端各移动一半,只有进入缩放 fallback 时才会变成以Edge中心为基准缩放。 - - 如果没有明确端面或可复用圆柱面,程序会按所选基准尝试整体/轴向几何缩放 fallback;这会影响所属Part/Solid的其它尺寸,因此会弹出高风险确认。 - - 执行成功后会做后置边长校验:在刷新后的Part/Solid范围内寻找最接近目标长度的Edge,并把实际最近边长、误差和相对误差写入操作结果。 + - 如果没有明确端面或可复用圆柱面,程序会按所选基准尝试整体/轴向几何缩放 fallback;这会影响所属零件/Solid的其它尺寸,因此会弹出高风险确认。 + - 执行成功后会做后置边长校验:在刷新后的零件/Solid范围内寻找最接近目标长度的Edge,并把实际最近边长、误差和相对误差写入操作结果。 - 通过检查后,会显示蓝绿色半透明预览,表示将要移动或缩放的范围。 - 真实编辑在后台执行,失败时会回滚到编辑前状态。 - 这是当前版本的 B-Rep 结果几何修改,不是任意边参数化改长。 - `平移 X/Y/Z` + `平移选中零件` / `平移选中Solid`: - 输入 X、Y、Z 三个方向的平移量。 - - 选择 `Part` 后点击 `平移选中零件`,会移动整个Part。 + - 选择 `零件` 后点击 `平移选中零件`,会移动整个零件。 - 选择 `Solid`,或选择属于某个Solid的Face/Edge后,点击 `平移选中Solid`,会移动当前Solid。 - - 如果当前Part只有一个Solid,平移Solid实际会移动整个Part shape,程序会提示中风险。 + - 如果当前零件只有一个Solid,平移Solid实际会移动整个零件 shape,程序会提示中风险。 - 平移向量为 0 会直接阻止。 - 如果平移距离相对对象包围盒过大,程序会提高风险并要求确认。 - 平移属于真实 B-Rep 变换,会写入操作历史,可以撤销/重做。 - `旋转轴/角度` + `旋转选中零件` / `旋转选中Solid`: - 选择 X、Y 或 Z 旋转轴。 - 输入旋转角度,单位是度。 - - 选择 `Part` 后点击 `旋转选中零件`,会绕该Part的包围盒中心旋转整个Part。 + - 选择 `零件` 后点击 `旋转选中零件`,会绕该零件的包围盒中心旋转整个零件。 - 选择 `Solid`,或选择属于某个Solid的Face/Edge后,点击 `旋转选中Solid`,会绕该Solid的包围盒中心旋转当前Solid。 - - 如果当前Part只有一个Solid,旋转Solid实际会旋转整个Part shape,程序会提示中风险。 + - 如果当前零件只有一个Solid,旋转Solid实际会旋转整个零件 shape,程序会提示中风险。 - 旋转角度为 0 会直接阻止;角度超过 360 度会提高风险并要求确认。 - 旋转属于真实 B-Rep 变换,会写入操作历史,可以撤销/重做。 - `撤销`: @@ -664,7 +664,7 @@ python scripts\verify_edge_length_resize.py - `导出选中零件`:导出当前选中的零件。 - `导出选中Solid`:先选中一个Solid,再导出该Solid。 - `导出选中面区域`:先切换到 `Face` 选择模式并选中一个Face,再导出该Face或同域面区域。 -- `导出选中特征区域`:先切换到 `Feature` 选择模式并选中局部特征,再导出当前特征高亮的Face集合。 +- `导出选中特征区域`:先切换到 `特征` 选择模式并选中特征,再导出当前特征高亮的Face集合。 - `导出选中Edge`:先切换到 `Edge` 选择模式并选中一个Edge,再导出该Edge。 - `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。 - `修复当前模型`:对当前完整模型执行 ShapeFix 和同域面/边合并,完成后会刷新显示并写入操作历史;如果修复失败,会尝试回滚到修复前状态。 @@ -701,7 +701,7 @@ pythonocc-step-editor/ polydata.py # OCC Shape / Edge / Face 到 VTK polydata 的显示数据生成 records.py # 操作历史记录数据结构 step_io.py # STEP/XCAF 读取、产品名解析和 STEP 写出 - transforms.py # Part / Solid 平移和旋转 + transforms.py # 零件 / Solid 平移和旋转 ui_helpers.py # UI 常量、格式化函数和 VTK 显示小工具 widgets.py # 自定义 Qt 小组件 window_actions.py # 导出、编辑按钮、扫描、后台编辑调度等窗口动作 @@ -739,7 +739,7 @@ pythonocc-step-editor/ 本项目是一个基于 Python、pythonocc-core/OCCT、VTK 和 PySide6/Qt 的 STEP 查看与局部编辑原型。它处理的是 STEP 里的 B-Rep 结果几何,不是原 CAD 软件的完整参数化建模历史。 -最小系统目标是打通:加载 STEP、显示模型、选择 Part/Solid/Face/Edge/Feature、查看属性、识别初步候选特征、执行受限局部编辑、撤销/重做、质量检查和导出。 +最小系统目标是打通:加载 STEP、显示模型、选择零件/Solid/Face/Edge/特征、查看属性、识别初步候选特征、执行受限局部编辑、撤销/重做、质量检查和导出。 ### 运行方式 @@ -797,11 +797,11 @@ git diff --check ### 当前关键能力 - STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。 -- 选择:支持 Part、Solid、Face、Edge、Feature。鼠标悬停红色预高亮,选中黄色高亮。 -- 同域面:Face/Feature 会尽量把共面或同轴同半径的连续碎面合成逻辑区域。 -- 编辑:平面推拉、孔径、槽/半孔宽度、盲孔/盲槽深度、凸台直径、已有圆角半径当前版本修改、Edge圆角、Edge倒角、薄壁厚度、修改Edge长度、Part/Solid 平移和旋转。 +- 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮。 +- 同域面:Face/特征会尽量把共面或同轴同半径的连续碎面合成逻辑区域。 +- 编辑:平面推拉、孔径、槽/半孔宽度、盲孔/盲槽深度、凸台直径、已有圆角半径当前版本修改、Edge圆角、Edge倒角、薄壁厚度、修改Edge长度、零件/Solid 平移和旋转。 - 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。 -- 导出:完整模型、Part、Solid、Face、Feature 区域和 Edge,导出前做基础质量检查。 +- 导出:完整模型、零件、Solid、Face、特征区域和 Edge,导出前做基础质量检查。 ### 修改Edge长度 @@ -809,7 +809,7 @@ git diff --check 当前策略顺序: -1. `local-edge-only-deform`:直线Edge位于简单全平面多面体时,优先只移动被选Edge的端点并重建所属Solid/Part。对立方体测试模型,默认固定起点移动终点;相邻面会自然变成梯形,必要时非共面四边面拆三角面,而不是把整个正方体缩放成长方体。 +1. `local-edge-only-deform`:直线Edge位于简单全平面多面体时,优先只移动被选Edge的端点并重建所属Solid/零件。对立方体测试模型,默认固定起点移动终点;相邻面会自然变成梯形,必要时非共面四边面拆三角面,而不是把整个正方体缩放成长方体。 2. `move-edge-end-plane-by-push-pull`:如果不适合局部形变,尝试找端点附近、方向匹配的平面端面,把改边长转成端面推拉。 3. `resize-adjacent-cylinder-from-circular-edge-length`:圆形/圆弧Edge尝试复用相邻圆柱孔/槽或凸台直径编辑。 4. `scale-owning-shape-from-edge`:圆边、椭圆边和可采样为平面的非直线Edge会优先用 `radial-affine` 围绕曲线平面法向做径向缩放;其他无明确局部路径的Edge才按Edge轴向或整体做几何缩放 fallback,风险高,会影响其它尺寸。 diff --git a/step_editor/app.py b/step_editor/app.py index 873f4f0..a67b8b4 100644 --- a/step_editor/app.py +++ b/step_editor/app.py @@ -7,15 +7,17 @@ import sys from pathlib import Path import vtk -from PySide6.QtCore import Qt, QThread, QTimer, Slot +from PySide6.QtCore import Qt, QThread, QTimer, Signal, Slot from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QCheckBox, QComboBox, QFileDialog, + QFrame, QGridLayout, QGroupBox, + QHeaderView, QHBoxLayout, QLabel, QLineEdit, @@ -25,6 +27,7 @@ from PySide6.QtWidgets import ( QPushButton, QPlainTextEdit, QScrollArea, + QSizePolicy, QTableWidget, QTableWidgetItem, QTabWidget, @@ -52,6 +55,16 @@ _CRASH_LOG_HANDLE = None PROJECT_ROOT = Path(__file__).resolve().parent.parent DEFAULT_MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "geom_extract.step" +# Panel build switches. These work like a Python-side "#if 0": code in a +# disabled branch is kept for later, but the widgets are not constructed. +ENABLE_STRUCTURE_TREE_PANEL = False +ENABLE_VIEW_PANEL = False +ENABLE_MEASURE_PANEL = False +ENABLE_EXPORT_PANEL = False +ENABLE_EDITABLE_OBJECTS_PANEL = False +ENABLE_CYLINDER_CANDIDATES_PANEL = False +ENABLE_HISTORY_PANEL = False + def _enable_crash_log() -> None: global _CRASH_LOG_HANDLE @@ -66,9 +79,12 @@ def _enable_crash_log() -> None: class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, InfoPanelMixin, QMainWindow): + ui_task_requested = Signal(object) + def __init__(self, step_path: str | Path, *, background_load: bool = True): super().__init__() - self.setWindowTitle("STEP 零件查看与编辑原型") + self.ui_task_requested.connect(self._run_ui_task, Qt.ConnectionType.QueuedConnection) + self.setWindowTitle("几何参数化") self.resize(1280, 820) self.model: StepModel | None = None @@ -143,6 +159,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.pending_load_path: Path | None = None self.initial_load_deflection = 2.0 self.last_id_kind = "Face" + self.property_editor_updating = False + self.property_editor_specs: list[dict[str, object]] = [] + self.property_table_expanded = False + self.property_table_collapsed_rows = 6 self._build_ui() self._build_vtk() @@ -237,17 +257,31 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf border-color: #b8dfd6; color: #0f766e; } - QGroupBox#modeSection, - QGroupBox#idSection { - background: #f7f5ff; - border: 1px solid #cbc4ff; - border-left: 5px solid #6b5cff; + QFrame#modeSection { + background: #f6f3ff; + border: 1px solid #a99bff; + border-left: 5px solid #5b4bdb; + border-radius: 7px; } - QGroupBox#modeSection::title, - QGroupBox#idSection::title { - background: #eeeaff; - border-color: #cbc4ff; - color: #5642c7; + QLabel#modeSectionTitle { + background: #ebe7ff; + border: 1px solid #a99bff; + border-radius: 5px; + color: #4736b3; + font-weight: 700; + padding: 1px 8px; + } + QFrame#modeIdSeparator { + background: #ded8ff; + border-left: 2px solid #5b4bdb; + border-right: 1px solid #b8adff; + max-width: 8px; + min-width: 8px; + } + QLabel#mouseModeLabel, + QLabel#idSelectLabel { + color: #4736b3; + font-weight: 700; } QGroupBox#viewSection { background: #f1faff; @@ -337,12 +371,6 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf QComboBox:focus { border: 1px solid #3478f6; } - QLineEdit#idModeDisplay { - background: #eef2ff; - border-color: #93a5e8; - color: #2d3a8c; - font-weight: 700; - } QPushButton { background: #f8fafc; border: 1px solid #c8d2df; @@ -365,6 +393,24 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf color: #8f99a8; font-weight: 500; } + QPushButton#propertyRowEditButton { + background: #fff3d9; + border: 1px solid #d97706; + border-radius: 5px; + color: #92400e; + font-weight: 700; + min-height: 18px; + padding: 1px 6px; + } + QPushButton#propertyRowEditButton:hover { + background: #ffe6b8; + border-color: #b45309; + } + QPushButton#propertyRowEditButton:disabled { + background: #eef2f6; + border: 1px dashed #bcc7d4; + color: #8f99a8; + } QLineEdit:disabled, QComboBox:disabled, QPlainTextEdit:disabled { @@ -372,6 +418,26 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf border: 1px dashed #bcc7d4; color: #8f99a8; } + QLineEdit#idSelectInput { + background: #ffffff; + border: 1px solid #8f82ea; + color: #172033; + placeholder-text-color: #64748b; + } + QLineEdit#idSelectInput:focus { + background: #ffffff; + border: 1px solid #5b4bdb; + } + QLineEdit#idSelectInput:disabled { + background: #ffffff; + border: 1px solid #c8d2df; + color: #172033; + } + QLineEdit#stepPathDisplay { + background: #ffffff; + border: 1px solid #bdd3ff; + color: #172033; + } QCheckBox:disabled, QTabWidget:disabled, QTreeWidget:disabled, @@ -423,178 +489,273 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf file_box = QGroupBox("STEP 文件") file_box.setObjectName("fileSection") - help_tip(file_box, "打开、重新加载和查看当前 STEP 文件路径。") + help_tip(file_box, "导入、查看路径并读取当前 STEP 几何模型。") file_layout = QVBoxLayout(file_box) - self.path_label = QLabel("") - self.path_label.setTextInteractionFlags(Qt.TextSelectableByMouse) - self.path_label.setWordWrap(True) - help_tip(self.path_label, "当前打开的 STEP 文件路径。可以选中文字复制路径。") - file_layout.addWidget(self.path_label) - file_buttons = QHBoxLayout() - self.open_button = QPushButton("打开") - help_tip(self.open_button, "选择并打开一个 .step 或 .stp 文件。打开失败时会保留当前模型。") + file_buttons.setSpacing(6) + self.open_button = QPushButton("导入几何模型") + self.open_button.setMinimumWidth(96) + help_tip(self.open_button, "选择并导入一个 .step 或 .stp 几何模型。打开失败时会保留当前模型。") self.open_button.clicked.connect(self.open_step) - self.reload_button = QPushButton("重新加载") - help_tip(self.reload_button, "从磁盘重新读取当前 STEP 文件,用于放弃本次会话里的临时查看状态。") + self.path_label = QLineEdit(str(self.step_path)) + self.path_label.setObjectName("stepPathDisplay") + self.path_label.setReadOnly(True) + self.path_label.setMinimumWidth(120) + help_tip(self.path_label, "当前 STEP 文件的完整路径。可以选中文字复制路径。") + self.path_label.setToolTip(str(self.step_path)) + self.path_label.setCursorPosition(0) + self.reload_button = QPushButton("读取模型") + self.reload_button.setMinimumWidth(78) + help_tip(self.reload_button, "从显示的路径重新读取 STEP 模型,用于放弃本次会话里的临时查看状态。") self.reload_button.clicked.connect(self.reload_step) file_buttons.addWidget(self.open_button) + file_buttons.addWidget(self.path_label, stretch=1) file_buttons.addWidget(self.reload_button) file_layout.addLayout(file_buttons) panel_layout.addWidget(file_box) - tree_box = QGroupBox("模型结构树") - tree_box.setObjectName("treeSection") - help_tip(tree_box, "查看 STEP 里的装配、零件和实体层级,并从树上直接选中对象。") - tree_layout = QVBoxLayout(tree_box) - self.part_tree = QTreeWidget() - self.part_tree.setHeaderLabels(["对象", "内容"]) - self.part_tree.setMinimumHeight(180) - self.part_tree.setAlternatingRowColors(True) - help_tip(self.part_tree, "模型里的装配、零件和Solid列表。点击一行可以选中并高亮对应对象。") - self.part_tree.currentItemChanged.connect(self.on_part_tree_select) - tree_layout.addWidget(self.part_tree) - panel_layout.addWidget(tree_box) + if ENABLE_STRUCTURE_TREE_PANEL: + tree_box = QGroupBox("模型结构树") + tree_box.setObjectName("treeSection") + help_tip(tree_box, "查看 STEP 里的装配、零件和实体层级,并从树上直接选中对象。") + tree_layout = QVBoxLayout(tree_box) + self.part_tree = QTreeWidget() + self.part_tree.setHeaderLabels(["对象", "内容"]) + self.part_tree.setMinimumHeight(180) + self.part_tree.setAlternatingRowColors(True) + help_tip(self.part_tree, "模型里的装配、零件和Solid列表。点击一行可以选中并高亮对应对象。") + self.part_tree.currentItemChanged.connect(self.on_part_tree_select) + tree_layout.addWidget(self.part_tree) + panel_layout.addWidget(tree_box) - mode_box = QGroupBox("鼠标选择模式") - mode_box.setObjectName("modeSection") - help_tip(mode_box, "决定鼠标点模型时选中 Part、Solid、Face、Edge,还是识别局部特征。") - mode_layout = QVBoxLayout(mode_box) + mode_box = QWidget() + mode_box.setMinimumHeight(62) + help_tip(mode_box, "决定鼠标点模型时选中零件、Solid、Face、Edge,还是识别几何特征。") + self.mode_section_title = QLabel("选择模式", mode_box) + self.mode_section_title.setObjectName("modeSectionTitle") + self.mode_section_title.setFixedSize(74, 20) + self.mode_section_title.move(12, 0) + self.mode_section_title.raise_() + help_tip(self.mode_section_title, "选择鼠标点模型时按什么对象类型选择。") + mode_outer_layout = QVBoxLayout(mode_box) + mode_outer_layout.setContentsMargins(0, 9, 0, 0) + mode_outer_layout.setSpacing(0) + self.mode_section_frame = QFrame() + self.mode_section_frame.setObjectName("modeSection") + self.mode_section_frame.setMinimumHeight(50) + mode_outer_layout.addWidget(self.mode_section_frame) + self.mode_section_title.raise_() + mode_layout = QHBoxLayout(self.mode_section_frame) + mode_layout.setSpacing(0) + mode_layout.setContentsMargins(0, 0, 0, 0) + mode_pick_panel = QWidget() + mode_pick_layout = QHBoxLayout(mode_pick_panel) + mode_pick_layout.setContentsMargins(8, 10, 8, 6) + mode_pick_layout.setSpacing(6) + self.mouse_mode_label = QLabel("按鼠标:") + self.mouse_mode_label.setObjectName("mouseModeLabel") + self.mouse_mode_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) + self.mouse_mode_label.setMaximumWidth(62) + help_tip(self.mouse_mode_label, "这里决定鼠标点击 3D 模型时按哪种对象类型选择。") self.mode_combo = NoWheelComboBox() - self.mode_combo.addItems(["Part", "Solid", "Face", "Edge", "Feature"]) - self.mode_combo.setCurrentText("Face") + for mode in ("Part", "Solid", "Face", "Edge", "Feature"): + self.mode_combo.addItem(_selection_mode_label(mode), mode) + self._set_selection_mode("Face") + self.mode_combo.setMinimumWidth(94) + self.mode_combo.setMaximumWidth(112) help_tip( self.mode_combo, - "选择鼠标点击模型时要选什么:Part、Solid、Face、Edge,或把 Face 解释成孔/槽/圆角等特征候选。", + "选择模式决定鼠标点击模型时要选什么:零件、Solid、Face、Edge,或把 Face 解释成孔/槽/圆角等几何特征候选。", ) - self.mode_combo.currentTextChanged.connect(self._on_mode_changed) - mode_layout.addWidget(self.mode_combo) + self.mode_combo.currentIndexChanged.connect(lambda _index: self._on_mode_changed(self._current_selection_mode())) + mode_pick_layout.addWidget(self.mouse_mode_label) + mode_pick_layout.addWidget(self.mode_combo) + mode_layout.addWidget(mode_pick_panel) + + mode_separator = QFrame() + mode_separator.setFrameShape(QFrame.Shape.NoFrame) + mode_separator.setObjectName("modeIdSeparator") + mode_separator.setFixedWidth(8) + mode_separator.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + self.mode_id_separator = mode_separator + help_tip(mode_separator, "左侧是鼠标点选模式,右侧是按当前模式输入 ID 选择。") + mode_layout.addWidget(mode_separator) + + id_select_panel = QWidget() + select_layout = QHBoxLayout(id_select_panel) + select_layout.setContentsMargins(8, 10, 8, 6) + select_layout.setSpacing(6) + self.id_select_label = QLabel(f"按ID:{_selection_mode_label(self._current_selection_mode())}") + self.id_select_label.setObjectName("idSelectLabel") + self.id_select_label.setMinimumWidth(78) + help_tip(self.id_select_label, "按当前选择模式解释输入的 ID。") + self.id_input = QLineEdit("") + self.id_input.setObjectName("idSelectInput") + self.id_input.setPlaceholderText("ID") + self.id_input.setMaximumWidth(70) + help_tip(self.id_input, "输入要选中的对象 ID。ID 类型显示在左侧标签中,并会跟随选择模式变化。") + self.select_id_button = QPushButton("选择") + self.select_id_button.setMaximumWidth(58) + help_tip(self.select_id_button, "按当前选择模式跳转到输入的 ID,并在 3D 视图中高亮它。") + self.select_id_button.clicked.connect(lambda _checked=False: self.select_by_id(self._current_selection_mode())) + self.id_input.textChanged.connect(lambda _text: self._update_action_states()) + self.id_input.returnPressed.connect(lambda: self.select_by_id(self._current_selection_mode())) + select_layout.addWidget(self.id_select_label) + select_layout.addWidget(self.id_input) + select_layout.addWidget(self.select_id_button) + mode_layout.addWidget(id_select_panel, stretch=1) panel_layout.addWidget(mode_box) - select_box = QGroupBox("按 ID 选择") - select_box.setObjectName("idSection") - help_tip(select_box, "知道对象 ID 时,可以直接输入 ID 跳转选择。") - select_layout = QGridLayout(select_box) - self.id_input = QLineEdit("") - self.id_input.setPlaceholderText("输入 ID") - help_tip(self.id_input, "输入要选中的对象 ID。ID 的类型由右侧显示的选择模式决定。") - self.id_mode_display = QLineEdit(self.mode_combo.currentText()) - self.id_mode_display.setObjectName("idModeDisplay") - self.id_mode_display.setReadOnly(True) - self.id_mode_display.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.id_mode_display.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.id_mode_display.setMinimumWidth(70) - help_tip(self.id_mode_display, "当前按这个鼠标选择模式解释输入的 ID。它会跟随鼠标选择模式自动变化。") - self.select_id_button = QPushButton("选择") - help_tip(self.select_id_button, "按当前选择模式跳转到输入的 ID,并在 3D 视图中高亮它。") - self.select_id_button.clicked.connect(lambda _checked=False: self.select_by_id(self.mode_combo.currentText())) - self.id_input.textChanged.connect(lambda _text: self._update_action_states()) - self.id_input.returnPressed.connect(lambda: self.select_by_id(self.mode_combo.currentText())) - select_layout.addWidget(QLabel("ID"), 0, 0) - select_layout.addWidget(self.id_input, 0, 1) - select_layout.addWidget(self.id_mode_display, 0, 2) - select_layout.addWidget(self.select_id_button, 0, 3) - panel_layout.addWidget(select_box) + if ENABLE_VIEW_PANEL: + view_box = QGroupBox("显示") + view_box.setObjectName("viewSection") + help_tip(view_box, "只改变视图显示方式,不会修改 STEP 几何。") + view_layout = QVBoxLayout(view_box) + view_buttons = QHBoxLayout() + self.isolate_button = QPushButton("只显示选中") + help_tip(self.isolate_button, "把视图临时隔离到当前选中的零件、Solid、Face、Edge 或几何特征区域。不会修改模型。") + self.isolate_button.clicked.connect(self.isolate_selected) + self.fit_button = QPushButton("对准选中") + help_tip(self.fit_button, "把相机移动到当前选中对象附近,方便看清细节。不会修改模型。") + self.fit_button.clicked.connect(self.fit_selected) + self.show_all_button = QPushButton("显示全部") + help_tip(self.show_all_button, "取消隔离显示,恢复查看完整模型。不会修改模型。") + self.show_all_button.clicked.connect(self.show_all_geometry) + view_buttons.addWidget(self.isolate_button) + view_buttons.addWidget(self.fit_button) + view_buttons.addWidget(self.show_all_button) + view_layout.addLayout(view_buttons) + self.show_internal_edges_checkbox = QCheckBox("显示同域内部边") + help_tip( + self.show_internal_edges_checkbox, + "显示同一平面或同一圆柱面内部的拓扑分割边。关闭时会隐藏布尔推拉后常见的视觉接缝线。", + ) + self.show_internal_edges_checkbox.toggled.connect(self._on_internal_edges_toggled) + view_layout.addWidget(self.show_internal_edges_checkbox) - view_box = QGroupBox("显示") - view_box.setObjectName("viewSection") - help_tip(view_box, "只改变视图显示方式,不会修改 STEP 几何。") - view_layout = QVBoxLayout(view_box) - view_buttons = QHBoxLayout() - self.isolate_button = QPushButton("只显示选中") - help_tip(self.isolate_button, "把视图临时隔离到当前选中的 Part、Solid、Face、Edge 或特征区域。不会修改模型。") - self.isolate_button.clicked.connect(self.isolate_selected) - self.fit_button = QPushButton("对准选中") - help_tip(self.fit_button, "把相机移动到当前选中对象附近,方便看清细节。不会修改模型。") - self.fit_button.clicked.connect(self.fit_selected) - self.show_all_button = QPushButton("显示全部") - help_tip(self.show_all_button, "取消隔离显示,恢复查看完整模型。不会修改模型。") - self.show_all_button.clicked.connect(self.show_all_geometry) - view_buttons.addWidget(self.isolate_button) - view_buttons.addWidget(self.fit_button) - view_buttons.addWidget(self.show_all_button) - view_layout.addLayout(view_buttons) - self.show_internal_edges_checkbox = QCheckBox("显示同域内部边") + if ENABLE_MEASURE_PANEL: + measure_box = QGroupBox("测量") + measure_box.setObjectName("measureSection") + help_tip(measure_box, "把当前选中对象的拾取点或中心设为 A/B,计算两点距离和 X/Y/Z 差值。不会修改模型。") + measure_layout = QVBoxLayout(measure_box) + self.measure_text = QPlainTextEdit() + self.measure_text.setReadOnly(True) + self.measure_text.setMaximumHeight(90) + help_tip(self.measure_text, "显示 A 点、B 点、两点距离和各方向差值。") + measure_buttons = QGridLayout() + self.set_measure_a_button = QPushButton("设为 A") + help_tip(self.set_measure_a_button, "把当前选中对象的拾取点设为测量点 A;没有拾取点时使用对象中心。") + self.set_measure_a_button.clicked.connect(lambda _checked=False: self.set_measure_point("A")) + self.set_measure_b_button = QPushButton("设为 B") + help_tip(self.set_measure_b_button, "把当前选中对象的拾取点设为测量点 B;没有拾取点时使用对象中心。") + self.set_measure_b_button.clicked.connect(lambda _checked=False: self.set_measure_point("B")) + self.copy_measure_button = QPushButton("复制测量") + help_tip(self.copy_measure_button, "复制当前测量结果文本,方便记录尺寸。") + self.copy_measure_button.clicked.connect(self.copy_measurement) + self.clear_measure_button = QPushButton("清除测量") + help_tip(self.clear_measure_button, "清除 A/B 测量点和 3D 测量线。不会影响模型。") + self.clear_measure_button.clicked.connect(self.clear_measurement) + measure_buttons.addWidget(self.set_measure_a_button, 0, 0) + measure_buttons.addWidget(self.set_measure_b_button, 0, 1) + measure_buttons.addWidget(self.copy_measure_button, 1, 0) + measure_buttons.addWidget(self.clear_measure_button, 1, 1) + measure_layout.addWidget(self.measure_text) + measure_layout.addLayout(measure_buttons) + self._refresh_measurement_panel() + + if ENABLE_EXPORT_PANEL: + export_box = QGroupBox("导出") + export_box.setObjectName("exportSection") + help_tip(export_box, "把当前模型或选中对象导出为 STEP,也可以做基础质量检查和修复。") + export_layout = QVBoxLayout(export_box) + self.export_all_button = QPushButton("导出当前完整 STEP") + help_tip(self.export_all_button, "把当前编辑后的整个模型导出为 STEP 文件。导出前会做基础质量检查。") + self.export_all_button.clicked.connect(self.export_all) + self.export_part_button = QPushButton("导出选中零件") + help_tip(self.export_part_button, "只导出当前选中的零件。适合从装配里拆出一个单独零件。") + self.export_part_button.clicked.connect(self.export_selected_part) + self.export_solid_button = QPushButton("导出选中Solid") + help_tip(self.export_solid_button, "只导出当前选中的实体Solid。适合检查或单独保存某个实体。") + self.export_solid_button.clicked.connect(self.export_selected_solid) + self.export_face_button = QPushButton("导出选中面区域") + help_tip(self.export_face_button, "导出当前选中的面区域;同域高亮的共面/同圆柱区域也会一起导出。") + self.export_face_button.clicked.connect(self.export_selected_face) + self.export_feature_button = QPushButton("导出选中特征区域") + help_tip(self.export_feature_button, "导出特征模式识别到的区域,例如孔、槽、圆角或凸台候选。") + self.export_feature_button.clicked.connect(self.export_selected_feature) + self.export_edge_button = QPushButton("导出选中Edge") + help_tip(self.export_edge_button, "导出当前选中的Edge。主要用于调试、定位或把边界单独拿出去检查。") + self.export_edge_button.clicked.connect(self.export_selected_edge) + self.export_check_button = QPushButton("检查导出质量") + help_tip(self.export_check_button, "检查当前导出对象是否像有效实体:B-Rep、Face/Edge 数量、体积和包围盒等。") + self.export_check_button.clicked.connect(self.check_export_quality) + self.repair_model_button = QPushButton("修复当前模型") + help_tip(self.repair_model_button, "对完整模型尝试 ShapeFix 和同域面/边合并。会写入历史,可撤销。") + self.repair_model_button.clicked.connect(self.repair_model) + self.repair_selected_button = QPushButton("修复选中零件/Solid") + help_tip(self.repair_selected_button, "只修复当前选中的零件或Solid,范围比修复完整模型更小。会写入历史,可撤销。") + self.repair_selected_button.clicked.connect(self.repair_selected_shape) + export_layout.addWidget(self.export_all_button) + export_layout.addWidget(self.export_part_button) + export_layout.addWidget(self.export_solid_button) + export_layout.addWidget(self.export_face_button) + export_layout.addWidget(self.export_feature_button) + export_layout.addWidget(self.export_edge_button) + export_layout.addWidget(self.export_check_button) + export_layout.addWidget(self.repair_model_button) + export_layout.addWidget(self.repair_selected_button) + + self.object_edit_box = QGroupBox("当前选中对象:未选择") + self.object_edit_box.setObjectName("editSection") help_tip( - self.show_internal_edges_checkbox, - "显示同一平面或同一圆柱面内部的拓扑分割边。关闭时会隐藏布尔推拉后常见的视觉接缝线。", + self.object_edit_box, + "选择模型对象后,这里会列出当前对象的属性和值;能改的行可以输入目标值,不能改的行只读。", ) - self.show_internal_edges_checkbox.toggled.connect(self._on_internal_edges_toggled) - view_layout.addWidget(self.show_internal_edges_checkbox) + 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) - measure_box = QGroupBox("测量") - measure_box.setObjectName("measureSection") - help_tip(measure_box, "把当前选中对象的拾取点或中心设为 A/B,计算两点距离和 X/Y/Z 差值。不会修改模型。") - measure_layout = QVBoxLayout(measure_box) - self.measure_text = QPlainTextEdit() - self.measure_text.setReadOnly(True) - self.measure_text.setMaximumHeight(90) - help_tip(self.measure_text, "显示 A 点、B 点、两点距离和各方向差值。") - measure_buttons = QGridLayout() - self.set_measure_a_button = QPushButton("设为 A") - help_tip(self.set_measure_a_button, "把当前选中对象的拾取点设为测量点 A;没有拾取点时使用对象中心。") - self.set_measure_a_button.clicked.connect(lambda _checked=False: self.set_measure_point("A")) - self.set_measure_b_button = QPushButton("设为 B") - help_tip(self.set_measure_b_button, "把当前选中对象的拾取点设为测量点 B;没有拾取点时使用对象中心。") - self.set_measure_b_button.clicked.connect(lambda _checked=False: self.set_measure_point("B")) - self.copy_measure_button = QPushButton("复制测量") - help_tip(self.copy_measure_button, "复制当前测量结果文本,方便记录尺寸。") - self.copy_measure_button.clicked.connect(self.copy_measurement) - self.clear_measure_button = QPushButton("清除测量") - help_tip(self.clear_measure_button, "清除 A/B 测量点和 3D 测量线。不会影响模型。") - self.clear_measure_button.clicked.connect(self.clear_measurement) - measure_buttons.addWidget(self.set_measure_a_button, 0, 0) - measure_buttons.addWidget(self.set_measure_b_button, 0, 1) - measure_buttons.addWidget(self.copy_measure_button, 1, 0) - measure_buttons.addWidget(self.clear_measure_button, 1, 1) - measure_layout.addWidget(self.measure_text) - measure_layout.addLayout(measure_buttons) - self._refresh_measurement_panel() - - export_box = QGroupBox("导出") - export_box.setObjectName("exportSection") - help_tip(export_box, "把当前模型或选中对象导出为 STEP,也可以做基础质量检查和修复。") - export_layout = QVBoxLayout(export_box) - self.export_all_button = QPushButton("导出当前完整 STEP") - help_tip(self.export_all_button, "把当前编辑后的整个模型导出为 STEP 文件。导出前会做基础质量检查。") - self.export_all_button.clicked.connect(self.export_all) - self.export_part_button = QPushButton("导出选中零件") - help_tip(self.export_part_button, "只导出当前选中的零件。适合从装配里拆出一个单独零件。") - self.export_part_button.clicked.connect(self.export_selected_part) - self.export_solid_button = QPushButton("导出选中Solid") - help_tip(self.export_solid_button, "只导出当前选中的实体Solid。适合检查或单独保存某个实体。") - self.export_solid_button.clicked.connect(self.export_selected_solid) - self.export_face_button = QPushButton("导出选中面区域") - help_tip(self.export_face_button, "导出当前选中的面区域;同域高亮的共面/同圆柱区域也会一起导出。") - self.export_face_button.clicked.connect(self.export_selected_face) - self.export_feature_button = QPushButton("导出选中特征区域") - help_tip(self.export_feature_button, "导出 Feature 模式识别到的局部特征区域,例如孔、槽、圆角或凸台候选。") - self.export_feature_button.clicked.connect(self.export_selected_feature) - self.export_edge_button = QPushButton("导出选中Edge") - help_tip(self.export_edge_button, "导出当前选中的Edge。主要用于调试、定位或把边界单独拿出去检查。") - self.export_edge_button.clicked.connect(self.export_selected_edge) - self.export_check_button = QPushButton("检查导出质量") - help_tip(self.export_check_button, "检查当前导出对象是否像有效实体:B-Rep、Face/Edge 数量、体积和包围盒等。") - self.export_check_button.clicked.connect(self.check_export_quality) - self.repair_model_button = QPushButton("修复当前模型") - help_tip(self.repair_model_button, "对完整模型尝试 ShapeFix 和同域面/边合并。会写入历史,可撤销。") - self.repair_model_button.clicked.connect(self.repair_model) - self.repair_selected_button = QPushButton("修复选中零件/Solid") - help_tip(self.repair_selected_button, "只修复当前选中的零件或Solid,范围比修复完整模型更小。会写入历史,可撤销。") - self.repair_selected_button.clicked.connect(self.repair_selected_shape) - export_layout.addWidget(self.export_all_button) - export_layout.addWidget(self.export_part_button) - export_layout.addWidget(self.export_solid_button) - export_layout.addWidget(self.export_face_button) - export_layout.addWidget(self.export_feature_button) - export_layout.addWidget(self.export_edge_button) - export_layout.addWidget(self.export_check_button) - export_layout.addWidget(self.repair_model_button) - export_layout.addWidget(self.repair_selected_button) - - edit_box = QGroupBox("编辑") + edit_box = QGroupBox(panel) edit_box.setObjectName("editSection") - help_tip(edit_box, "这里的按钮会真实修改当前 B-Rep 模型;执行前通常会预览,成功后可撤销。部分能力仍是当前版本的几何近似。") + edit_box.setVisible(False) + self.hidden_edit_controls_box = edit_box edit_layout = QGridLayout(edit_box) edit_layout.addWidget(QLabel("Face偏移"), 0, 0) self.offset_input = QLineEdit("5.0") @@ -770,118 +931,125 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf edit_layout.addWidget(self.rotate_part_button, 24, 0, 1, 2) edit_layout.addWidget(self.rotate_solid_button, 25, 0, 1, 2) - self.cylinders_button = QPushButton("扫描圆柱面") - help_tip(self.cylinders_button, "扫描模型里的圆柱面候选,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。") - self.cylinders_button.clicked.connect(self.list_cylinders) - edit_layout.addWidget(self.cylinders_button, 26, 0, 1, 2) + panel_layout.addWidget(self.object_edit_box) + if ENABLE_EXPORT_PANEL: + panel_layout.addWidget(export_box) + if ENABLE_VIEW_PANEL: + panel_layout.addWidget(view_box) + if ENABLE_MEASURE_PANEL: + panel_layout.addWidget(measure_box) - 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(view_box) - panel_layout.addWidget(measure_box) + if ENABLE_EDITABLE_OBJECTS_PANEL: + editable_box = QGroupBox("可编辑对象") + editable_box.setObjectName("editableSection") + help_tip(editable_box, "自动找出当前版本可以尝试编辑的Face或Edge,方便不用手动到处点。") + editable_layout = QVBoxLayout(editable_box) + editable_button_row = QHBoxLayout() + self.editable_refresh_button = QPushButton("扫描对象") + help_tip(self.editable_refresh_button, "快速扫描一批可以尝试编辑的对象,并列在下面表格里。") + self.editable_refresh_button.clicked.connect( + lambda _checked=False: self.refresh_editable_candidates(show_info=True) + ) + self.editable_deep_scan_button = QPushButton("深度扫描") + help_tip(self.editable_deep_scan_button, "扫描更多候选对象,结果更全但更慢。默认列表找不到目标时再用。") + self.editable_deep_scan_button.clicked.connect( + lambda _checked=False: self.refresh_editable_candidates(show_info=True, deep_scan=True) + ) + editable_button_row.addWidget(self.editable_refresh_button) + editable_button_row.addWidget(self.editable_deep_scan_button) + editable_layout.addLayout(editable_button_row) + self.editable_table = QTableWidget(0, 8) + self.editable_table.setHorizontalHeaderLabels( + ["操作", "ID", "对象", "当前值", "状态", "风险", "置信度", "说明"] + ) + self.editable_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.editable_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.editable_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.editable_table.setMinimumHeight(130) + self.editable_table.setAlternatingRowColors(True) + help_tip(self.editable_table, "这里列出可尝试编辑的对象。点击一行会选中模型里的对应Face或Edge,并自动填入相关输入框。") + self.editable_table.cellClicked.connect(self.on_editable_row_clicked) + editable_layout.addWidget(self.editable_table) + panel_layout.addWidget(editable_box) - editable_box = QGroupBox("可编辑对象") - editable_box.setObjectName("editableSection") - help_tip(editable_box, "自动找出当前版本可以尝试编辑的Face或Edge,方便不用手动到处点。") - editable_layout = QVBoxLayout(editable_box) - editable_button_row = QHBoxLayout() - self.editable_refresh_button = QPushButton("扫描对象") - help_tip(self.editable_refresh_button, "快速扫描一批可以尝试编辑的对象,并列在下面表格里。") - self.editable_refresh_button.clicked.connect(lambda _checked=False: self.refresh_editable_candidates(show_info=True)) - self.editable_deep_scan_button = QPushButton("深度扫描") - help_tip(self.editable_deep_scan_button, "扫描更多候选对象,结果更全但更慢。默认列表找不到目标时再用。") - self.editable_deep_scan_button.clicked.connect( - lambda _checked=False: self.refresh_editable_candidates(show_info=True, deep_scan=True) - ) - editable_button_row.addWidget(self.editable_refresh_button) - editable_button_row.addWidget(self.editable_deep_scan_button) - editable_layout.addLayout(editable_button_row) - self.editable_table = QTableWidget(0, 8) - self.editable_table.setHorizontalHeaderLabels( - ["操作", "ID", "对象", "当前值", "状态", "风险", "置信度", "说明"] - ) - self.editable_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.editable_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - self.editable_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) - self.editable_table.setMinimumHeight(130) - self.editable_table.setAlternatingRowColors(True) - help_tip(self.editable_table, "这里列出可尝试编辑的对象。点击一行会选中模型里的对应Face或Edge,并自动填入相关输入框。") - self.editable_table.cellClicked.connect(self.on_editable_row_clicked) - editable_layout.addWidget(self.editable_table) - panel_layout.addWidget(editable_box) + if ENABLE_CYLINDER_CANDIDATES_PANEL: + candidate_box = QGroupBox("圆柱面候选") + candidate_box.setObjectName("candidateSection") + help_tip(candidate_box, "列出圆柱面候选,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。") + candidate_layout = QVBoxLayout(candidate_box) + filter_layout = QHBoxLayout() + self.cylinders_button = QPushButton("扫描圆柱面") + help_tip(self.cylinders_button, "扫描模型里的圆柱面候选,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。") + self.cylinders_button.clicked.connect(self.list_cylinders) + filter_layout.addWidget(self.cylinders_button) + filter_layout.addWidget(QLabel("类型")) + self.candidate_filter_combo = NoWheelComboBox() + self.candidate_filter_combo.addItems( + [ + "All", + "Hole/Groove", + "Round/Fillet", + "Boss/Outer", + "Unclear", + ] + ) + help_tip( + self.candidate_filter_combo, + "筛选下面的圆柱面候选:孔/槽、圆角、凸台/外圆,或暂时无法明确分类的圆柱面。", + ) + self.candidate_filter_combo.currentTextChanged.connect( + lambda _text: self._filter_cached_cylinder_candidates(show_info=True) + ) + filter_layout.addWidget(self.candidate_filter_combo) + candidate_layout.addLayout(filter_layout) + self.cylinder_table = QTableWidget(0, 8) + self.cylinder_table.setHorizontalHeaderLabels( + ["Face", "Guess", "Diameter", "Span", "Height", "Confidence", "Risk", "零件"] + ) + self.cylinder_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.cylinder_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.cylinder_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.cylinder_table.setMinimumHeight(140) + self.cylinder_table.setAlternatingRowColors(True) + help_tip(self.cylinder_table, "这里显示圆柱面候选。点击一行会选中对应Face,并在属性区显示可用操作和风险。") + self.cylinder_table.cellClicked.connect(self.on_cylinder_row_clicked) + candidate_layout.addWidget(self.cylinder_table) + panel_layout.addWidget(candidate_box) - candidate_box = QGroupBox("圆柱面候选") - candidate_box.setObjectName("candidateSection") - help_tip(candidate_box, "列出圆柱面候选,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。") - candidate_layout = QVBoxLayout(candidate_box) - filter_layout = QHBoxLayout() - filter_layout.addWidget(QLabel("类型")) - self.candidate_filter_combo = NoWheelComboBox() - self.candidate_filter_combo.addItems( - [ - "All", - "Hole/Groove", - "Round/Fillet", - "Boss/Outer", - "Unclear", - ] - ) - help_tip( - self.candidate_filter_combo, - "筛选下面的圆柱面候选:孔/槽、圆角、凸台/外圆,或暂时无法明确分类的圆柱面。", - ) - self.candidate_filter_combo.currentTextChanged.connect( - lambda _text: self._filter_cached_cylinder_candidates(show_info=True) - ) - filter_layout.addWidget(self.candidate_filter_combo) - candidate_layout.addLayout(filter_layout) - self.cylinder_table = QTableWidget(0, 8) - self.cylinder_table.setHorizontalHeaderLabels( - ["Face", "Guess", "Diameter", "Span", "Height", "Confidence", "Risk", "Part"] - ) - self.cylinder_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.cylinder_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - self.cylinder_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) - self.cylinder_table.setMinimumHeight(140) - self.cylinder_table.setAlternatingRowColors(True) - help_tip(self.cylinder_table, "这里显示圆柱面候选。点击一行会选中对应Face,并在属性区显示可用操作和风险。") - self.cylinder_table.cellClicked.connect(self.on_cylinder_row_clicked) - candidate_layout.addWidget(self.cylinder_table) - panel_layout.addWidget(candidate_box) - - history_box = QGroupBox("操作历史") - history_box.setObjectName("historySection") - help_tip(history_box, "查看已经成功执行的编辑、差异预览,并导出报告或 JSON 历史。") - history_layout = QVBoxLayout(history_box) - self.history_list = QListWidget() - self.history_list.setMinimumHeight(110) - help_tip(self.history_list, "成功执行过的编辑会出现在这里。点击一条记录可查看参数、差异预览和目标位置。") - self.history_list.currentRowChanged.connect(self.on_history_row_changed) - self.history_list.currentRowChanged.connect(lambda _row: self._update_action_states()) - history_buttons = QHBoxLayout() - self.clear_diff_button = QPushButton("清除差异预览") - help_tip(self.clear_diff_button, "关闭历史记录产生的红/绿差异叠加和热力图显示。不会修改模型。") - self.clear_diff_button.clicked.connect(lambda _checked=False: self.clear_diff_preview()) - self.export_diff_button = QPushButton("导出差异报告") - help_tip(self.export_diff_button, "把当前选中的历史记录导出成文本报告,包含参数、拓扑变化和几何差异统计。") - self.export_diff_button.clicked.connect(self.export_diff_report) - self.export_history_button = QPushButton("导出编辑历史") - help_tip(self.export_history_button, "把本次会话里的所有编辑记录导出为 JSON,方便留档或后续复盘。") - self.export_history_button.clicked.connect(self.export_operation_history) - history_layout.addWidget(self.history_list) - history_buttons.addWidget(self.clear_diff_button) - history_buttons.addWidget(self.export_diff_button) - history_buttons.addWidget(self.export_history_button) - history_layout.addLayout(history_buttons) - panel_layout.addWidget(history_box) + if ENABLE_HISTORY_PANEL: + history_box = QGroupBox("操作历史") + history_box.setObjectName("historySection") + help_tip(history_box, "查看已经成功执行的编辑、差异预览,并导出报告或 JSON 历史。") + history_layout = QVBoxLayout(history_box) + self.history_list = QListWidget() + self.history_list.setMinimumHeight(110) + help_tip(self.history_list, "成功执行过的编辑会出现在这里。点击一条记录可查看参数、差异预览和目标位置。") + self.history_list.currentRowChanged.connect(self.on_history_row_changed) + self.history_list.currentRowChanged.connect(lambda _row: self._update_action_states()) + history_buttons = QHBoxLayout() + self.undo_button = QPushButton("撤销") + help_tip(self.undo_button, "撤销上一次成功编辑,把模型恢复到编辑前快照。") + self.undo_button.clicked.connect(self.undo_edit) + self.redo_button = QPushButton("重做") + help_tip(self.redo_button, "重做刚刚撤销的编辑。") + self.redo_button.clicked.connect(self.redo_edit) + self.clear_diff_button = QPushButton("清除差异预览") + help_tip(self.clear_diff_button, "关闭历史记录产生的红/绿差异叠加和热力图显示。不会修改模型。") + self.clear_diff_button.clicked.connect(lambda _checked=False: self.clear_diff_preview()) + self.export_diff_button = QPushButton("导出差异报告") + help_tip(self.export_diff_button, "把当前选中的历史记录导出成文本报告,包含参数、拓扑变化和几何差异统计。") + self.export_diff_button.clicked.connect(self.export_diff_report) + self.export_history_button = QPushButton("导出编辑历史") + help_tip(self.export_history_button, "把本次会话里的所有编辑记录导出为 JSON,方便留档或后续复盘。") + self.export_history_button.clicked.connect(self.export_operation_history) + history_layout.addWidget(self.history_list) + history_buttons.addWidget(self.undo_button) + history_buttons.addWidget(self.redo_button) + history_buttons.addWidget(self.clear_diff_button) + history_buttons.addWidget(self.export_diff_button) + history_buttons.addWidget(self.export_history_button) + history_layout.addLayout(history_buttons) + panel_layout.addWidget(history_box) info_box = QGroupBox("选中对象信息") info_box.setObjectName("infoSection") @@ -889,7 +1057,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf info_layout = QVBoxLayout(info_box) info_buttons = QHBoxLayout() self.copy_id_button = QPushButton("复制 ID") - help_tip(self.copy_id_button, "复制当前选中对象的 ID。Face/Feature 会优先复制逻辑 Face ID。") + help_tip(self.copy_id_button, "复制当前选中对象的 ID。Face/特征会优先复制逻辑 Face ID。") self.copy_id_button.clicked.connect(self.copy_selected_id) self.copy_pick_button = QPushButton("复制坐标") help_tip(self.copy_pick_button, "复制最近一次鼠标点到模型上的三维坐标。") diff --git a/step_editor/export.py b/step_editor/export.py index 95c7be7..2bf222a 100644 --- a/step_editor/export.py +++ b/step_editor/export.py @@ -80,7 +80,7 @@ class ExportMixin: part = self.part_by_id(target_id) if part is None: raise ValueError(f"Unknown part id {target_id}") - info = _shape_quality_info(f"零件 {part.id}: {part.name}", part.shape, expect_solid=True) + info = _shape_quality_info(f"特征 {part.id}: {part.name}", part.shape, expect_solid=True) info["part_id"] = part.id return info if scope == "solid": diff --git a/step_editor/features.py b/step_editor/features.py index dc54f87..4c908c7 100644 --- a/step_editor/features.py +++ b/step_editor/features.py @@ -986,7 +986,7 @@ class FeatureMixin: "cutter_strategy": "bounded-to-selected-cylinder-v-range", "cutter_note": ( "有限长度切削:优先按同域圆柱侧壁整体 V 范围生成 cutter," - "如果没有同域拆分则退回选中Face范围,减少贯穿整个零件的误切风险。" + "如果没有同域拆分则退回选中Face范围,减少贯穿整个特征的误切风险。" ), "cutter_scope_face_ids": axis_range["same_domain_face_ids"], "cutter_scope_face_count": axis_range["same_domain_face_count"], diff --git a/step_editor/geometry_utils.py b/step_editor/geometry_utils.py index 30ba942..6b0a67a 100644 --- a/step_editor/geometry_utils.py +++ b/step_editor/geometry_utils.py @@ -197,7 +197,7 @@ def _shape_quality_info(label: str, shape: TopoDS_Shape, expect_solid: bool) -> elif expect_solid and solids > 1: warnings.append( f"检测到 {solids} 个Solid。" - "如果这不是有意的多实体零件,导出后可能看起来像多个体叠在一起或彼此分离。" + "如果这不是有意的多实体特征,导出后可能看起来像多个体叠在一起或彼此分离。" ) geometry_info = _shape_volume_info(shape) @@ -560,7 +560,8 @@ def _axis_aligned_edge_candidates( def _finalize_boolean_result(op, operation_name: str) -> TopoDS_Shape: op.SetNonDestructive(True) - if hasattr(op, "SetGlue"): + use_glue = "cut" not in operation_name.lower() + if use_glue and hasattr(op, "SetGlue"): try: op.SetGlue(BOPAlgo_GlueFull) except Exception: @@ -764,7 +765,14 @@ def _cylinder_resize_readiness( else: diameter_delta = abs(new_diameter - current_diameter) delta_ratio = diameter_delta / max(current_diameter, 1e-9) - if delta_ratio > 1.0: + if new_diameter > current_diameter * 2.0: + status = "blocked" + risk = "blocked" + blockers.append( + "目标直径超过当前直径的 2 倍;当前受限 B-Rep 孔径编辑会直接阻止这种极端放大," + "否则很容易出现布尔返回成功但孔壁没有真正变成目标直径。" + ) + elif delta_ratio > 1.0: risk = _max_risk(risk, "high") warnings.append("目标直径变化超过当前直径的 100%,很可能导致大范围误切或布尔失败。") elif delta_ratio > 0.35: @@ -772,8 +780,11 @@ def _cylinder_resize_readiness( warnings.append("目标直径变化超过当前直径的 35%,请确认预览范围。") if height_estimate > 0 and new_diameter > height_estimate * 2.0: - risk = _max_risk(risk, "high") - warnings.append("目标直径超过圆柱面估算高度的 2 倍,几何比例异常。") + status = "blocked" + risk = "blocked" + blockers.append( + "目标直径超过圆柱面估算高度的 2 倍;当前版本无法稳定判断周边材料余量,已阻止。" + ) elif height_estimate > 0 and new_diameter > height_estimate: risk = _max_risk(risk, "medium") warnings.append("目标直径超过圆柱面估算高度,可能不是常规孔径修改。") diff --git a/step_editor/info_panel.py b/step_editor/info_panel.py index 45283e4..a5792c8 100644 --- a/step_editor/info_panel.py +++ b/step_editor/info_panel.py @@ -27,7 +27,9 @@ class InfoPanelMixin: self.info_text.setPlainText(self.current_info_text) self._populate_info_tree(info) self.info_tabs.setCurrentWidget(self.info_tree) - if hasattr(self, "export_all_button"): + if hasattr(self, "_refresh_property_editor"): + self._refresh_property_editor() + if hasattr(self, "mode_combo"): self._update_action_states() def set_plain_info(self, text: str) -> None: @@ -36,7 +38,9 @@ class InfoPanelMixin: self.info_tree.clear() self.info_text.setPlainText(text) self.info_tabs.setCurrentWidget(self.info_text) - if hasattr(self, "export_all_button"): + if hasattr(self, "_clear_property_editor"): + self._clear_property_editor() + if hasattr(self, "mode_combo"): self._update_action_states() def _populate_info_tree(self, info: dict[str, object]) -> None: diff --git a/step_editor/model.py b/step_editor/model.py index d575432..b66783d 100644 --- a/step_editor/model.py +++ b/step_editor/model.py @@ -9,7 +9,8 @@ from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse from OCC.Core.BRepBndLib import brepbndlib from OCC.Core.BOPAlgo import BOPAlgo_GlueFull -from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform +from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeVertex, BRepBuilderAPI_Transform +from OCC.Core.BRepExtrema import BRepExtrema_DistShapeShape from OCC.Core.BRepCheck import BRepCheck_Analyzer from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet @@ -948,6 +949,40 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd best_edge_id = edge_id return best_edge_id if best_edge_id is not None else valid_edge_ids[0] + def nearest_face_id_to_point( + self, + face_ids: Iterable[int], + point: tuple[float, float, float] | None, + ) -> int | None: + valid_face_ids = [int(face_id) for face_id in face_ids if 0 <= int(face_id) < len(self.faces)] + if not valid_face_ids: + return None + if point is None: + return valid_face_ids[0] + + point_vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(float(point[0]), float(point[1]), float(point[2]))).Vertex() + best_face_id: int | None = None + best_distance = math.inf + for face_id in valid_face_ids: + try: + extrema = BRepExtrema_DistShapeShape(point_vertex, self.faces[face_id]) + extrema.Perform() + if not extrema.IsDone(): + continue + distance = float(extrema.Value()) + except Exception: + center = _surface_center(self.faces[face_id]) + distance = math.sqrt( + _point_distance_sq( + (float(point[0]), float(point[1]), float(point[2])), + _point_tuple(center), + ) + ) + if distance < best_distance: + best_distance = distance + best_face_id = face_id + return best_face_id if best_face_id is not None else valid_face_ids[0] + def _adjacent_face_ids_for_edges(self, edge_ids: list[int], face_id: int) -> list[int]: if not edge_ids: return [] diff --git a/step_editor/operations.py b/step_editor/operations.py index f38367c..73323fe 100644 --- a/step_editor/operations.py +++ b/step_editor/operations.py @@ -106,18 +106,18 @@ class OperationMixin: def repair_part(self, part_id: int) -> str: part = self.part_by_id(part_id) if part is None: - raise ValueError(f"Unknown part id {part_id}") + raise ValueError(f"未知零件 ID {part_id}") if part.shape.IsNull(): - raise RuntimeError(f"Part {part_id} has a null shape and cannot be repaired.") + raise RuntimeError(f"零件 {part_id} 为空 shape,无法修复。") before_stats = self.part_topology_stats(part_id) repaired = _prepare_shape_for_step_export(part.shape) if repaired.IsNull(): - raise RuntimeError(f"Part {part_id} repair returned a null shape.") + raise RuntimeError(f"零件 {part_id} 修复结果为空 shape。") part.shape = repaired self.refresh_topology() after_stats = self.part_topology_stats(part_id) return ( - f"Part repair completed: part {part_id}, " + f"零件修复完成: 零件 {part_id}, " f"solids {before_stats.solids}->{after_stats.solids}, " f"faces {before_stats.faces}->{after_stats.faces}, " f"edges {before_stats.edges}->{after_stats.edges}." @@ -180,7 +180,7 @@ class OperationMixin: readiness["fillet_risk"] = _max_risk(str(readiness["fillet_risk"]), "high") readiness["fillet_warnings"] = _join_nonempty( readiness["fillet_warnings"], - f"当前零件包含 {part_stats.solids} 个Solid,Edge倒圆会作用在整个Part shape 上,请导出前检查结果。", + f"当前零件包含 {part_stats.solids} 个Solid,Edge倒圆会作用在整个零件 shape 上,请导出前检查结果。", ) readiness["fillet_note"] = _join_nonempty(readiness["fillet_note"], readiness["fillet_warnings"]) @@ -224,7 +224,7 @@ class OperationMixin: readiness["chamfer_risk"] = _max_risk(str(readiness["chamfer_risk"]), "high") readiness["chamfer_warnings"] = _join_nonempty( readiness["chamfer_warnings"], - f"当前零件包含 {part_stats.solids} 个Solid,Edge倒角会作用在整个Part shape 上,请导出前检查结果。", + f"当前零件包含 {part_stats.solids} 个Solid,Edge倒角会作用在整个零件 shape 上,请导出前检查结果。", ) readiness["chamfer_note"] = _join_nonempty(readiness["chamfer_note"], readiness["chamfer_warnings"]) @@ -479,7 +479,7 @@ class OperationMixin: part_id = int(edge_info.get("part_id", -1)) part = self.part_by_id(part_id) if part is None: - return None, "局部边形变不可用:找不到所属Part。" + return None, "局部边形变不可用:找不到所属零件。" solid = self.solids[solid_id][1] solid_faces = _explore(solid, TopAbs_FACE) if not solid_faces: @@ -760,7 +760,7 @@ class OperationMixin: solid_id = int(edge_info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None if part is None: - return None, "平面曲线径向缩放不可用:找不到所属Part。" + return None, "平面曲线径向缩放不可用:找不到所属零件。" part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" scale = float(target_length) / max(current_length, 1e-9) @@ -1634,7 +1634,7 @@ class OperationMixin: or ("solid" if solid_id >= 0 else "part") ) edge_ids = [edge_id for edge_id in range(len(self.edges)) if part_id < 0 or self.edge_part_ids[edge_id] == part_id] - scope = f"Part {part_id}" if part_id >= 0 else "model" + scope = f"零件 {part_id}" if part_id >= 0 else "model" if target_kind == "solid" and solid_id >= 0: solid_edge_ids = [edge_id for edge_id in edge_ids if self.edge_solid_ids[edge_id] == solid_id] if solid_edge_ids: @@ -2246,6 +2246,8 @@ class OperationMixin: return None if face_id < 0 or face_id >= len(self.faces): return None + if len(_explore(self.faces[face_id], TopAbs_WIRE)) > 1: + return None cap_center = _surface_center(self.faces[face_id]) boundary_edge_ids = self._face_boundary_edge_ids(face_id) @@ -2256,7 +2258,7 @@ class OperationMixin: diagonal = _shape_diagonal(self.shape) tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) outward_dir = gp_Dir(float(outward[0]), float(outward[1]), float(outward[2])) - best: tuple[float, dict[str, object]] | None = None + candidates: list[tuple[float, dict[str, object]]] = [] for adjacent_id in adjacent_face_ids: side_surf = BRepAdaptor_Surface(self.faces[adjacent_id]) @@ -2312,10 +2314,18 @@ class OperationMixin: "start_parameter": new_min, "end_parameter": new_max, } - if best is None or score < best[0]: - best = (score, candidate) + candidates.append((score, candidate)) - return best[1] if best is not None else None + if not candidates: + return None + distinct_radii: list[float] = [] + for _score, candidate in candidates: + radius = float(candidate["radius"]) + if not any(abs(radius - existing) <= max(radius, existing, 1.0) * 1e-5 for existing in distinct_radii): + distinct_radii.append(radius) + if len(distinct_radii) > 1: + return None + return min(candidates, key=lambda item: item[0])[1] def resize_existing_fillet(self, face_id: int, target_radius: float) -> str: plan = self.existing_fillet_resize_plan(face_id, target_radius) @@ -2441,6 +2451,7 @@ class OperationMixin: raise ValueError(f"Unknown part id {part_id}") direction = cyl.Axis().Direction() + old_part_shape = part.shape source_shape = part.shape if plan["resize_mode"] == "shrink": fill_start = gp_Pnt(*plan["fill_start_point"]) @@ -2461,14 +2472,89 @@ class OperationMixin: result = _finalize_boolean_result(op, "cylinder cut") part.shape = result self.refresh_topology() + verification = self._verify_cylindrical_resize_result(plan, part_id) + if not verification["matched"]: + part.shape = old_part_shape + self.refresh_topology() + detail = str(verification.get("detail", "")) + raise RuntimeError( + "孔/圆柱直径布尔计算返回了结果,但结果里没有检测到目标直径的圆柱面," + "已回滚到修改前状态。" + f"{detail}" + ) action = "enlarged by bounded cut" if plan["resize_mode"] == "enlarge" else "shrunk by fill and recut" return ( f"Cylindrical resize completed: diameter {old_radius * 2.0:g} -> {new_diameter:g}, " f"mode={plan['resize_mode']}, action={action}, " f"risk={plan['risk']}, feature={plan['feature_guess']}, " - f"cutter={plan['cutter_strategy']}, height={float(plan['cutter_height']):g}." + f"cutter={plan['cutter_strategy']}, height={float(plan['cutter_height']):g}, " + f"verified_face={verification.get('face_id', '')}." ) + def _verify_cylindrical_resize_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: + target_diameter = float(plan.get("target_diameter", 0.0)) + target_radius = target_diameter / 2.0 + if target_radius <= 1e-9: + return {"matched": False, "detail": " 目标直径无效。"} + try: + axis_point = gp_Pnt(*plan["cutter_axis_point"]) + axis_direction = gp_Dir(*plan["cutter_axis_direction"]) + except Exception: + return {"matched": False, "detail": " 缺少原孔轴信息,无法确认结果。"} + + part = self.part_by_id(part_id) + diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_radius, 1.0) + radius_tolerance = max(target_radius * 0.02, diagonal * 1e-6, 1e-5) + axis_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4) + best: dict[str, object] | None = None + best_score = math.inf + + for face_id, face in enumerate(self.faces): + if self.face_part_ids[face_id] != part_id: + continue + try: + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + continue + cyl = surf.Cylinder() + candidate_radius = float(cyl.Radius()) + radius_error = abs(candidate_radius - target_radius) + axis_dot = abs(_direction_dot(axis_direction, cyl.Axis().Direction())) + if axis_dot < 1.0 - 1e-5: + continue + axis_distance = _point_axis_distance(axis_point, axis_direction, cyl.Axis().Location()) + except Exception: + continue + + score = radius_error / max(radius_tolerance, 1e-9) + axis_distance / max(axis_tolerance, 1e-9) + candidate = { + "matched": radius_error <= radius_tolerance and axis_distance <= axis_tolerance, + "face_id": face_id, + "diameter": candidate_radius * 2.0, + "radius_error": radius_error, + "axis_distance": axis_distance, + "radius_tolerance": radius_tolerance, + "axis_tolerance": axis_tolerance, + } + if candidate["matched"]: + return candidate + if score < best_score: + best_score = score + best = candidate + + if best is None: + return {"matched": False, "detail": " 未找到同零件内与原孔轴平行的圆柱面。"} + return { + "matched": False, + "detail": ( + f" 最近候选 Face {best['face_id']} 的直径约 {float(best['diameter']):.6g}," + f"目标直径 {target_diameter:.6g}," + f"半径误差 {float(best['radius_error']):.6g}," + f"轴距 {float(best['axis_distance']):.6g}。" + ), + **best, + } + def resize_cylindrical_boss(self, face_id: int, new_diameter: float) -> str: plan = self.cylindrical_boss_resize_plan(face_id, new_diameter) if plan["status"] == "blocked": diff --git a/step_editor/transforms.py b/step_editor/transforms.py index d14532b..b943d3e 100644 --- a/step_editor/transforms.py +++ b/step_editor/transforms.py @@ -94,7 +94,7 @@ class TransformMixin: risk = readiness["translate_risk"] status = readiness["translate_status"] if part_solid_count <= 1 and status != "blocked": - warnings = _join_nonempty(warnings, "当前Part只有一个Solid,平移Solid实际会移动整个Part shape。") + warnings = _join_nonempty(warnings, "当前零件只有一个Solid,平移Solid实际会移动整个零件 shape。") if risk == "low": risk = "medium" status = "caution" @@ -119,12 +119,12 @@ class TransformMixin: raise ValueError(str(plan["message"])) part = self.part_by_id(part_id) if part is None: - raise ValueError(f"Unknown part id {part_id}") + raise ValueError(f"未知零件 ID {part_id}") part.shape = _translated_shape_by_vector(part.shape, vector) _ensure_valid_shape(part.shape) self.refresh_topology() return ( - f"Part translated: part {part_id}, vector={_format_tuple(vector)}, " + f"零件已平移: 零件 {part_id}, vector={_format_tuple(vector)}, " f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}." ) @@ -192,7 +192,7 @@ class TransformMixin: risk = readiness["rotate_risk"] status = readiness["rotate_status"] if part_solid_count <= 1 and status != "blocked": - warnings = _join_nonempty(warnings, "当前Part只有一个Solid,旋转Solid实际会旋转整个Part shape。") + warnings = _join_nonempty(warnings, "当前零件只有一个Solid,旋转Solid实际会旋转整个零件 shape。") if risk == "low": risk = "medium" status = "caution" @@ -218,12 +218,12 @@ class TransformMixin: raise ValueError(str(plan["message"])) part = self.part_by_id(part_id) if part is None: - raise ValueError(f"Unknown part id {part_id}") + raise ValueError(f"未知零件 ID {part_id}") part.shape = _rotated_shape(part.shape, str(plan["rotation_axis"]), float(plan["rotation_angle_degrees"]), plan["rotation_center"]) _ensure_valid_shape(part.shape) self.refresh_topology() return ( - f"Part rotated: part {part_id}, axis={plan['rotation_axis']}, " + f"零件已旋转: 零件 {part_id}, axis={plan['rotation_axis']}, " f"angle={float(plan['rotation_angle_degrees']):g}, center={_format_tuple(plan['rotation_center'])}, " f"risk={plan['risk']}." ) diff --git a/step_editor/ui_helpers.py b/step_editor/ui_helpers.py index 6ce2968..da49313 100644 --- a/step_editor/ui_helpers.py +++ b/step_editor/ui_helpers.py @@ -358,7 +358,7 @@ INFO_LABELS = { "name": "名称", "path": "层级路径", "file": "文件", - "part_id": "Part ID", + "part_id": "零件 ID", "solid_id": "Solid ID", "logical_face_id": "逻辑 Face ID", "face_region_logical_id": "面区域逻辑 ID", @@ -694,7 +694,7 @@ INFO_LABELS = { "translate_warnings": "平移警告", "translate_blockers": "平移阻止原因", "translate_note": "平移说明", - "part_solid_count": "Part 内 Solid 数", + "part_solid_count": "零件内 Solid 数", "rotate_status": "旋转状态", "rotate_risk": "旋转风险", "rotate_warnings": "旋转警告", @@ -713,6 +713,26 @@ EDITABLE_ACTION_ROLE = Qt.UserRole + 1 EDITABLE_TARGET_KIND_ROLE = Qt.UserRole + 2 +SELECTION_MODE_LABELS = { + "Part": "零件", + "Solid": "Solid", + "Face": "Face", + "Edge": "Edge", + "Feature": "特征", +} +SELECTION_MODE_VALUES = {label: mode for mode, label in SELECTION_MODE_LABELS.items()} + + +def _selection_mode_label(mode: object) -> str: + mode_text = str(mode or "") + return SELECTION_MODE_LABELS.get(mode_text, mode_text or "Face") + + +def _selection_mode_value(mode_or_label: object) -> str: + mode_text = str(mode_or_label or "") + return SELECTION_MODE_VALUES.get(mode_text, mode_text or "Face") + + def _format_float(value: float) -> str: return f"{value:.6g}" diff --git a/step_editor/window_actions.py b/step_editor/window_actions.py index 4e31f5f..bd73a7e 100644 --- a/step_editor/window_actions.py +++ b/step_editor/window_actions.py @@ -105,7 +105,7 @@ class WindowActionMixin: if self._edit_busy("请等待当前编辑完成后再导出。"): return if self.selected_face_id is None: - QMessageBox.information(self, "未选择 Face", "请先切换到 Face 或 Feature 模式并选择一个 Face。") + QMessageBox.information(self, "未选择 Face", "请先切换到 Face 或特征模式并选择一个 Face。") return if not self._confirm_export_quality("face", self.selected_face_id): return @@ -128,7 +128,7 @@ class WindowActionMixin: if self._edit_busy("请等待当前编辑完成后再导出。"): return if self.selected_face_id is None: - QMessageBox.information(self, "未选择特征", "请先切换到 Feature 模式并选择一个局部特征。") + QMessageBox.information(self, "未选择特征", "请先切换到特征模式并选择一个特征。") return if not self._confirm_export_quality("feature", self.selected_face_id): return @@ -281,7 +281,7 @@ class WindowActionMixin: self._run_edit_action( action, operation_name="修复选中零件", - target=f"Part {part_id}", + target=f"零件 {part_id}", parameters={ "scope": "part", "part_id": part_id, @@ -292,7 +292,7 @@ class WindowActionMixin: ) return - QMessageBox.information(self, "未选择对象", "请先选择 Part、Solid、Face 或 Edge。") + QMessageBox.information(self, "未选择对象", "请先选择零件、Solid、Face 或 Edge。") self.statusBar().showMessage("未选择可修复对象") def push_pull_face(self) -> None: @@ -436,6 +436,203 @@ class WindowActionMixin: target_id=face_id, ) + @staticmethod + def _max_quick_risk(current: str, candidate: str) -> str: + order = {"low": 0, "medium": 1, "high": 2, "blocked": 3} + return current if order.get(current, 0) >= order.get(candidate, 0) else candidate + + def _selected_face_info_snapshot(self) -> dict[str, object]: + info = dict(getattr(self, "current_info_values", {}) or {}) + if self.model is None or self.selected_face_id is None: + return info + + selected_face_id = int(self.selected_face_id) + possible_ids = ( + info.get("face_id"), + info.get("topological_face_id"), + info.get("feature_source_face_id"), + info.get("face_region_source_face_id"), + ) + has_selected_id = False + for value in possible_ids: + try: + if value is not None and int(value) == selected_face_id: + has_selected_id = True + break + except (TypeError, ValueError): + pass + + has_any_face_id = any(value is not None for value in possible_ids) + if info and has_any_face_id and not has_selected_id: + info = {} + if info and str(info.get("kind", "")) in {"edge", "part", "solid"} and not has_selected_id: + info = {} + if "surface" not in info and "diameter" not in info: + try: + info = dict(self.model.face_info(selected_face_id)) + except Exception: + pass + return info + + def _quick_cylinder_resize_plan( + self, + face_id: int, + target_diameter: float, + info: dict[str, object] | None = None, + ) -> dict[str, object]: + info = dict(info or self._selected_face_info_snapshot()) + warnings: list[str] = [] + blockers: list[str] = [] + risk = "low" + status = "ready" + + surface = str(info.get("surface", "")) + current_diameter = _float_or_none(info.get("diameter")) + height_estimate = _float_or_none(info.get("height_estimate")) + if height_estimate is None: + height_estimate = _float_or_none(info.get("same_domain_height_estimate")) + if height_estimate is None: + height_estimate = _float_or_none(info.get("hole_depth_estimate")) + guess = str(info.get("feature_guess", "cylindrical face")) + confidence = str(info.get("confidence", "low")) + angular_span = _float_or_none(info.get("angular_span")) + + if surface != "cylinder" or current_diameter is None or current_diameter <= 1e-9: + blockers.append("当前选中对象不是可识别的圆柱面,不能调整孔/槽直径。") + if target_diameter <= 0: + blockers.append("目标直径必须大于 0。") + + if guess == "round/fillet candidate": + risk = self._max_quick_risk(risk, "high") + warnings.append("当前圆柱面更像圆角/倒圆,调整孔径可能误切圆角。") + elif guess == "boss/outer-round candidate": + risk = self._max_quick_risk(risk, "high") + warnings.append("当前圆柱面更像凸台或外圆,调整孔径可能切掉外部结构。") + elif guess != "hole/groove candidate": + risk = self._max_quick_risk(risk, "high") + warnings.append("当前圆柱面还没有被稳定识别为孔/槽候选。") + + if guess == "hole/groove candidate" and confidence == "low": + risk = self._max_quick_risk(risk, "medium") + warnings.append("孔/槽判断置信度较低。") + if ( + guess == "hole/groove candidate" + and angular_span is not None + and angular_span < math.tau * 0.92 + ): + risk = self._max_quick_risk(risk, "medium") + warnings.append("这是局部圆柱面,更像槽或半孔,不是完整圆孔。") + + delta_diameter = None + delta_ratio = None + target_to_height_ratio = None + resize_mode = "unknown" + if current_diameter is not None and current_diameter > 1e-9: + delta_diameter = target_diameter - current_diameter + delta_ratio = abs(delta_diameter) / current_diameter + resize_mode = "expand" if delta_diameter > 0 else "shrink" + if abs(delta_diameter) <= max(current_diameter * 1e-5, 1e-6): + blockers.append("目标直径与当前直径几乎相同,不需要修改。") + elif target_diameter > current_diameter * 2.0: + blockers.append( + "目标直径超过当前直径的 2 倍;当前版本会阻止这种极端放大,避免布尔计算长时间卡死。" + ) + elif delta_ratio > 1.0: + risk = self._max_quick_risk(risk, "high") + warnings.append("目标直径变化超过当前直径的 100%,很可能导致大范围误切或布尔失败。") + elif delta_ratio > 0.35: + risk = self._max_quick_risk(risk, "medium") + warnings.append("目标直径变化超过当前直径的 35%。") + + if target_diameter < current_diameter: + if guess != "hole/groove candidate": + blockers.append("缩小孔径当前版本只支持明确的孔/槽候选。") + else: + risk = self._max_quick_risk(risk, "high") + warnings.append("缩小孔径需要先补料再重切,属于高风险实验路径。") + + if height_estimate is not None and height_estimate > 1e-9: + target_to_height_ratio = target_diameter / height_estimate + if target_diameter > height_estimate * 2.0: + blockers.append("目标直径超过圆柱面估算高度的 2 倍,当前版本暂不放行。") + elif target_diameter > height_estimate: + risk = self._max_quick_risk(risk, "medium") + warnings.append("目标直径大于圆柱面估算高度,请确认单位和目标。") + + if blockers: + status = "blocked" + risk = "blocked" + elif risk in {"medium", "high"}: + status = "caution" + + message = ";".join(blockers + warnings) if blockers or warnings else "可以尝试调整孔/槽直径。" + return { + "status": status, + "risk": risk, + "message": message, + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": info.get("part_id"), + "solid_id": info.get("solid_id"), + "current_diameter": current_diameter, + "target_diameter": target_diameter, + "delta_diameter": delta_diameter, + "diameter_delta_ratio": delta_ratio, + "target_to_height_ratio": target_to_height_ratio, + "resize_mode": resize_mode, + "feature_type": info.get("feature_type"), + "feature_guess": guess, + "confidence": confidence, + "angular_span": angular_span, + "height_estimate": height_estimate, + "material_vote_summary": info.get("material_vote_summary"), + "cylinder_end_type": info.get("cylinder_end_type"), + "hole_depth_estimate": info.get("hole_depth_estimate"), + "feature_bottom_face_ids": info.get("feature_bottom_face_ids"), + "feature_opening_face_ids": info.get("feature_opening_face_ids"), + "quick_preflight": True, + } + + def _confirm_quick_edit_plan( + self, + title: str, + plan: dict[str, object], + lines: list[str], + blocked_status: str, + cancelled_status: str, + ) -> bool: + if plan.get("status") == "blocked": + QMessageBox.information(self, f"不能{title}", str(plan.get("message", ""))) + self.statusBar().showMessage(blocked_status) + return False + if self._block_unisolated_high_risk_operation(f"已阻止高风险{title}", plan): + return False + if plan.get("risk") == "low": + return True + + warnings = str(plan.get("warnings", "")) + warnings_line = f"\n警告: {warnings}\n" if warnings else "" + result = QMessageBox.question( + self, + f"确认{title}", + ( + "\n".join(lines) + + f"\n风险: {plan.get('risk')}\n" + + warnings_line + + f"\n{plan.get('message', '')}\n\n" + "为避免复杂 STEP 在界面线程卡死,本次不会先生成红/绿预览;" + "详细几何方案会进入后台计算,完成后自动刷新模型。\n\n" + "确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if result != QMessageBox.StandardButton.Yes: + self.statusBar().showMessage(cancelled_status) + return False + return True + def resize_hole(self) -> None: if self.model is None: return @@ -449,64 +646,29 @@ class WindowActionMixin: except ValueError: QMessageBox.critical(self, "直径无效", "请输入数字形式的目标直径。") return - info = self.model.face_info(self.selected_face_id) - if "diameter" not in info: - QMessageBox.information(self, "不是圆柱面", "当前选中的 Face 不是圆柱面,不能调整圆柱孔径。") - return - plan = self.model.cylindrical_resize_plan(self.selected_face_id, diameter) - if plan["status"] == "blocked": - QMessageBox.information(self, "不能调整孔径", str(plan["message"])) - self.statusBar().showMessage("圆柱孔径调整已阻止") - return - guess = str(info.get("feature_guess", "cylindrical face")) - if plan["risk"] != "low": - target_to_height_ratio = plan.get("target_to_height_ratio", "") - target_to_height_line = ( - "" - if target_to_height_ratio is None or target_to_height_ratio == "" - else f"目标直径/估算高度: {_format_value(target_to_height_ratio)}\n" - ) - warnings = str(plan.get("warnings", "")) - warnings_line = f"警告: {warnings}\n\n" if warnings else "" - result = QMessageBox.question( - self, - "确认圆柱孔径调整", - ( - f"face: {plan['face_id']}\n" - f"当前直径: {_format_value(plan['current_diameter'])}\n" - f"目标直径: {_format_value(plan['target_diameter'])}\n" - f"直径变化量: {_format_value(plan['delta_diameter'])}\n" - f"直径变化比例: {_format_percent(plan['diameter_delta_ratio'])}\n" - f"{target_to_height_line}" - f"调整模式: {plan['resize_mode']}\n" - f"候选判断: {plan['feature_guess']}\n" - f"置信度: {plan['confidence']}\n" - f"材料投票: {plan['material_vote_summary']}\n" - f"端部类型: {plan['cylinder_end_type']}\n" - f"深度估算: {_format_value(plan['hole_depth_estimate'])}\n" - f"疑似底面 Face: {_format_value(plan.get('feature_bottom_face_ids', ''))}\n" - f"开口端相邻 Face: {_format_value(plan.get('feature_opening_face_ids', ''))}\n" - f"风险: {plan['risk']}\n\n" - f"Cutter: {plan['cutter_strategy']}\n" - f"Cutter 高度: {_format_value(plan['cutter_height'])}\n" - f"Cutter 起点/终点余量: {_format_value(plan['cutter_start_margin'])} / " - f"{_format_value(plan['cutter_end_margin'])}\n\n" - f"底面保护: {_format_value(plan.get('cutter_bottom_protection', ''))}\n" - f"{_format_value(plan.get('cutter_bottom_note', ''))}\n\n" - f"补料策略: {plan.get('fill_strategy', '')}\n\n" - f"{warnings_line}" - f"{plan['message']}\n\n" - "继续操作会对当前零件执行受限B-Rep布尔修改。\n\n" - "确定继续吗?" - ), - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if result != QMessageBox.StandardButton.Yes: - self.statusBar().showMessage("已取消圆柱孔径调整") - return face_id = self.selected_face_id - self._show_cylinder_resize_preview(face_id, diameter) + info = self._selected_face_info_snapshot() + plan = self._quick_cylinder_resize_plan(face_id, diameter, info) + lines = [ + f"Face: {face_id}", + f"当前直径: {_format_value(plan.get('current_diameter'))}", + f"目标直径: {_format_value(plan.get('target_diameter'))}", + f"直径变化量: {_format_value(plan.get('delta_diameter'))}", + f"直径变化比例: {_format_percent(plan.get('diameter_delta_ratio'))}", + f"候选判断: {plan.get('feature_guess')}", + f"置信度: {plan.get('confidence')}", + ] + if plan.get("target_to_height_ratio") not in {None, ""}: + lines.append(f"目标直径/估算高度: {_format_value(plan.get('target_to_height_ratio'))}") + if not self._confirm_quick_edit_plan( + "圆柱孔径调整", + plan, + lines, + "圆柱孔径调整已阻止", + "已取消圆柱孔径调整", + ): + return + self.clear_edit_preview(render=False) def action(): return self.model.resize_cylindrical_hole(face_id, diameter) @@ -519,7 +681,7 @@ class WindowActionMixin: "part_id": plan.get("part_id"), "solid_id": plan.get("solid_id"), "new_diameter": diameter, - "old_diameter": info.get("diameter"), + "old_diameter": plan.get("current_diameter"), "delta_diameter": plan.get("delta_diameter"), "diameter_delta_ratio": plan.get("diameter_delta_ratio"), "target_to_height_ratio": plan.get("target_to_height_ratio"), @@ -527,8 +689,7 @@ class WindowActionMixin: "feature_type": plan.get("feature_type"), "feature_bottom_face_ids": plan.get("feature_bottom_face_ids"), "feature_opening_face_ids": plan.get("feature_opening_face_ids"), - "feature_bottom_note": plan.get("feature_bottom_note"), - "feature_guess": guess, + "feature_guess": plan.get("feature_guess"), "confidence": plan.get("confidence"), "resize_status": plan.get("status"), "resize_risk": plan.get("risk"), @@ -538,23 +699,8 @@ class WindowActionMixin: "material_vote_summary": plan.get("material_vote_summary"), "cylinder_end_type": plan.get("cylinder_end_type"), "hole_depth_estimate": plan.get("hole_depth_estimate"), - "start_end_state": plan.get("start_end_state"), - "end_end_state": plan.get("end_end_state"), - "cutter_strategy": plan.get("cutter_strategy"), - "cutter_height": plan.get("cutter_height"), - "cutter_margin": plan.get("cutter_margin"), - "cutter_start_margin": plan.get("cutter_start_margin"), - "cutter_end_margin": plan.get("cutter_end_margin"), - "cutter_bottom_protection": plan.get("cutter_bottom_protection"), - "cutter_protected_bottom_face_ids": plan.get("cutter_protected_bottom_face_ids"), - "cutter_opening_face_ids": plan.get("cutter_opening_face_ids"), - "cutter_bottom_note": plan.get("cutter_bottom_note"), - "cutter_note": plan.get("cutter_note"), - "fill_strategy": plan.get("fill_strategy"), - "fill_height": plan.get("fill_height"), - "fill_radius": plan.get("fill_radius"), - "fill_radius_overlap": plan.get("fill_radius_overlap"), - "fill_note": plan.get("fill_note"), + "quick_preflight": True, + "ui_preview": "skipped-to-avoid-ui-freeze", }, target_kind="face", target_id=face_id, @@ -580,7 +726,7 @@ class WindowActionMixin: return face_id = self.selected_face_id - info = self.model.feature_info(face_id) + info = self._selected_face_info_snapshot() if info.get("surface") != "cylinder" or info.get("slot_kind") != "partial-cylindrical-groove": QMessageBox.information(self, "不是槽/半孔候选", "当前选中Face没有被识别为槽/半孔候选。") return @@ -597,37 +743,24 @@ class WindowActionMixin: target_diameter = target_width / sin_half_span current_width = _float_or_none(info.get("slot_chord_width_estimate")) - plan = self.model.cylindrical_resize_plan(face_id, target_diameter) - if plan["status"] == "blocked": - QMessageBox.information(self, "不能调整槽/半孔宽度", str(plan["message"])) - self.statusBar().showMessage("槽/半孔宽度调整已阻止") + plan = self._quick_cylinder_resize_plan(face_id, target_diameter, info) + lines = [ + f"Face: {face_id}", + f"当前槽宽估算: {_format_value(current_width)}", + f"目标槽宽: {_format_value(target_width)}", + f"槽圆弧角度: {_format_value(angular_span)}", + f"换算目标圆柱直径: {_format_value(target_diameter)}", + "当前版本会把槽宽换算成圆柱直径后执行孔/槽重建,不是完整 CAD 槽参数编辑。", + ] + if not self._confirm_quick_edit_plan( + "槽/半孔宽度调整", + plan, + lines, + "槽/半孔宽度调整已阻止", + "已取消槽/半孔宽度调整", + ): return - - if plan["risk"] != "low": - warnings = str(plan.get("warnings", "")) - warnings_line = f"警告: {warnings}\n\n" if warnings else "" - result = QMessageBox.question( - self, - "确认调整槽/半孔宽度", - ( - f"face: {face_id}\n" - f"当前槽宽估算: {_format_value(current_width)}\n" - f"目标槽宽: {_format_value(target_width)}\n" - f"槽圆弧角度: {_format_value(angular_span)}\n" - f"换算目标圆柱直径: {_format_value(target_diameter)}\n" - f"风险: {plan['risk']}\n\n" - f"{warnings_line}" - f"{plan['message']}\n\n" - "当前版本会把槽宽换算成圆柱直径后执行孔/槽重建,不是完整 CAD 槽参数编辑。确定继续吗?" - ), - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if result != QMessageBox.StandardButton.Yes: - self.statusBar().showMessage("已取消槽/半孔宽度调整") - return - - self._show_cylinder_resize_preview(face_id, target_diameter) + self.clear_edit_preview(render=False) def action(): return self.model.resize_cylindrical_hole(face_id, target_diameter) @@ -644,7 +777,7 @@ class WindowActionMixin: "slot_width_delta": None if current_width is None else target_width - current_width, "slot_angular_span": angular_span, "derived_new_diameter": target_diameter, - "old_diameter": info.get("diameter"), + "old_diameter": plan.get("current_diameter"), "delta_diameter": plan.get("delta_diameter"), "diameter_delta_ratio": plan.get("diameter_delta_ratio"), "resize_mode": plan.get("resize_mode"), @@ -664,9 +797,8 @@ class WindowActionMixin: "hole_depth_estimate": plan.get("hole_depth_estimate"), "feature_bottom_face_ids": plan.get("feature_bottom_face_ids"), "feature_opening_face_ids": plan.get("feature_opening_face_ids"), - "cutter_strategy": plan.get("cutter_strategy"), - "cutter_height": plan.get("cutter_height"), - "fill_strategy": plan.get("fill_strategy"), + "quick_preflight": True, + "ui_preview": "skipped-to-avoid-ui-freeze", }, target_kind="face", target_id=face_id, @@ -723,7 +855,7 @@ class WindowActionMixin: f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" - "继续操作会对当前零件执行受限B-Rep布尔修改。\n\n" + "继续操作会对当前零件的局部几何执行受限B-Rep布尔修改。\n\n" "确定继续吗?" ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, @@ -877,6 +1009,111 @@ class WindowActionMixin: raise ValueError(f"底面 Face ID {bottom_face_id} 不存在。") return bottom_face_id + def _quick_cylinder_depth_plan( + self, + face_id: int, + target_depth: float, + bottom_face_id: int | None = None, + info: dict[str, object] | None = None, + ) -> dict[str, object]: + info = dict(info or self._selected_face_info_snapshot()) + warnings: list[str] = [] + blockers: list[str] = [] + risk = "low" + status = "ready" + + surface = str(info.get("surface", "")) + guess = str(info.get("feature_guess", "cylindrical face")) + confidence = str(info.get("confidence", "low")) + angular_span = _float_or_none(info.get("angular_span")) + end_type = str(info.get("cylinder_end_type", "unknown")) + current_depth = _float_or_none(info.get("hole_depth_estimate")) + if current_depth is None: + current_depth = _float_or_none(info.get("same_domain_height_estimate")) + manual_bottom = bottom_face_id is not None + + if surface != "cylinder": + blockers.append("当前选中对象不是圆柱孔/槽,不能调整孔深。") + if guess != "hole/groove candidate": + blockers.append("孔深调整当前版本只支持明确的孔/槽候选。") + if end_type != "blind" and not manual_bottom: + blockers.append("孔深调整需要识别到盲孔/盲槽底面,或手动填写底面 Face ID。") + elif end_type != "blind" and manual_bottom: + risk = self._max_quick_risk(risk, "medium") + warnings.append("端部类型不是明确 blind,当前会按手动底面 Face ID 尝试推断孔深方向。") + if current_depth is None or current_depth <= 1e-9: + blockers.append("当前圆柱面没有可靠的深度估算。") + if target_depth <= 0: + blockers.append("目标深度必须大于 0。") + + if guess == "hole/groove candidate" and confidence == "low": + risk = self._max_quick_risk(risk, "medium") + warnings.append("孔/槽判断置信度较低。") + if ( + guess == "hole/groove candidate" + and angular_span is not None + and angular_span < math.tau * 0.92 + ): + risk = self._max_quick_risk(risk, "medium") + warnings.append("这是局部圆柱面,更像槽或半孔,孔深调整会按局部槽处理。") + + delta_depth = None + delta_ratio = None + depth_mode = "unknown" + if current_depth is not None and current_depth > 1e-9: + delta_depth = target_depth - current_depth + delta_ratio = abs(delta_depth) / current_depth + depth_mode = "deepen" if delta_depth > 0 else "shallow" + if abs(delta_depth) <= max(current_depth * 1e-5, 1e-6): + blockers.append("目标深度与当前深度几乎相同,不需要修改。") + elif delta_ratio > 1.0: + risk = self._max_quick_risk(risk, "high") + warnings.append("目标深度变化超过当前深度的 100%,很可能贯穿、误切或布尔失败。") + elif delta_ratio > 0.35: + risk = self._max_quick_risk(risk, "medium") + warnings.append("目标深度变化超过当前深度的 35%。") + if target_depth < current_depth * 0.08: + risk = self._max_quick_risk(risk, "high") + warnings.append("目标深度非常浅,补料后可能生成很薄的局部面。") + + if blockers: + status = "blocked" + risk = "blocked" + elif risk in {"medium", "high"}: + status = "caution" + + message = ";".join(blockers + warnings) if blockers or warnings else "可以尝试调整盲孔/盲槽深度。" + return { + "status": status, + "risk": risk, + "message": message, + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": info.get("part_id"), + "solid_id": info.get("solid_id"), + "current_depth": current_depth, + "target_depth": target_depth, + "delta_depth": delta_depth, + "depth_delta_ratio": delta_ratio, + "depth_mode": depth_mode, + "diameter": info.get("diameter"), + "radius": info.get("radius"), + "feature_type": info.get("feature_type"), + "feature_guess": guess, + "confidence": confidence, + "angular_span": angular_span, + "cylinder_end_type": end_type, + "feature_bottom_face_ids": info.get("feature_bottom_face_ids"), + "manual_bottom_face_id": "" if bottom_face_id is None else bottom_face_id, + "manual_bottom_face_used": manual_bottom, + "feature_opening_face_ids": info.get("feature_opening_face_ids"), + "feature_bottom_confidence": info.get("feature_bottom_confidence"), + "feature_bottom_detection": info.get("feature_bottom_detection"), + "feature_bottom_note": info.get("feature_bottom_note"), + "quick_preflight": True, + } + def resize_hole_depth(self) -> None: if self.model is None: return @@ -896,62 +1133,30 @@ class WindowActionMixin: QMessageBox.critical(self, "底面 Face ID 无效", str(exc)) return - info = self.model.face_info(self.selected_face_id) - if info.get("surface") != "cylinder": - QMessageBox.information(self, "不是圆柱孔/槽", "当前选中的 Face 不是可调整孔深的圆柱孔/槽。") - return - plan = self.model.cylindrical_depth_plan( - self.selected_face_id, - target_depth, - bottom_face_id=bottom_face_id, - ) - if plan["status"] == "blocked": - QMessageBox.information(self, "不能调整孔深", str(plan["message"])) - self.statusBar().showMessage("盲孔深度调整已阻止") - return - - if plan["risk"] != "low": - warnings = str(plan.get("warnings", "")) - warnings_line = f"警告: {warnings}\n\n" if warnings else "" - result = QMessageBox.question( - self, - "确认盲孔深度调整", - ( - f"face: {plan['face_id']}\n" - f"当前深度: {_format_value(plan['current_depth'])}\n" - f"目标深度: {_format_value(plan['target_depth'])}\n" - f"深度变化量: {_format_value(plan['delta_depth'])}\n" - f"深度变化比例: {_format_percent(plan['depth_delta_ratio'])}\n" - f"调整模式: {plan['depth_mode']}\n" - f"候选判断: {plan['feature_guess']}\n" - f"置信度: {plan['confidence']}\n" - f"材料投票: {plan['material_vote_summary']}\n" - f"端部类型: {plan['cylinder_end_type']}\n" - f"疑似底面 Face: {_format_value(plan.get('feature_bottom_face_ids', ''))}\n" - f"手动底面 Face: {_format_value(plan.get('manual_bottom_face_id', ''))}\n" - f"底面识别来源: {_format_value(plan.get('feature_bottom_detection', ''))}\n" - f"当前深度来源: {_format_value(plan.get('depth_current_depth_source', ''))}\n" - f"开口方向来源: {_format_value(plan.get('depth_open_direction_source', ''))}\n" - f"开口端相邻 Face: {_format_value(plan.get('feature_opening_face_ids', ''))}\n" - f"风险: {plan['risk']}\n\n" - f"工具策略: {plan['depth_tool_strategy']}\n" - f"工具类型: {plan['depth_tool_role']}\n" - f"工具高度: {_format_value(plan['depth_tool_height'])}\n" - f"工具半径: {_format_value(plan['depth_tool_radius'])}\n\n" - f"{warnings_line}" - f"{plan['message']}\n\n" - "继续操作会对当前零件执行受限B-Rep布尔修改。\n\n" - "确定继续吗?" - ), - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if result != QMessageBox.StandardButton.Yes: - self.statusBar().showMessage("已取消盲孔深度调整") - return - face_id = self.selected_face_id - self._show_cylinder_depth_preview(face_id, target_depth, bottom_face_id=bottom_face_id) + info = self._selected_face_info_snapshot() + plan = self._quick_cylinder_depth_plan(face_id, target_depth, bottom_face_id=bottom_face_id, info=info) + lines = [ + f"Face: {face_id}", + f"当前深度: {_format_value(plan.get('current_depth'))}", + f"目标深度: {_format_value(plan.get('target_depth'))}", + f"深度变化量: {_format_value(plan.get('delta_depth'))}", + f"深度变化比例: {_format_percent(plan.get('depth_delta_ratio'))}", + f"候选判断: {plan.get('feature_guess')}", + f"置信度: {plan.get('confidence')}", + f"端部类型: {plan.get('cylinder_end_type')}", + ] + if bottom_face_id is not None: + lines.append(f"手动底面 Face: {bottom_face_id}") + if not self._confirm_quick_edit_plan( + "盲孔深度调整", + plan, + lines, + "盲孔深度调整已阻止", + "已取消盲孔深度调整", + ): + return + self.clear_edit_preview(render=False) def action(): return self.model.resize_cylindrical_depth( @@ -989,18 +1194,8 @@ class WindowActionMixin: "feature_bottom_confidence": plan.get("feature_bottom_confidence"), "feature_bottom_detection": plan.get("feature_bottom_detection"), "feature_bottom_note": plan.get("feature_bottom_note"), - "depth_tool_strategy": plan.get("depth_tool_strategy"), - "depth_tool_role": plan.get("depth_tool_role"), - "depth_tool_height": plan.get("depth_tool_height"), - "depth_tool_radius": plan.get("depth_tool_radius"), - "depth_tool_radius_overlap": plan.get("depth_tool_radius_overlap"), - "depth_current_depth": plan.get("depth_current_depth"), - "depth_current_depth_source": plan.get("depth_current_depth_source"), - "depth_bottom_parameter_source": plan.get("depth_bottom_parameter_source"), - "depth_open_direction_source": plan.get("depth_open_direction_source"), - "depth_open_point": plan.get("depth_open_point"), - "depth_current_bottom_point": plan.get("depth_current_bottom_point"), - "depth_target_bottom_point": plan.get("depth_target_bottom_point"), + "quick_preflight": True, + "ui_preview": "skipped-to-avoid-ui-freeze", }, target_kind="face", target_id=face_id, @@ -1135,7 +1330,7 @@ class WindowActionMixin: f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" - "继续操作会调用OCCT倒圆并真实修改当前零件。\n\n" + "继续操作会调用OCCT倒圆并真实修改当前零件的局部几何。\n\n" "确定继续吗?" ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, @@ -1212,7 +1407,7 @@ class WindowActionMixin: f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" - "继续操作会调用OCCT倒角并真实修改当前零件。\n\n" + "继续操作会调用OCCT倒角并真实修改当前零件的局部几何。\n\n" "确定继续吗?" ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, @@ -1546,7 +1741,7 @@ class WindowActionMixin: if self._edit_busy("请等待当前编辑完成后再平移零件。"): return if self.selected_part_id is None: - QMessageBox.information(self, "未选择零件", "请先选择一个Part,或选择属于某个Part的对象。") + QMessageBox.information(self, "未选择零件", "请先选择一个零件,或选择属于某个零件的对象。") return try: vector = self._translation_vector_from_inputs() @@ -1569,7 +1764,7 @@ class WindowActionMixin: self._run_edit_action( action, operation_name="平移零件", - target=f"Part {part_id}", + target=f"零件 {part_id}", parameters={ "part_id": part_id, "translation_vector": vector, @@ -1652,7 +1847,7 @@ class WindowActionMixin: title, ( f"对象: {plan.get('target_kind', '')}\n" - f"Part: {_format_value(plan.get('part_id', ''))}\n" + f"零件: {_format_value(plan.get('part_id', ''))}\n" f"Solid: {_format_value(plan.get('solid_id', ''))}\n" f"平移向量: {_format_value(plan['translation_vector'])}\n" f"平移距离: {_format_value(plan['translation_distance'])}\n" @@ -1674,7 +1869,7 @@ class WindowActionMixin: if self._edit_busy("请等待当前编辑完成后再旋转零件。"): return if self.selected_part_id is None: - QMessageBox.information(self, "未选择零件", "请先选择一个Part,或选择属于某个Part的对象。") + QMessageBox.information(self, "未选择零件", "请先选择一个零件,或选择属于某个零件的对象。") return try: axis, angle = self._rotation_values_from_inputs() @@ -1697,7 +1892,7 @@ class WindowActionMixin: self._run_edit_action( action, operation_name="旋转零件", - target=f"Part {part_id}", + target=f"零件 {part_id}", parameters={ "part_id": part_id, "rotation_axis": plan.get("rotation_axis"), @@ -1779,7 +1974,7 @@ class WindowActionMixin: title, ( f"对象: {plan.get('target_kind', '')}\n" - f"Part: {_format_value(plan.get('part_id', ''))}\n" + f"零件: {_format_value(plan.get('part_id', ''))}\n" f"Solid: {_format_value(plan.get('solid_id', ''))}\n" f"旋转轴: {_format_value(plan['rotation_axis'])}\n" f"旋转角度: {_format_value(plan['rotation_angle_degrees'])}\n" @@ -1923,6 +2118,10 @@ class WindowActionMixin: scan_label: str, limit: int, ) -> None: + if not hasattr(self, "editable_table"): + if show_info: + self.set_plain_info("可编辑对象面板当前未启用。") + return self.editable_table.setRowCount(len(candidates)) lines = [f"可编辑对象:显示 {len(candidates)} 个({scan_label}上限 {limit})"] for row, item in enumerate(candidates): @@ -1956,7 +2155,7 @@ class WindowActionMixin: self.editable_table.setItem(row, column, table_item) lines.append( f"{target_kind} {target_id}: {item['operation']}, " - f"Part={item['part_id']}, Solid={item['solid_id']}, " + f"零件={item['part_id']}, Solid={item['solid_id']}, " f"object={item['feature_guess']}, " f"{item['current_value_label']}={_format_value(item['current_value'])}, " f"status={item['status']}, risk={item['risk']}, " @@ -2006,6 +2205,10 @@ class WindowActionMixin: self.statusBar().showMessage("圆柱面候选扫描失败") def _filter_cached_cylinder_candidates(self, show_info: bool = False) -> None: + if not hasattr(self, "cylinder_table"): + if show_info: + self.set_plain_info("圆柱面候选面板当前未启用。") + return if not self.cylinder_candidates_loaded: self.cylinder_table.setRowCount(0) if show_info: @@ -2019,6 +2222,10 @@ class WindowActionMixin: self._populate_cylinder_table(candidates, show_info=show_info) def _populate_cylinder_table(self, candidates: list[dict[str, object]], show_info: bool = True) -> None: + if not hasattr(self, "cylinder_table"): + if show_info: + self.set_plain_info("圆柱面候选面板当前未启用。") + return self.cylinder_table.setRowCount(len(candidates)) lines = [f"圆柱面候选:显示 {len(candidates)} 个"] for row, item in enumerate(candidates): @@ -2040,7 +2247,7 @@ class WindowActionMixin: self.cylinder_table.setItem(row, 6, risk_item) self.cylinder_table.setItem(row, 7, part_item) lines.append( - f"Face {item['face_id']}: Part {item['part_id']}, " + f"Face {item['face_id']}: 零件 {item['part_id']}, " f"guess={item['feature_guess']}, " f"diameter={_format_float(float(item['diameter']))}, " f"height={_format_float(float(item['height_estimate']))}, " @@ -2053,6 +2260,8 @@ class WindowActionMixin: self.set_plain_info("\n".join(lines)) def _candidate_matches_filter(self, feature_guess: str) -> bool: + if not hasattr(self, "candidate_filter_combo"): + return True current = self.candidate_filter_combo.currentText() if current == "All": return True @@ -2243,13 +2452,15 @@ class WindowActionMixin: if "推拉" in operation_name: progress_text = f"{operation_name} 正在后台计算,半透明预览会持续渲染,3D 视图仍可旋转查看。" elif "孔径" in operation_name: - progress_text = f"{operation_name} 正在后台计算,红色预览表示切削范围,绿色预览表示补料范围。" + progress_text = f"{operation_name} 正在后台计算;为避免复杂模型卡顿,已跳过同步红/绿预览。" + elif "槽/半孔宽度" in operation_name: + progress_text = f"{operation_name} 正在后台计算;槽宽会在后台换算并重建孔/槽几何。" elif "凸台" in operation_name: progress_text = f"{operation_name} 正在后台计算,绿色预览表示扩大补料范围,红色预览表示缩小切削范围。" elif "封堵" in operation_name: progress_text = f"{operation_name} 正在后台计算,绿色预览表示封堵补料范围。" elif "孔深" in operation_name: - progress_text = f"{operation_name} 正在后台计算,红色预览表示加深切削范围,绿色预览表示变浅补料范围。" + progress_text = f"{operation_name} 正在后台计算;为避免复杂模型卡顿,已跳过同步孔深预览。" elif "圆角" in operation_name: if "已有" in operation_name: progress_text = f"{operation_name} 正在后台计算,蓝色预览表示将移除并重建的已有圆角面。" @@ -2276,6 +2487,9 @@ class WindowActionMixin: @Slot(object) def _finish_edit_action(self, result: object) -> None: + if hasattr(self, "_is_ui_thread") and not self._is_ui_thread(): + self._invoke_on_ui_thread(lambda result=result: self._finish_edit_action(result)) + return context = self.pending_edit_context if context is None: self._end_edit_task(clear_preview=True) @@ -2319,9 +2533,9 @@ class WindowActionMixin: show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False)) ) self.clear_edit_preview(render=False) - self._reset_selection() self._populate_part_tree() self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=False) + locator_note = self._locate_operation_record(record) except Exception as exc: rollback_message = self._restore_failed_edit_snapshot(result.get("snapshot") if isinstance(result, dict) else None) self._end_edit_task(clear_preview=True) @@ -2340,11 +2554,16 @@ class WindowActionMixin: if result.get("quality_warnings"): self.statusBar().showMessage("编辑完成,但有质量警告,请查看操作历史详情") else: - self.statusBar().showMessage(message) - self.set_plain_info(record.detail) + selection_note = ";已保持当前选择" if self.selected_kind is not None else "" + self.statusBar().showMessage(f"{message}{selection_note}") + if self.selected_kind is None: + self.set_plain_info(f"{record.detail}\n\n{locator_note}") @Slot(str) def _fail_edit_action(self, message: str) -> None: + if hasattr(self, "_is_ui_thread") and not self._is_ui_thread(): + self._invoke_on_ui_thread(lambda message=message: self._fail_edit_action(message)) + return self._end_edit_task(clear_preview=True) QMessageBox.critical(self, "操作失败", message) self._clear_editable_candidates() diff --git a/step_editor/window_core.py b/step_editor/window_core.py index 1bdfc86..3ddc658 100644 --- a/step_editor/window_core.py +++ b/step_editor/window_core.py @@ -21,7 +21,24 @@ from .ui_helpers import * # noqa: F403 from .workers import EditWorker, LoadWorker, ScanWorker +VIEW_BACKGROUND_COLOR = (226 / 255.0, 238 / 255.0, 247 / 255.0) + + class WindowCoreMixin: + @Slot(object) + def _run_ui_task(self, callback) -> None: + callback() + + def _is_ui_thread(self) -> bool: + return QThread.currentThread() == self.thread() + + def _invoke_on_ui_thread(self, callback) -> None: + if self._is_ui_thread(): + callback() + return + if hasattr(self, "ui_task_requested"): + self.ui_task_requested.emit(callback) + def eventFilter(self, watched, event): if watched is getattr(self, "vtk_widget", None): event_type = event.type() @@ -37,7 +54,7 @@ class WindowCoreMixin: def _build_vtk(self) -> None: self.renderer = vtk.vtkRenderer() - self.renderer.SetBackground(0.11, 0.13, 0.15) + self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR) self.render_window = self.vtk_widget.GetRenderWindow() self.render_window.AddRenderer(self.renderer) @@ -185,7 +202,10 @@ class WindowCoreMixin: self._clear_history() if hasattr(self, "measure_text"): self.clear_measurement() - self.path_label.setText(str(self.step_path)) + path_text = str(self.step_path) + self.path_label.setText(path_text) + self.path_label.setToolTip(path_text) + self.path_label.setCursorPosition(0) self._populate_part_tree() self._reset_selection() model_polydata = result.get("model_polydata") @@ -314,10 +334,13 @@ class WindowCoreMixin: self.clear_diff_preview(render=False) if hasattr(self, "history_list"): self.history_list.clear() - if hasattr(self, "export_all_button"): + if hasattr(self, "mode_combo"): self._update_action_states() def _refresh_history_list(self) -> None: + if not hasattr(self, "history_list"): + self._update_action_states() + return was_blocked = self.history_list.blockSignals(True) try: self.history_list.clear() @@ -346,6 +369,9 @@ class WindowCoreMixin: return if self._edit_busy("编辑计算中,暂时不能导出差异报告。"): return + if not hasattr(self, "history_list"): + QMessageBox.information(self, "操作历史未启用", "当前界面没有构建操作历史面板。") + return row = self.history_list.currentRow() if row < 0 or row >= len(self.operation_history): QMessageBox.information(self, "未选择历史记录", "请先选择一条操作历史。") @@ -436,6 +462,8 @@ class WindowCoreMixin: self.set_plain_info(f"已导出编辑历史:{target}\n\n记录数:{len(records)}") def _populate_part_tree(self) -> None: + if not hasattr(self, "part_tree"): + return was_blocked = self.part_tree.blockSignals(True) try: self.part_tree.clear() @@ -501,11 +529,11 @@ class WindowCoreMixin: def _part_tree_part_tooltip(self, part_id: int) -> str: if self.model is None: - return f"Part ID: {part_id}" + return f"零件 ID: {part_id}" try: info = self.model.part_info(part_id) except Exception: - return f"Part ID: {part_id}" + return f"零件 ID: {part_id}" lines = [ f"ID: {part_id}", f"类型: {_part_tree_kind_label(str(info.get('kind', '')))}", @@ -560,7 +588,7 @@ class WindowCoreMixin: if self.selected_kind == "part" and self.selected_part_id is not None: part_ids = [self.selected_part_id] - label = f"Part {self.selected_part_id}" + label = f"零件 {self.selected_part_id}" elif self.selected_kind == "solid" and self.selected_solid_id is not None: face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == self.selected_solid_id] edge_ids = self.model.edge_ids_for_solid(self.selected_solid_id) @@ -569,7 +597,7 @@ class WindowCoreMixin: info = self.model.feature_info(self.selected_face_id) face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id] edge_ids = _int_values(info.get("feature_boundary_edge_ids")) - label = f"Feature Face {self.selected_face_id}" + label = f"特征 Face {self.selected_face_id}" elif self.selected_kind == "face" and self.selected_face_id is not None: face_ids = self.model.connected_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id] edge_ids = self.model.face_region_boundary_edge_ids(self.selected_face_id) @@ -580,7 +608,7 @@ class WindowCoreMixin: edge_ids = [self.selected_edge_id] label = f"Edge {self.selected_edge_id}" else: - QMessageBox.information(self, "未选择对象", "请先选择 Part、Solid、Face、Edge 或 Feature。") + QMessageBox.information(self, "未选择对象", "请先选择零件、Solid、Face、Edge 或特征。") return model_polydata = self.model.build_face_polydata(face_ids=face_ids, part_ids=part_ids) @@ -610,7 +638,7 @@ class WindowCoreMixin: return bounds = self._selected_focus_bounds() if bounds is None: - QMessageBox.information(self, "未选择对象", "请先选择 Part、Solid、Face、Edge 或 Feature。") + QMessageBox.information(self, "未选择对象", "请先选择零件、Solid、Face、Edge 或特征。") return self._fit_camera_to_bounds(bounds) self.render_window.Render() @@ -758,7 +786,7 @@ class WindowCoreMixin: def _rebuild_scene_from_polydata(self, model_polydata, edge_polydata, reset_camera: bool = False) -> None: self._clear_overlay_polydata_cache() self.renderer.RemoveAllViewProps() - self.renderer.SetBackground(0.11, 0.13, 0.15) + self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR) self.scene_isolated = False self.model_polydata = _smooth_surface_polydata(model_polydata) @@ -808,10 +836,20 @@ class WindowCoreMixin: def _on_mode_changed(self, mode: str) -> None: self._clear_hover(render=True) - if hasattr(self, "id_mode_display"): - self.id_mode_display.setText(mode) + if hasattr(self, "_update_id_select_title"): + self._update_id_select_title(mode) def on_left_click(self, _obj, _event) -> None: + try: + x, y = self.interactor.GetEventPosition() + except Exception: + x, y = 0, 0 + if not self._is_ui_thread(): + self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._handle_left_click(x, y)) + return + self._handle_left_click(int(x), int(y)) + + def _handle_left_click(self, x: int, y: int) -> None: self.pointer_button_down = True self.pending_hover_position = None self.last_hover_pick_position = None @@ -827,8 +865,7 @@ class WindowCoreMixin: return if self.model is None: return - mode = self.mode_combo.currentText() - x, y = self.interactor.GetEventPosition() + mode = self._current_selection_mode() target = self._pick_selection_target(mode, x, y) if target is None: self.statusBar().showMessage("未选中对象") @@ -838,6 +875,12 @@ class WindowCoreMixin: self._select_pick_target(target) def on_pointer_button_press(self, _obj, _event) -> None: + if not self._is_ui_thread(): + self._invoke_on_ui_thread(self._handle_pointer_button_press) + return + self._handle_pointer_button_press() + + def _handle_pointer_button_press(self) -> None: self.pointer_button_down = True self.pending_hover_position = None self.last_hover_pick_position = None @@ -845,11 +888,27 @@ class WindowCoreMixin: self._clear_hover(render=True) def on_pointer_button_release(self, _obj, _event) -> None: + if not self._is_ui_thread(): + self._invoke_on_ui_thread(self._handle_pointer_button_release) + return + self._handle_pointer_button_release() + + def _handle_pointer_button_release(self) -> None: self.pointer_button_down = False self.pending_hover_position = None self.last_hover_pick_position = None def on_mouse_move(self, _obj, _event) -> None: + try: + x, y = self.interactor.GetEventPosition() + except Exception: + x, y = 0, 0 + if not self._is_ui_thread(): + self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._handle_mouse_move(x, y)) + return + self._handle_mouse_move(int(x), int(y)) + + def _handle_mouse_move(self, x: int, y: int) -> None: if QApplication.mouseButtons() != Qt.MouseButton.NoButton: self.pointer_button_down = True return @@ -863,8 +922,7 @@ class WindowCoreMixin: ): self._clear_hover(render=True) return - x, y = self.interactor.GetEventPosition() - self._queue_hover_position(int(x), int(y)) + self._queue_hover_position(x, y) def _queue_hover_from_qt_event(self, event) -> None: if ( @@ -883,6 +941,9 @@ class WindowCoreMixin: self._queue_hover_position(x, y) def _queue_hover_position(self, x: int, y: int) -> None: + if not self._is_ui_thread(): + self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._queue_hover_position(x, y)) + return position = (int(x), int(y)) threshold = int(getattr(self, "hover_move_threshold_px", 0) or 0) if threshold > 0: @@ -909,7 +970,7 @@ class WindowCoreMixin: return x, y = self.pending_hover_position self.last_hover_pick_position = (x, y) - target = self._pick_selection_target(self.mode_combo.currentText(), x, y) + target = self._pick_selection_target(self._current_selection_mode(), x, y) self._show_hover_target(target) def _pick_selection_target(self, mode: str, x: int, y: int) -> dict[str, object] | None: @@ -1101,7 +1162,7 @@ class WindowCoreMixin: self.select_edge(edge_id, pick_position=pick_position) def on_part_tree_select(self, current: QTreeWidgetItem | None, _previous: QTreeWidgetItem | None) -> None: - if self._edit_busy("编辑计算中,暂时不能切换零件树选择。"): + if self._edit_busy("编辑计算中,暂时不能切换结构树选择。"): return if current is None: return @@ -1110,14 +1171,16 @@ class WindowCoreMixin: if node_kind == "solid" and target_id is not None: part_id = current.data(0, PART_TREE_PART_ID_ROLE) if part_id is not None: - self.mode_combo.setCurrentText("Solid") + self._set_selection_mode("Solid") self.select_solid(int(target_id), int(part_id)) return if node_kind in {"part", "assembly"} and target_id is not None: - self.mode_combo.setCurrentText("Part") + self._set_selection_mode("Part") self.select_part(int(target_id)) def on_cylinder_row_clicked(self, row: int, _column: int) -> None: + if not hasattr(self, "cylinder_table"): + return if self._edit_busy("编辑计算中,暂时不能切换圆柱面候选。"): return item = self.cylinder_table.item(row, 0) @@ -1126,10 +1189,12 @@ class WindowCoreMixin: face_id = item.data(Qt.UserRole) if face_id is None: return - self.mode_combo.setCurrentText("Feature") + self._set_selection_mode("Feature") self.select_feature(int(face_id)) def on_editable_row_clicked(self, row: int, _column: int) -> None: + if not hasattr(self, "editable_table"): + return if self._edit_busy("编辑计算中,暂时不能切换可编辑对象。"): return item = self.editable_table.item(row, 0) @@ -1141,51 +1206,51 @@ class WindowCoreMixin: if target_id is None: return if action == "resize_cylinder": - self.mode_combo.setCurrentText("Feature") + self._set_selection_mode("Feature") self.select_feature(int(target_id)) self.statusBar().showMessage(f"已选择可调整孔径候选Face {target_id}") elif action == "resize_boss": - self.mode_combo.setCurrentText("Feature") + self._set_selection_mode("Feature") self.select_feature(int(target_id)) self.statusBar().showMessage(f"已选择可调整凸台直径候选Face {target_id}") elif action == "resize_slot_width": - self.mode_combo.setCurrentText("Feature") + self._set_selection_mode("Feature") self.select_feature(int(target_id)) self.statusBar().showMessage(f"已选择可调整槽/半孔宽度候选Face {target_id}") elif action == "resize_shell_thickness": - self.mode_combo.setCurrentText("Feature") + self._set_selection_mode("Feature") self.select_feature(int(target_id)) self.statusBar().showMessage(f"已选择可调整薄壁厚度候选Face {target_id}") elif action == "suppress_cylinder": - self.mode_combo.setCurrentText("Feature") + self._set_selection_mode("Feature") self.select_feature(int(target_id)) self.statusBar().showMessage(f"已选择可封堵圆柱孔Face {target_id}") elif action == "resize_depth": - self.mode_combo.setCurrentText("Feature") + self._set_selection_mode("Feature") self.select_feature(int(target_id)) self.statusBar().showMessage(f"已选择可调整盲孔深度候选Face {target_id}") elif action == "inspect_existing_fillet": - self.mode_combo.setCurrentText("Feature") + self._set_selection_mode("Feature") self.select_feature(int(target_id)) self.statusBar().showMessage(f"已选择已有圆角/倒圆候选Face {target_id}") elif action == "fillet_edge": - self.mode_combo.setCurrentText("Edge") + self._set_selection_mode("Edge") self.select_edge(int(target_id)) self.statusBar().showMessage(f"已选择可添加圆角Edge {target_id}") elif action == "chamfer_edge": - self.mode_combo.setCurrentText("Edge") + self._set_selection_mode("Edge") self.select_edge(int(target_id)) self.statusBar().showMessage(f"已选择可添加倒角Edge {target_id}") elif action == "resize_edge_length": - self.mode_combo.setCurrentText("Edge") + self._set_selection_mode("Edge") self.select_edge(int(target_id)) self.statusBar().showMessage(f"已选择可尝试调整长度的Edge {target_id}") elif target_kind == "edge": - self.mode_combo.setCurrentText("Edge") + self._set_selection_mode("Edge") self.select_edge(int(target_id)) self.statusBar().showMessage(f"已选择Edge {target_id}") else: - self.mode_combo.setCurrentText("Face") + self._set_selection_mode("Face") self.select_face(int(target_id)) self.statusBar().showMessage(f"已选择可推拉平面Face {target_id}") @@ -1197,38 +1262,38 @@ class WindowCoreMixin: try: target_id = int(self.id_input.text()) except ValueError: - QMessageBox.information(self, "ID 无效", "请输入整数 ID。Part ID 从 1 开始,Solid/Face/Edge ID 从 0 开始。") + QMessageBox.information(self, "ID 无效", "请输入整数 ID。零件 ID 从 1 开始,Solid/Face/Edge ID 从 0 开始。") return - kind = kind or self.mode_combo.currentText() + kind = _selection_mode_value(kind or self._current_selection_mode()) try: if kind == "Part": if self.model.part_by_id(target_id) is None: - raise ValueError(f"不存在 Part ID {target_id}") - self.mode_combo.setCurrentText("Part") + raise ValueError(f"不存在零件 ID {target_id}") + self._set_selection_mode("Part") self.select_part(target_id) elif kind == "Solid": if target_id < 0 or target_id >= len(self.model.solids): raise ValueError(f"不存在 Solid ID {target_id}") part_id = self.model.solids[target_id][0] - self.mode_combo.setCurrentText("Solid") + self._set_selection_mode("Solid") self.select_solid(target_id, part_id) elif kind == "Face": resolved_face_id = self.model.resolve_face_selection_id(target_id) if resolved_face_id is None: raise ValueError(f"不存在 Face/逻辑 Face ID {target_id}") - self.mode_combo.setCurrentText("Face") + self._set_selection_mode("Face") self.select_face(resolved_face_id) elif kind == "Feature": resolved_face_id = self.model.resolve_face_selection_id(target_id) if resolved_face_id is None: - raise ValueError(f"不存在 Feature 来源 Face/逻辑 Face ID {target_id}") - self.mode_combo.setCurrentText("Feature") + raise ValueError(f"不存在特征来源 Face/逻辑 Face ID {target_id}") + self._set_selection_mode("Feature") self.select_feature(resolved_face_id) elif kind == "Edge": if target_id < 0 or target_id >= len(self.model.edges): raise ValueError(f"不存在 Edge ID {target_id}") - self.mode_combo.setCurrentText("Edge") + self._set_selection_mode("Edge") self.select_edge(target_id) self.last_id_kind = kind except ValueError as exc: @@ -1333,7 +1398,7 @@ class WindowCoreMixin: self._show_pick_marker(pick_position) self._sync_id_picker("Feature", face_id) self.set_info(self._with_pick_info(info, pick_position)) - feature_type = str(info.get("feature_type", "局部特征候选")) + feature_type = str(info.get("feature_type", "特征候选")) self._update_action_states() self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源Face {face_id}", pick_position)) @@ -1859,7 +1924,7 @@ class WindowCoreMixin: if render and hasattr(self, "render_window"): self.render_window.Render() self.statusBar().showMessage("已清除差异预览") - if hasattr(self, "export_all_button"): + if hasattr(self, "mode_combo"): self._update_action_states() def _show_operation_diff(self, record: OperationRecord) -> str: diff --git a/step_editor/window_state.py b/step_editor/window_state.py index 6f3e488..300ba10 100644 --- a/step_editor/window_state.py +++ b/step_editor/window_state.py @@ -10,9 +10,11 @@ from PySide6.QtWidgets import ( QApplication, QFileDialog, QMessageBox, + QPushButton, QTableWidgetItem, QTreeWidgetItem, ) +from PySide6.QtGui import QColor from .model import StepModel from .records import OperationRecord @@ -20,6 +22,9 @@ from .ui_helpers import * # noqa: F403 from .workers import EditWorker, LoadWorker, ScanWorker +PROPERTY_VALUE_TOLERANCE = 1e-9 + + class WindowStateMixin: def _reset_selection(self, clear_highlight: bool = True) -> None: self.selected_kind = None @@ -30,6 +35,7 @@ class WindowStateMixin: self.selected_pick_position = None if clear_highlight: self._clear_highlight() + self._update_selected_object_title() self._update_action_states() def _show_pick_marker(self, pick_position: tuple[float, float, float] | None) -> None: @@ -94,9 +100,12 @@ class WindowStateMixin: def _sync_id_picker(self, kind: str, target_id: int) -> None: self.last_id_kind = kind self.id_input.setText(str(target_id)) + self._update_id_select_title(kind) def _update_action_states(self) -> None: - if not hasattr(self, "export_all_button"): + self._update_selected_object_title() + self._update_id_select_title() + if not hasattr(self, "mode_combo"): return has_loaded_model = self.model is not None busy = self.operation_in_progress or self.scan_in_progress or self.load_in_progress @@ -134,35 +143,35 @@ class WindowStateMixin: self._set_control_state( self.open_button, not busy, - "选择并打开一个 .step 或 .stp 文件。", - "请等待当前后台任务完成后再打开其他 STEP 文件。", + "导入一个 .step 或 .stp 几何模型。", + "请等待当前后台任务完成后再导入其他几何模型。", ) self._set_control_state( self.reload_button, has_loaded_model and not busy, - "从磁盘重新读取当前 STEP 文件。", + "按当前显示路径读取 STEP 模型。", wait_or_load_tip, ) if hasattr(self, "part_tree"): self._set_control_state( self.part_tree, has_model, - "从结构树中选择 Part、Solid 或装配节点。", + "从结构树中选择零件、Solid 或装配节点。", wait_or_load_tip, ) if hasattr(self, "mode_combo"): self._set_control_state( self.mode_combo, not busy, - "选择鼠标点击模型时要选什么对象。", - "请等待当前后台任务完成后再切换鼠标选择模式。", + "选择模式决定鼠标点击模型时按什么对象类型选择。", + "请等待当前后台任务完成后再切换选择模式。", ) if hasattr(self, "id_input"): self._set_control_state( self.id_input, - has_model, - "输入要选中的对象 ID。", - wait_or_load_tip, + True, + "输入要选中的对象 ID;加载模型后可点击选择按钮跳转。", + "输入要选中的对象 ID;加载模型后可点击选择按钮跳转。", ) self._set_control_state( self.select_id_button, @@ -171,18 +180,49 @@ class WindowStateMixin: "请先加载模型并输入整数 ID。", ) - self._set_control_state( - self.export_all_button, - has_model, - "导出当前完整 STEP 模型。", - wait_or_load_tip, - ) - self._set_control_state( - self.export_check_button, - has_model, - "检查当前导出对象的 B-Rep、数量、体积和包围盒等质量信息。", - wait_or_load_tip, - ) + if hasattr(self, "export_all_button"): + self._set_control_state( + self.export_all_button, + has_model, + "导出当前完整 STEP 模型。", + wait_or_load_tip, + ) + self._set_control_state( + self.export_check_button, + has_model, + "检查当前导出对象的 B-Rep、数量、体积和包围盒等质量信息。", + wait_or_load_tip, + ) + self._set_control_state( + self.export_part_button, + has_model and has_part, + "导出当前选中对象所属零件。", + "请先选择零件,或选择属于某个零件的 Solid、Face 或 Edge。", + ) + self._set_control_state( + self.export_solid_button, + has_model and has_solid, + "导出当前选中对象所属 Solid。", + "请先选择 Solid,或选择属于某个 Solid 的 Face 或 Edge。", + ) + self._set_control_state( + self.export_face_button, + has_model and has_face, + "导出当前选中的 Face 或同域面区域。", + "请先在 Face 或特征模式下选择一个 Face。", + ) + self._set_control_state( + self.export_feature_button, + has_model and selected_kind == "feature" and self.selected_face_id is not None, + "导出特征模式识别到的区域。", + "请先切换到特征模式并选择孔、槽、圆角或凸台候选。", + ) + self._set_control_state( + self.export_edge_button, + has_model and has_edge, + "导出当前选中的 Edge。", + "请先切换到 Edge 模式并选择一条 Edge。", + ) if hasattr(self, "repair_model_button"): self._set_control_state( self.repair_model_button, @@ -194,44 +234,14 @@ class WindowStateMixin: self.repair_selected_button, has_model and (has_solid or has_part), "优先修复当前选中对象所属Solid;没有Solid时修复所属零件,并写入撤销历史。", - "请先选择 Part、Solid、Face 或 Edge,或等待当前后台任务完成。", + "请先选择零件、Solid、Face 或 Edge,或等待当前后台任务完成。", ) - self._set_control_state( - self.export_part_button, - has_model and has_part, - "导出当前选中对象所属零件。", - "请先选择 Part,或选择属于某个 Part 的 Solid、Face 或 Edge。", - ) - self._set_control_state( - self.export_solid_button, - has_model and has_solid, - "导出当前选中对象所属 Solid。", - "请先选择 Solid,或选择属于某个 Solid 的 Face 或 Edge。", - ) - self._set_control_state( - self.export_face_button, - has_model and has_face, - "导出当前选中的 Face 或同域面区域。", - "请先在 Face 或 Feature 模式下选择一个 Face。", - ) - self._set_control_state( - self.export_feature_button, - has_model and selected_kind == "feature" and self.selected_face_id is not None, - "导出 Feature 模式识别到的局部特征区域。", - "请先切换到 Feature 模式并选择孔、槽、圆角或凸台候选。", - ) - self._set_control_state( - self.export_edge_button, - has_model and has_edge, - "导出当前选中的 Edge。", - "请先切换到 Edge 模式并选择一条 Edge。", - ) if hasattr(self, "isolate_button"): self._set_control_state( self.isolate_button, has_model and has_selection, "只显示当前选中的对象或区域。", - "请先加载模型并选择 Part、Solid、Face、Edge 或 Feature。", + "请先加载模型并选择零件、Solid、Face、Edge 或特征。", ) self._set_control_state( self.fit_button, @@ -321,6 +331,13 @@ class WindowStateMixin: "复制当前属性区里的完整文本。", "当前属性区还没有可复制的信息。", ) + if hasattr(self, "property_table"): + self._set_control_state( + self.property_table, + has_current_info, + "查看当前选中对象的属性;可修改的行可以输入目标值。", + "请先选择一个对象。", + ) self._update_edit_action_states(has_model) def _update_edit_action_states(self, has_model: bool) -> None: @@ -380,7 +397,7 @@ class WindowStateMixin: self.push_button, can_push, "对当前平面 Face 执行推拉。", - "请先选择一个平面 Face,或在 Feature 模式下选择可推拉平面候选。", + "请先选择一个平面 Face,或在特征模式下选择可推拉平面候选。", ) if hasattr(self, "resize_shell_thickness_button"): self._set_control_state( @@ -547,12 +564,13 @@ class WindowStateMixin: "旋转当前选中对象所属 Solid。", "请先选择一个 Solid,或选择属于某个 Solid 的 Face/Edge。", ) - self._set_control_state( - self.cylinders_button, - has_model, - "扫描当前模型中的圆柱面候选。", - "请先加载 STEP 文件。", - ) + if hasattr(self, "cylinders_button"): + self._set_control_state( + self.cylinders_button, + has_model, + "扫描当前模型中的圆柱面候选。", + "请先加载 STEP 文件。", + ) if hasattr(self, "editable_refresh_button"): self._set_control_state( self.editable_refresh_button, @@ -586,18 +604,21 @@ class WindowStateMixin: "点击一行可选中对应圆柱 Face。", "当前没有圆柱面候选列表;请先点击扫描圆柱面。", ) - self._set_control_state( - self.undo_button, - has_model and bool(self.undo_stack), - "撤销上一步编辑。", - "当前没有可撤销的编辑。", - ) - self._set_control_state( - self.redo_button, - has_model and bool(self.redo_stack), - "重做刚撤销的编辑。", - "当前没有可重做的编辑。", - ) + if hasattr(self, "undo_button"): + self._set_control_state( + self.undo_button, + has_model and bool(self.undo_stack), + "撤销上一步编辑。", + "当前没有可撤销的编辑。", + ) + if hasattr(self, "redo_button"): + self._set_control_state( + self.redo_button, + has_model and bool(self.redo_stack), + "重做刚撤销的编辑。", + "当前没有可重做的编辑。", + ) + self._update_property_apply_state(has_model) def _set_control_state(self, widget, enabled: bool, enabled_tip: str, disabled_tip: str) -> None: widget.setEnabled(enabled) @@ -607,6 +628,754 @@ class WindowStateMixin: else: widget.setToolTip(tip) + def _current_selection_mode(self) -> str: + if not hasattr(self, "mode_combo"): + return getattr(self, "last_id_kind", "Face") + data = self.mode_combo.currentData() + if data is not None: + return _selection_mode_value(data) + return _selection_mode_value(self.mode_combo.currentText()) + + def _set_selection_mode(self, mode: str) -> None: + if not hasattr(self, "mode_combo"): + self.last_id_kind = _selection_mode_value(mode) + return + value = _selection_mode_value(mode) + index = self.mode_combo.findData(value) + if index >= 0: + self.mode_combo.setCurrentIndex(index) + else: + self.mode_combo.setCurrentText(_selection_mode_label(value)) + self.last_id_kind = value + self._update_id_select_title(value) + + def _update_id_select_title(self, kind: str | None = None) -> None: + current_kind = kind + if current_kind is None and hasattr(self, "mode_combo"): + current_kind = self._current_selection_mode() + current_kind = current_kind or getattr(self, "last_id_kind", "Face") + if hasattr(self, "id_select_label"): + self.id_select_label.setText(f"按ID:{_selection_mode_label(current_kind)}") + + def _update_selected_object_title(self) -> None: + if not hasattr(self, "object_edit_box"): + return + self.object_edit_box.setTitle(f"当前选中对象:{self._selected_object_title_suffix()}") + + def _selected_object_title_suffix(self) -> str: + if self.selected_kind == "part" and self.selected_part_id is not None: + return f"零件 {self.selected_part_id}" + if self.selected_kind == "solid" and self.selected_solid_id is not None: + return f"Solid {self.selected_solid_id}" + if self.selected_kind == "feature" and self.selected_face_id is not None: + return f"特征 Face {self.selected_face_id}" + if self.selected_kind == "face" and self.selected_face_id is not None: + logical_id = self.current_info_values.get("face_region_logical_id") + if logical_id is None: + logical_id = self.current_info_values.get("logical_face_id") + if logical_id not in {None, "", self.selected_face_id}: + return f"Face {logical_id}(拓扑 {self.selected_face_id})" + return f"Face {self.selected_face_id}" + if self.selected_kind == "edge" and self.selected_edge_id is not None: + return f"Edge {self.selected_edge_id}" + return "未选择" + + def _clear_property_editor(self) -> None: + if not hasattr(self, "property_table"): + return + self.property_editor_specs = [] + self.property_table_expanded = False + was_blocked = self.property_table.blockSignals(True) + try: + self.property_table.setRowCount(0) + finally: + self.property_table.blockSignals(was_blocked) + self._resize_property_table_height() + self._update_property_apply_state(False) + + def _refresh_property_editor(self) -> None: + if not hasattr(self, "property_table"): + return + info = dict(getattr(self, "current_info_values", {}) or {}) + action_info = self._selected_action_info() + for key, value in action_info.items(): + info.setdefault(key, value) + + specs = self._property_editor_specs(info, action_info) + self.property_editor_specs = specs + self.property_table_expanded = False + self.property_editor_updating = True + was_blocked = self.property_table.blockSignals(True) + try: + self.property_table.setRowCount(len(specs)) + for row, spec in enumerate(specs): + editable = bool(spec.get("editable") and spec.get("enabled")) + label_item = self._property_table_item(str(spec.get("label", "")), editable=False) + current_item = self._property_table_item(str(spec.get("current_text", "")), editable=False) + target_item = self._property_table_item(str(spec.get("target_text", "")), editable=editable) + status_text = "" if editable else str(spec.get("status_text", "")) + status_item = self._property_table_item(status_text, editable=False) + target_item.setToolTip(self._property_target_tooltip(spec, editable=editable)) + if editable: + target_item.setBackground(QColor("#fff3d9")) + status_item.setForeground(QColor("#166534")) + else: + target_item.setBackground(QColor("#eef2f6")) + target_item.setForeground(QColor("#8f99a8")) + status_item.setForeground(QColor("#64748b")) + for column, item in enumerate((label_item, current_item, target_item, status_item)): + item.setToolTip(item.toolTip() or item.text()) + self.property_table.setItem(row, column, item) + if editable: + self._set_property_row_button(row, spec) + else: + self.property_table.removeCellWidget(row, 3) + self._resize_property_table_height() + finally: + self.property_table.blockSignals(was_blocked) + self.property_editor_updating = False + self._update_property_apply_state() + + def _set_property_row_button(self, row: int, spec: dict[str, object]) -> None: + button = QPushButton("可修改") + button.setObjectName("propertyRowEditButton") + tooltip = f"只应用“{spec.get('label', '当前属性')}”这一行的目标值。" + range_hint = self._property_range_hint(spec) + if range_hint: + tooltip = f"{tooltip}\n\n{range_hint}" + button.setToolTip(tooltip) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.clicked.connect(lambda _checked=False, target_row=row: self.apply_property_row_edit(target_row)) + self.property_table.setCellWidget(row, 3, button) + + def _property_target_tooltip(self, spec: dict[str, object], *, editable: bool) -> str: + tip_key = "enabled_tip" if editable else "disabled_tip" + parts = [str(spec.get(tip_key, "")).strip()] + if editable: + parts.append(self._property_range_hint(spec)) + return "\n\n".join(part for part in parts if part) + + def _property_range_hint(self, spec: dict[str, object]) -> str: + parts: list[str] = [] + hard_range = self._property_hard_range_text(spec) + if hard_range: + parts.append(f"有效范围:{hard_range}") + range_hint = str(spec.get("range_hint", "")).strip() + if range_hint: + parts.append(f"建议范围:{range_hint}") + return "\n".join(parts) + + def _property_hard_range_text(self, spec: dict[str, object]) -> str: + chunks: list[str] = [] + min_value = _float_or_none(spec.get("min_value")) + max_value = _float_or_none(spec.get("max_value")) + if min_value is not None: + operator = ">" if spec.get("min_exclusive") else ">=" + chunks.append(f"{operator} {_format_float(min_value)}") + if max_value is not None: + operator = "<" if spec.get("max_exclusive") else "<=" + chunks.append(f"{operator} {_format_float(max_value)}") + return " 且 ".join(chunks) + + def _resize_property_table_height(self) -> None: + if not hasattr(self, "property_table"): + return + row_count = self.property_table.rowCount() + collapsed_rows = int(getattr(self, "property_table_collapsed_rows", 6) or 6) + expanded = bool(getattr(self, "property_table_expanded", False)) + visible_rows = row_count if expanded else min(row_count, collapsed_rows) + visible_rows = max(1, visible_rows) + row_height = max(int(self.property_table.verticalHeader().defaultSectionSize()), 22) + header_height = int(self.property_table.horizontalHeader().height()) + frame = int(self.property_table.frameWidth()) * 2 + height = header_height + frame + visible_rows * row_height + 8 + self.property_table.setMinimumHeight(height) + self.property_table.setMaximumHeight(height) + has_hidden_rows = row_count > collapsed_rows + self.property_table.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff + if expanded or not has_hidden_rows + else Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + if hasattr(self, "property_expand_button"): + self.property_expand_button.setVisible(has_hidden_rows) + if expanded: + self.property_expand_button.setText(f"收起,只显示前 {collapsed_rows} 项") + self.property_expand_button.setToolTip("收起当前选中对象属性表,让面板只保留最常用的前几行。") + else: + self.property_expand_button.setText(f"展开全部属性({row_count} 项)") + self.property_expand_button.setToolTip("展开当前选中对象的完整属性表;参数化建模按钮会继续留在下方。") + + def toggle_property_table_expanded(self) -> None: + if not hasattr(self, "property_table"): + return + self.property_table_expanded = not bool(getattr(self, "property_table_expanded", False)) + self._resize_property_table_height() + + def _property_table_item(self, text: str, *, editable: bool) -> QTableWidgetItem: + item = QTableWidgetItem(text) + flags = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled + if editable: + flags |= Qt.ItemFlag.ItemIsEditable + item.setFlags(flags) + return item + + def _property_editor_specs( + self, + info: dict[str, object], + action_info: dict[str, object], + ) -> list[dict[str, object]]: + editable_specs, used_keys = self._editable_property_specs(action_info) + specs = list(editable_specs) + for key, value in self._ordered_info_items(info): + if key in used_keys: + continue + specs.append( + { + "key": key, + "label": INFO_LABELS.get(key, key), + "current_text": _format_value(value), + "current_raw": value, + "target_text": "", + "editable": False, + "enabled": False, + "status_text": "只读", + "disabled_tip": "这个属性来自当前 STEP/B-Rep 几何或识别结果,当前版本只能查看,不能直接修改。", + } + ) + if not specs: + specs.append( + { + "key": "empty", + "label": "当前对象", + "current_text": "尚未选择", + "target_text": "", + "editable": False, + "enabled": False, + "status_text": "只读", + "disabled_tip": "请先在模型中选择零件、Solid、Face、Edge 或特征。", + } + ) + return specs + + def _ordered_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]: + ordered: list[tuple[str, object]] = [] + emitted: set[str] = set() + for _group_name, keys in INFO_GROUPS: + for key in keys: + if key in info and key not in emitted: + ordered.append((key, info[key])) + emitted.add(key) + for key in sorted(key for key in info if key not in emitted): + ordered.append((key, info[key])) + return ordered + + def _editable_property_specs(self, action_info: dict[str, object]) -> tuple[list[dict[str, object]], set[str]]: + has_model = self.model is not None and not (self.operation_in_progress or self.scan_in_progress or self.load_in_progress) + surface = str(action_info.get("surface", "")) + curve = str(action_info.get("curve", "")) + feature_guess = str(action_info.get("feature_guess", "")) + angular_span = _float_or_none(action_info.get("angular_span")) + has_face = self.selected_face_id is not None and self.selected_kind in {"face", "feature"} + has_edge = self.selected_edge_id is not None and self.selected_kind == "edge" + is_plane = has_face and surface == "plane" + is_shell_candidate = is_plane and action_info.get("shell_region_status") == "candidate" + is_cylinder = has_face and surface == "cylinder" and "diameter" in action_info + is_hole_or_groove = is_cylinder and feature_guess == "hole/groove candidate" + is_boss = is_cylinder and feature_guess == "boss/outer-round candidate" + is_existing_fillet = is_cylinder and feature_guess == "round/fillet candidate" + is_full_cylinder = angular_span is not None and angular_span >= math.tau * 0.92 + is_slot_or_half_hole = ( + is_hole_or_groove + and angular_span is not None + and angular_span < math.tau * 0.92 + and _float_or_none(action_info.get("slot_chord_width_estimate")) is not None + ) + is_blind = action_info.get("cylinder_end_type") == "blind" + has_bottom = bool(_int_values(action_info.get("feature_bottom_face_ids"))) + has_fillet_support = len(_int_values(action_info.get("feature_existing_fillet_support_face_ids"))) >= 2 + is_line_edge = has_edge and curve == "line" + specs: list[dict[str, object]] = [] + used_keys: set[str] = set() + + def numeric_text(value: object, fallback: str = "") -> str: + number = _float_or_none(value) + return _format_float(number) if number is not None else fallback + + def relative_range_hint(current: object, caution_ratio: float, high_ratio: float) -> str: + number = _float_or_none(current) + if number is None or number <= 0: + return "建议先小幅试改并查看预览,过大变化可能导致布尔失败或周边变形。" + lower = max(number * (1.0 - caution_ratio), 0.0) + upper = number * (1.0 + caution_ratio) + return ( + f"建议先在 {_format_float(lower)} - {_format_float(upper)} 内试改" + f"(相对当前值约 +/-{caution_ratio * 100.0:.0f}%);" + f"变化超过 {high_ratio * 100.0:.0f}% 时风险较高。" + ) + + def positive_minimum() -> dict[str, object]: + return {"min_value": 0.0, "min_exclusive": True} + + def push_pull_hint() -> str: + diagonal = _float_or_none(action_info.get("bbox_diagonal")) + if diagonal is not None and diagonal > 0: + return ( + "可输入正数或负数,单位同模型;" + f"建议单次绝对值不超过 {_format_float(diagonal * 0.08)}," + f"超过 {_format_float(diagonal * 0.2)} 时风险较高。" + ) + return "可输入正数或负数,单位同模型;建议先小幅试改并查看预览。" + + def translation_hint() -> str: + diagonal = _float_or_none(action_info.get("bbox_diagonal")) + if diagonal is not None and diagonal > 0: + return ( + "格式为 X, Y, Z;" + f"建议单次平移距离不超过 {_format_float(diagonal * 0.5)}," + f"超过 {_format_float(diagonal * 2.0)} 时请重点确认单位和方向。" + ) + return "格式为 X, Y, Z;建议先小幅试改并查看预览。" + + def hole_diameter_upper_limit(current: object) -> float | None: + current_number = _float_or_none(current) + height = _float_or_none(action_info.get("height_estimate")) + limits: list[float] = [] + if current_number is not None and current_number > 0: + limits.append(current_number * 2.0) + if height is not None and height > 0: + limits.append(height * 2.0) + return min(limits) if limits else None + + def hole_diameter_hint(current: object) -> str: + hint = relative_range_hint(current, 0.35, 1.0) + upper = hole_diameter_upper_limit(current) + if upper is not None: + hint = ( + f"{hint} 当前版本会阻止超过 {_format_float(upper)} 的目标直径," + "避免布尔返回成功但孔壁没有真正变成目标直径。" + ) + return hint + + def add_spec( + *, + key: str, + label: str, + current_raw: object, + target_text: str, + action: str, + target_attr: str | None = None, + target_attrs: tuple[str, ...] | None = None, + enabled: bool, + enabled_tip: str, + disabled_tip: str, + value_type: str = "number", + used: tuple[str, ...] = (), + range_hint: str = "", + min_value: float | None = None, + max_value: float | None = None, + min_exclusive: bool = False, + max_exclusive: bool = False, + ) -> None: + specs.append( + { + "key": key, + "label": label, + "current_text": _format_value(current_raw), + "current_raw": current_raw, + "target_text": target_text, + "action": action, + "target_attr": target_attr, + "target_attrs": target_attrs, + "editable": True, + "enabled": has_model and enabled, + "status_text": "可修改" if has_model and enabled else "不可修改", + "enabled_tip": enabled_tip, + "disabled_tip": disabled_tip, + "value_type": value_type, + "range_hint": range_hint, + "min_value": min_value, + "max_value": max_value, + "min_exclusive": min_exclusive, + "max_exclusive": max_exclusive, + } + ) + used_keys.update(used or (key,)) + + if is_plane: + add_spec( + key="push_pull_distance", + label="Face偏移", + current_raw=0.0, + target_text="0", + action="push_pull_face", + target_attr="offset_input", + enabled=True, + enabled_tip="输入平面 Face 的推拉距离,正数通常向外加料,负数通常向内切削。", + disabled_tip="当前对象不是可推拉的平面 Face。", + range_hint=push_pull_hint(), + ) + if is_shell_candidate: + current = _float_or_none(action_info.get("shell_thickness_estimate")) + add_spec( + key="shell_thickness_estimate", + label="薄壁厚度", + current_raw=current if current is not None else "", + target_text=numeric_text(current), + action="resize_shell_thickness", + target_attr="shell_thickness_input", + enabled=current is not None, + enabled_tip="输入薄壁/壳体局部区域的目标厚度。", + disabled_tip="当前平面没有稳定识别到可修改的薄壁厚度。", + value_type="positive", + range_hint=relative_range_hint(current, 0.35, 0.8), + used=("shell_thickness_estimate", "shell_current_thickness"), + **positive_minimum(), + ) + if is_hole_or_groove: + current_diameter = _float_or_none(action_info.get("diameter")) + diameter_upper = hole_diameter_upper_limit(current_diameter) + add_spec( + key="diameter", + label="孔/圆柱直径", + current_raw=current_diameter if current_diameter is not None else "", + target_text=numeric_text(current_diameter), + action="resize_hole", + target_attr="hole_diameter_input", + enabled=current_diameter is not None, + enabled_tip="输入当前孔/槽圆柱面的目标直径。", + disabled_tip="当前圆柱面没有稳定识别为孔/槽候选。", + value_type="positive", + range_hint=hole_diameter_hint(current_diameter), + max_value=diameter_upper, + **positive_minimum(), + ) + if is_slot_or_half_hole: + current_slot = _float_or_none(action_info.get("slot_chord_width_estimate")) + add_spec( + key="slot_chord_width_estimate", + label="槽/半孔宽度", + current_raw=current_slot if current_slot is not None else "", + target_text=numeric_text(current_slot), + action="resize_slot_width", + target_attr="slot_width_input", + enabled=current_slot is not None, + enabled_tip="输入槽或半孔的目标宽度。", + disabled_tip="当前对象不是稳定的槽/半孔候选。", + value_type="positive", + range_hint=relative_range_hint(current_slot, 0.35, 1.0), + **positive_minimum(), + ) + current_depth = _float_or_none(action_info.get("hole_depth_estimate")) + can_depth = bool(is_blind and has_bottom and current_depth is not None) + if is_blind or current_depth is not None: + add_spec( + key="hole_depth_estimate", + label="盲孔/盲槽深度", + current_raw=current_depth if current_depth is not None else "", + target_text=numeric_text(current_depth), + action="resize_hole_depth", + target_attr="hole_depth_input", + enabled=can_depth, + enabled_tip="输入盲孔或盲槽的目标深度。", + disabled_tip="需要识别到盲孔/盲槽底面后才能直接修改深度。", + value_type="positive", + range_hint=relative_range_hint(current_depth, 0.35, 1.0), + **positive_minimum(), + ) + if is_boss: + current_boss = _float_or_none(action_info.get("diameter")) + add_spec( + key="boss_diameter", + label="凸台直径", + current_raw=current_boss if current_boss is not None else "", + target_text=numeric_text(current_boss), + action="resize_boss", + target_attr="boss_diameter_input", + enabled=bool(is_full_cylinder and current_boss is not None), + enabled_tip="输入完整圆柱凸台的目标直径。", + disabled_tip="当前凸台候选不是接近完整圆柱,暂不放行直径修改。", + value_type="positive", + range_hint=relative_range_hint(current_boss, 0.3, 0.8), + used=("diameter",), + **positive_minimum(), + ) + if is_existing_fillet: + current_radius = _float_or_none(action_info.get("existing_fillet_radius_estimate")) + if current_radius is None: + current_radius = _float_or_none(action_info.get("radius")) + fillet_hint = relative_range_hint(current_radius, 0.35, 1.0) + arc_length = _float_or_none(action_info.get("existing_fillet_arc_length_estimate")) + if arc_length is not None and arc_length > 0: + fillet_hint = ( + f"{fillet_hint} 目标半径接近 {_format_float(arc_length * 0.5)} " + "(圆角圆弧长度估算的一半)时比例会很异常。" + ) + add_spec( + key="existing_fillet_radius_estimate", + label="已有圆角半径", + current_raw=current_radius if current_radius is not None else "", + target_text=numeric_text(current_radius), + action="resize_existing_fillet", + target_attr="edge_fillet_radius_input", + enabled=bool(current_radius is not None and has_fillet_support), + enabled_tip="输入已有圆角/倒圆面的目标半径。", + disabled_tip="当前圆角候选缺少稳定支撑面,暂不放行修改。", + value_type="positive", + range_hint=fillet_hint, + used=("existing_fillet_radius_estimate", "radius"), + **positive_minimum(), + ) + if has_edge: + current_length = _float_or_none(action_info.get("length")) + add_spec( + key="length", + label="Edge长度", + current_raw=current_length if current_length is not None else "", + target_text=numeric_text(current_length), + action="resize_any_edge_length", + target_attr="edge_target_length_input", + enabled=current_length is not None, + enabled_tip="输入当前Edge的目标长度。", + disabled_tip="当前Edge没有稳定长度信息。", + value_type="positive", + range_hint=relative_range_hint(current_length, 0.25, 0.5), + **positive_minimum(), + ) + if is_line_edge: + edge_limit = current_length * 0.45 if current_length is not None and current_length > 0 else None + edge_soft = current_length * 0.12 if current_length is not None and current_length > 0 else None + edge_warning = current_length * 0.25 if current_length is not None and current_length > 0 else None + fillet_edge_hint = "建议半径先用较小值试改;过大容易导致倒圆失败。" + chamfer_edge_hint = "建议距离先用较小值试改;过大容易导致倒角失败。" + if edge_soft is not None and edge_warning is not None: + fillet_edge_hint = ( + f"建议半径先不超过 Edge 长度的 12%(约 {_format_float(edge_soft)});" + f"超过 25%(约 {_format_float(edge_warning)})时风险较高。" + ) + chamfer_edge_hint = ( + f"建议距离先不超过 Edge 长度的 12%(约 {_format_float(edge_soft)});" + f"超过 25%(约 {_format_float(edge_warning)})时风险较高。" + ) + add_spec( + key="new_fillet_radius", + label="新增圆角半径", + current_raw="未添加", + target_text="", + action="fillet_edge", + target_attr="edge_fillet_radius_input", + enabled=True, + enabled_tip="输入半径后给当前直线Edge添加新圆角。", + disabled_tip="只有直线Edge才能直接添加圆角。", + value_type="positive", + range_hint=fillet_edge_hint, + max_value=edge_limit, + max_exclusive=edge_limit is not None, + **positive_minimum(), + ) + add_spec( + key="new_chamfer_distance", + label="新增倒角距离", + current_raw="未添加", + target_text="", + action="chamfer_edge", + target_attr="edge_chamfer_distance_input", + enabled=True, + enabled_tip="输入距离后给当前直线Edge添加新倒角。", + disabled_tip="只有直线Edge才能直接添加倒角。", + value_type="positive", + range_hint=chamfer_edge_hint, + max_value=edge_limit, + max_exclusive=edge_limit is not None, + **positive_minimum(), + ) + if self.selected_kind in {"part", "solid"}: + target_is_part = self.selected_kind == "part" + translate_action = "translate_selected_part" if target_is_part else "translate_selected_solid" + rotate_action = "rotate_selected_part" if target_is_part else "rotate_selected_solid" + add_spec( + key="translation_vector", + label="平移 X/Y/Z", + current_raw=(0.0, 0.0, 0.0), + target_text="0, 0, 0", + action=translate_action, + target_attrs=("translate_x_input", "translate_y_input", "translate_z_input"), + enabled=True, + enabled_tip="输入三个数,例如 10, 0, 0,平移当前对象。", + disabled_tip="请先选择零件或 Solid。", + value_type="vector3", + range_hint=translation_hint(), + ) + axis = self.rotate_axis_combo.currentText() if hasattr(self, "rotate_axis_combo") else "Z" + add_spec( + key="rotation_angle_degrees", + label=f"旋转角度({axis}轴)", + current_raw=0.0, + target_text="0", + action=rotate_action, + target_attr="rotate_angle_input", + enabled=True, + enabled_tip=f"输入旋转角度,当前使用 {axis} 轴。", + disabled_tip="请先选择零件或 Solid。", + range_hint="角度可正可负;建议单次输入 -360 到 360 度,超过后请确认旋转方向和单位。", + ) + return specs, used_keys + + def _on_property_table_item_changed(self, _item: QTableWidgetItem) -> None: + if getattr(self, "property_editor_updating", False): + return + self._update_property_apply_state() + + def _update_property_apply_state(self, has_model: bool | None = None) -> None: + if not hasattr(self, "property_table"): + return + if has_model is None: + has_model = self.model is not None and not ( + self.operation_in_progress or self.scan_in_progress or self.load_in_progress + ) + for row, spec in enumerate(getattr(self, "property_editor_specs", [])): + widget = self.property_table.cellWidget(row, 3) + if isinstance(widget, QPushButton): + widget.setEnabled(bool(has_model and spec.get("enabled") and spec.get("action"))) + if not hasattr(self, "apply_property_button"): + return + changed = self._changed_property_rows() + enabled = bool(has_model and changed) + disabled_tip = "请先选择对象,并在属性表中修改一个可编辑目标值。" + if changed and len(changed) > 1: + disabled_tip = "请使用每一行状态列里的修改按钮,单独应用某个特征。" + self._set_control_state( + self.apply_property_button, + enabled and len(changed) == 1, + "应用属性表中被修改的一行目标值。", + disabled_tip, + ) + + def _changed_property_rows(self) -> list[tuple[int, dict[str, object], str]]: + if not hasattr(self, "property_table"): + return [] + changed: list[tuple[int, dict[str, object], str]] = [] + for row, spec in enumerate(getattr(self, "property_editor_specs", [])): + if not spec.get("action") or not spec.get("enabled"): + continue + item = self.property_table.item(row, 2) + if item is None: + continue + text = item.text().strip() + if self._property_target_changed(spec, text): + changed.append((row, spec, text)) + return changed + + def _property_target_changed(self, spec: dict[str, object], text: str) -> bool: + if not text: + return False + value_type = str(spec.get("value_type", "number")) + if value_type == "vector3": + try: + values = self._parse_property_vector3(text) + except ValueError: + return True + return any(abs(value) > PROPERTY_VALUE_TOLERANCE for value in values) + try: + value = float(text) + except ValueError: + return True + if value_type == "positive" and value <= 0: + return True + if self._property_scalar_range_error(spec, value): + return True + current = _float_or_none(spec.get("current_raw")) + if current is None: + return True + return abs(value - current) > PROPERTY_VALUE_TOLERANCE + + def apply_current_property_edit(self) -> None: + changed = self._changed_property_rows() + if not changed: + self.statusBar().showMessage("请先在当前选中对象表中修改一个可编辑目标值。") + return + if len(changed) > 1: + QMessageBox.information(self, "一次应用一个修改", "请只修改一行目标值,然后再点击应用当前修改。") + return + row, _spec, _text = changed[0] + self.apply_property_row_edit(row) + + def apply_property_row_edit(self, row: int) -> None: + specs = getattr(self, "property_editor_specs", []) + if row < 0 or row >= len(specs): + self.statusBar().showMessage("当前属性行已经失效,请重新选择对象。") + return + spec = specs[row] + if not spec.get("action") or not spec.get("enabled"): + self.statusBar().showMessage("这一行当前不能直接修改。") + return + item = self.property_table.item(row, 2) if hasattr(self, "property_table") else None + text = item.text().strip() if item is not None else "" + if not self._property_target_changed(spec, text): + self.statusBar().showMessage("请先在这一行的目标值列输入一个不同的新值。") + return + try: + self._sync_property_edit_target(spec, text) + except ValueError as exc: + QMessageBox.information(self, "目标值无效", str(exc)) + return + action_name = str(spec.get("action", "")) + action = getattr(self, action_name, None) + if action is None: + QMessageBox.information(self, "暂不支持", f"当前属性没有可用的执行入口:{action_name}") + return + action() + + def _sync_property_edit_target(self, spec: dict[str, object], text: str) -> None: + value_type = str(spec.get("value_type", "number")) + if value_type == "vector3": + values = self._parse_property_vector3(text) + target_attrs = tuple(spec.get("target_attrs") or ()) + if len(target_attrs) != 3: + raise ValueError("这个属性缺少 X/Y/Z 输入绑定。") + for attr, value in zip(target_attrs, values): + getattr(self, str(attr)).setText(_format_float(value)) + return + try: + value = float(text) + except ValueError as exc: + raise ValueError("请输入数字形式的目标值。") from exc + if value_type == "positive" and value <= 0: + raise ValueError("请输入大于 0 的目标值。") + range_error = self._property_scalar_range_error(spec, value) + if range_error: + raise ValueError(range_error) + target_attr = spec.get("target_attr") + if not target_attr: + raise ValueError("这个属性缺少目标输入绑定。") + getattr(self, str(target_attr)).setText(_format_float(value)) + + def _property_scalar_range_error(self, spec: dict[str, object], value: float) -> str: + label = str(spec.get("label", "目标值")) + min_value = _float_or_none(spec.get("min_value")) + max_value = _float_or_none(spec.get("max_value")) + if min_value is not None: + min_exclusive = bool(spec.get("min_exclusive")) + if (min_exclusive and value <= min_value) or (not min_exclusive and value < min_value): + operator = "大于" if min_exclusive else "大于或等于" + return f"{label} 必须{operator} {_format_float(min_value)}。" + if max_value is not None: + max_exclusive = bool(spec.get("max_exclusive")) + if (max_exclusive and value >= max_value) or (not max_exclusive and value > max_value): + operator = "小于" if max_exclusive else "小于或等于" + return f"{label} 必须{operator} {_format_float(max_value)}。" + return "" + + def _parse_property_vector3(self, text: str) -> tuple[float, float, float]: + chunks = text.replace(",", ",").replace(";", ",").replace(";", ",").replace(" ", ",").split(",") + values = [chunk for chunk in chunks if chunk.strip()] + if len(values) != 3: + raise ValueError("请输入三个数字,例如 10, 0, 0。") + try: + return (float(values[0]), float(values[1]), float(values[2])) + except ValueError as exc: + raise ValueError("平移 X/Y/Z 必须都是数字。") from exc + def _selected_action_info(self) -> dict[str, object]: if self.model is None: return {} @@ -632,8 +1401,7 @@ class WindowStateMixin: locator_note = "" if record.target_kind in {"face", "feature"} and record.target_id is not None: - lookup_id = record.target_logical_id if record.target_logical_id is not None else record.target_id - resolved_face_id = self.model.resolve_face_selection_id(lookup_id) + resolved_face_id = self._resolve_record_face_id(record) if resolved_face_id is not None: info = self.model.feature_info(resolved_face_id) if record.target_kind == "feature" else self.model.face_info(resolved_face_id) self.selected_kind = "feature" if record.target_kind == "feature" else "face" @@ -641,7 +1409,7 @@ class WindowStateMixin: self.selected_part_id = int(info["part_id"]) self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None self.selected_pick_position = record.pick_position - self.mode_combo.setCurrentText("Feature" if record.target_kind == "feature" else "Face") + self._set_selection_mode("Feature" if record.target_kind == "feature" else "Face") current_logical_id = self.model.face_region_logical_id(resolved_face_id) self._sync_id_picker("Feature" if record.target_kind == "feature" else "Face", current_logical_id) face_ids = ( @@ -652,6 +1420,7 @@ class WindowStateMixin: if not face_ids: face_ids = self.model.face_region_ids(resolved_face_id) self._highlight_faces(face_ids=face_ids) + self.set_info(self._with_pick_info(info, record.pick_position)) located = True region_note = f";同域面区域 {len(face_ids)} 个Face" if len(face_ids) > 1 else "" id_note = ( @@ -672,24 +1441,60 @@ class WindowStateMixin: locator_note = f"定位: 原目标Face {record.target_id} 在当前模型索引中已经不存在。" elif record.target_kind == "edge" and record.target_id is not None: - if 0 <= record.target_id < len(self.model.edges): - info = self.model.edge_info(record.target_id) + resolved_edge_id = self._resolve_record_edge_id(record) + if resolved_edge_id is not None: + info = self.model.edge_info(resolved_edge_id) self.selected_kind = "edge" - self.selected_edge_id = record.target_id + self.selected_edge_id = resolved_edge_id self.selected_part_id = int(info["part_id"]) self.selected_solid_id = int(info.get("solid_id", -1)) if int(info.get("solid_id", -1)) >= 0 else None self.selected_pick_position = record.pick_position - self.mode_combo.setCurrentText("Edge") - self._sync_id_picker("Edge", record.target_id) - self._highlight_edge(record.target_id) + self._set_selection_mode("Edge") + self._sync_id_picker("Edge", resolved_edge_id) + self._highlight_edge(resolved_edge_id) + self.set_info(self._with_pick_info(info, record.pick_position)) located = True locator_note = ( - f"定位: 已尝试高亮当前模型中的Edge {record.target_id}。" + f"定位: 已尝试高亮当前模型中的Edge {resolved_edge_id}。" "布尔/倒圆编辑后Edge ID可能发生语义变化,请结合拾取点确认。" ) else: locator_note = f"定位: 原目标Edge {record.target_id} 在当前模型索引中已经不存在。" + elif record.target_kind == "part" and record.target_id is not None: + part_id = int(record.target_id) + if self.model.part_by_id(part_id) is not None: + info = self.model.part_info(part_id) + self.selected_kind = "part" + self.selected_part_id = part_id + self.selected_pick_position = record.pick_position + self._set_selection_mode("Part") + self._sync_id_picker("Part", part_id) + self._highlight_faces(part_ids=[part_id]) + self.set_info(self._with_pick_info(info, record.pick_position)) + located = True + locator_note = f"定位: 已尝试高亮当前模型中的零件 {part_id}。" + else: + locator_note = f"定位: 原目标零件 {part_id} 在当前模型索引中已经不存在。" + + elif record.target_kind == "solid" and record.target_id is not None: + solid_id = int(record.target_id) + if 0 <= solid_id < len(self.model.solids): + info = self.model.solid_info(solid_id) + self.selected_kind = "solid" + self.selected_solid_id = solid_id + self.selected_part_id = int(info["part_id"]) + self.selected_pick_position = record.pick_position + face_ids = [i for i, sid in enumerate(self.model.face_solid_ids) if sid == solid_id] + self._set_selection_mode("Solid") + self._sync_id_picker("Solid", solid_id) + self._highlight_faces(face_ids=face_ids) + self.set_info(self._with_pick_info(info, record.pick_position)) + located = True + locator_note = f"定位: 已尝试高亮当前模型中的Solid {solid_id}。" + else: + locator_note = f"定位: 原目标Solid {solid_id} 在当前模型索引中已经不存在。" + if record.pick_position is not None: self._show_pick_marker(record.pick_position) if not locator_note: @@ -705,6 +1510,93 @@ class WindowStateMixin: self.statusBar().showMessage(locator_note.splitlines()[0]) return locator_note + def _resolve_record_face_id(self, record: OperationRecord) -> int | None: + if self.model is None: + return None + if record.pick_position is not None: + nearest = self.model.nearest_face_id_to_point(self._record_candidate_face_ids(record), record.pick_position) + if nearest is not None: + return nearest + lookup_id = record.target_logical_id if record.target_logical_id is not None else record.target_id + if lookup_id is None: + return None + try: + return self.model.resolve_face_selection_id(int(lookup_id)) + except Exception: + return None + + def _resolve_record_edge_id(self, record: OperationRecord) -> int | None: + if self.model is None: + return None + if record.pick_position is not None: + nearest = self.model.nearest_edge_id_to_point(self._record_candidate_edge_ids(record), record.pick_position) + if nearest is not None: + return nearest + if record.target_id is None: + return None + try: + edge_id = int(record.target_id) + except (TypeError, ValueError): + return None + return edge_id if 0 <= edge_id < len(self.model.edges) else None + + def _record_candidate_face_ids(self, record: OperationRecord) -> list[int]: + if self.model is None: + return [] + part_id = self._record_int_parameter(record, "part_id") + solid_id = self._record_int_parameter(record, "solid_id") + face_ids = list(range(len(self.model.faces))) + if part_id is not None: + face_ids = [face_id for face_id in face_ids if self.model.face_part_ids[face_id] == part_id] + if solid_id is not None and solid_id >= 0: + face_ids = [face_id for face_id in face_ids if self.model.face_solid_ids[face_id] == solid_id] + target_diameter = ( + self._record_float_parameter(record, "new_diameter") + or self._record_float_parameter(record, "derived_new_diameter") + or self._record_float_parameter(record, "target_diameter") + ) + if target_diameter is not None and target_diameter > 0: + diameter_tolerance = max(target_diameter * 0.02, 1e-5) + cylindrical_face_ids: list[int] = [] + for face_id in face_ids: + try: + info = self.model.face_info(face_id) + except Exception: + continue + current_diameter = _float_or_none(info.get("diameter")) + if info.get("surface") == "cylinder" and current_diameter is not None: + if abs(current_diameter - target_diameter) <= diameter_tolerance: + cylindrical_face_ids.append(face_id) + if cylindrical_face_ids: + face_ids = cylindrical_face_ids + return face_ids or list(range(len(self.model.faces))) + + def _record_candidate_edge_ids(self, record: OperationRecord) -> list[int]: + if self.model is None: + return [] + part_id = self._record_int_parameter(record, "part_id") + solid_id = self._record_int_parameter(record, "solid_id") + edge_ids = list(range(len(self.model.edges))) + if part_id is not None: + edge_ids = [edge_id for edge_id in edge_ids if self.model.edge_part_ids[edge_id] == part_id] + if solid_id is not None and solid_id >= 0: + edge_ids = [edge_id for edge_id in edge_ids if self.model.edge_solid_ids[edge_id] == solid_id] + return edge_ids or list(range(len(self.model.edges))) + + def _record_int_parameter(self, record: OperationRecord, key: str) -> int | None: + parameters = record.parameters if isinstance(record.parameters, dict) else {} + value = parameters.get(key) + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + def _record_float_parameter(self, record: OperationRecord, key: str) -> float | None: + parameters = record.parameters if isinstance(record.parameters, dict) else {} + return _float_or_none(parameters.get(key)) + def undo_edit(self) -> None: if self.model is None: return