diff --git a/README.md b/README.md index a5761e7..efc6aa5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - 读取 STEP 中的零件、装配、solid 等结构。 - 用户可以选择零件、solid、面、边和局部几何特征。 - 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。 -- 用户可以修改可控的局部特征,比如平面推拉、孔径调整、圆角半径调整。 +- 用户可以修改可控的局部特征,比如平面推拉、孔径调整、盲孔深度调整、凸台直径调整、边倒圆、边倒角和受限直线边长度调整。 - 用户可以撤销/重做修改,避免实验性编辑一步做坏。 - 用户可以导出修改后的完整模型。 - 如果 STEP 文件里存在多个零件,用户可以只导出选中的某个零件。 @@ -41,18 +41,30 @@ - 默认加载 `geom_extract.step`。 - 支持打开其他 `.step` / `.stp` 文件。 +- 打开 STEP 时会先在临时模型里完成读取和显示网格生成;失败时当前模型保持不变。 - 使用 XCAF 读取 STEP 中的 part / assembly 标签。 -- 零件 / 装配树会显示 part / assembly 层级,并在对应 part 下面列出 solid 子节点。 -- 点击零件 / 装配树中的 solid 子节点,可以直接选中、高亮、查看属性并用于导出该 solid。 +- 模型结构树会用中文显示 STEP 里的装配、零件和实体层级,并在对应零件下面列出实体子节点。 +- 点击模型结构树中的实体子节点,可以直接选中、高亮、查看属性并用于导出该实体。 - 使用 OCCT 读取真实 B-Rep 拓扑。 - 使用 VTK 显示模型,并通过 PySide6/Qt 承载桌面界面。 +- 左侧操作面板按功能模块分组显示,并使用四边同色的轻量彩色边框和标题层级区分文件、选择、显示、导出、编辑、候选、历史和属性信息。 - 支持选择模式: - Part - Solid - Face - Edge - Feature -- 支持按 ID 直接选择 Part / Solid / Face / Edge。 +- 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。 +- 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界 edge。 +- 支持按当前选择模式输入 ID 直接选择 Part / Solid / Face / Edge / Feature。 +- `鼠标选择模式` 下拉框不会响应鼠标滚轮,避免鼠标经过时误切换模式;需要点击后手动选择。 +- `按 ID 选择` 会在输入框和 `选择` 按钮之间同步显示当前鼠标选择模式。 +- `Feature` 模式现在会把点到的 face 解释为局部几何特征候选: + - 平面会识别为可推拉平面候选。 + - 圆柱面会识别为圆柱孔候选、槽/半孔候选、圆角/倒圆候选、凸台/外圆候选或未明确圆柱特征。 + - 圆柱特征会尝试找出边界 edge、相邻 face、端面 face、开口端相邻 face 和疑似底面 face,并一起高亮相关局部区域。 + - 槽/半孔候选会额外显示槽宽、圆弧长度、槽深估算和槽边界相邻 face。 + - 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑 face。 - 支持显示选中对象信息,并提供 `属性表` / `原始文本` 两种查看方式: - 零件名称 - solid 数量 @@ -74,19 +86,35 @@ - edge 的参数范围、起点、终点、长度中心、包围盒。 - 直线 edge 的方向,圆弧 edge 的圆心、轴线、半径和直径。 - 属性面板支持复制当前对象 ID、拾取坐标和完整信息。 -- 支持高亮选中的零件、solid、面或边。 +- 面/边属性查询带缓存;重复选择同一个对象会复用上次计算结果,模型编辑、撤销、重做或重新加载后缓存会自动清空。 +- 支持高亮选中的零件、solid、面、边或局部特征候选,并在鼠标悬停时用红色预览当前可选对象。 +- 支持只显示当前选中对象、对准当前选中对象,并可一键恢复显示完整模型。 +- Feature 模式会在属性表里显示特征来源 face、侧壁 face、端面 face、疑似底面 face、开口端相邻 face、边界 edge 和当前可用操作。 - 支持导出当前完整模型为 STEP。 - 支持导出选中的 part 为 STEP。 - 支持导出选中的 solid 为 STEP。 - 支持导出选中的 face 为 STEP。 +- 支持导出选中特征区域为 STEP。 +- 支持导出选中的 edge 为 STEP。 +- 支持导出质量检查:导出前会检查 B-Rep 有效性、solid / face / edge 数量、体积和包围盒;如果发现风险,会先弹窗确认。 +- 导出完整模型、part 或 solid 前,会先尝试执行 ShapeFix 和同域面/边合并,减少外部 CAD 打开时出现碎面、坏体或明显拼接痕迹的概率。 +- 实验性编辑按钮会根据当前选中对象自动启用或禁用,并通过悬停提示说明为什么当前操作不可用,减少 face、edge、孔、凸台、盲孔等对象混用导致的误操作。 - 支持扫描第一版可编辑对象: + - 默认扫描优先保证界面响应速度,会列出一组有代表性的候选。 + - `深度扫描` 会尝试列出更多候选,适合默认列表里没有找到目标对象时使用。 - 可推拉的平面 face。 - 可调整孔径的圆柱 face 候选。 + - 可调整盲孔深度的圆柱 face 候选。 + - 可添加新圆角的直线 edge 候选。 + - 可添加倒角的直线 edge 候选。 + - 可尝试通过端面推拉调整长度的直线 edge 候选。 - 表格会显示当前值、状态、风险、置信度和说明。 - - 点击表格行会直接选中对应 face,方便继续执行推拉或孔径调整。 + - 盲孔深度候选会在列表阶段先确认疑似底面,列表里的深度值优先使用底面 face 轴向位置计算。 + - 点击表格行会直接选中对应 face 或 edge,方便继续执行推拉、孔径调整、盲孔深度调整、边倒圆、边倒角或受限直线边长度调整。 - 支持列出圆柱面候选特征。 - 圆柱候选会显示在表格中,点击候选行可以直接选中并高亮对应 face。 - 圆柱候选可以按类型筛选:全部、孔/槽候选、圆角候选、凸柱/外圆候选、未明确分类。 +- 圆柱候选的类型下拉框不会响应鼠标滚轮,避免滚动左侧面板时误改筛选条件。 - 圆柱候选表格会显示初步分类: - `hole/groove candidate`:孔/槽一类的凹向圆柱候选。 - `round/fillet candidate`:小半径局部圆柱面,可能是圆角/倒圆。 @@ -94,25 +122,103 @@ - `cylindrical face`:暂时无法明确分类的圆柱面。 - 圆柱候选表格会显示直径、角度跨度、估算高度和置信度。 - 圆柱候选表格会显示 `risk`,用于提示调整圆柱孔径的风险。 -- 圆柱候选表默认列出前 100 个候选,以避免打开大模型时刷新过慢。 +- 圆柱候选表默认列出前 60 个候选,以避免打开大模型时刷新过慢。 - 实验性支持平面 face 推拉。 - 平面推拉会自动判断面两侧的 inside / outside,输入正数表示向外加料,输入负数表示向内切削。 - 推拉平面会先显示半透明预览体,表示推拉方向和大致范围,然后在后台执行真实 OCCT 布尔计算。 -- 后台编辑计算时会显示进度提示,并暂时阻止选择、扫描、导出或关闭窗口。 +- 推拉平面现在会从选中 face 出发,自动查找共享边且共面的相邻 face,并把它们作为同一片平面区域一起推拉。 +- 这可以减少 STEP 碎面导致的“只推了一小块,导出后像贴上去一块,中间有伤疤”的情况。 +- 推拉加料时,工具体会向原实体内部保留一个很小的重叠量,再执行 Fuse,避免只靠两个面贴合导致导出后像“原模型 + 新增块”叠在一起。 +- Fuse/Cut 完成后会执行更强的推拉结果清理:按模型尺寸设置小容差,显式禁止保留 internal edges,并多轮执行 OCCT 同域面/边合并,减少第三方 CAD 中出现不必要共面分割线的概率。 +- 如果推拉本身产生了真实台阶边界,第三方 CAD 仍会显示这条几何边;清理只能减少不必要的共面残留边,不能消除真实形状边界。 +- 推拉平面会先生成操作计划:检查距离是否为 0、方向置信度和相对 face 尺寸的风险;高风险会先弹窗确认。 +- 调整圆柱孔径也会先显示半透明预览体:红色表示切削 cutter,绿色表示缩小孔径时的补料范围。 +- 支持第一版盲孔/盲槽深度调整: + - 只对 `hole/groove candidate` 且端部类型为 `blind`、并识别到疑似底面 face 的圆柱候选开放。 + - 目标深度大于当前估算深度时,会沿开口到疑似底面方向用有限长度 cutter 加深切削。 + - 目标深度小于当前估算深度时,会从目标新底面到旧底面方向补料,让孔变浅。 + - 调整盲孔深度会先显示半透明预览体:红色表示加深切削范围,绿色表示变浅补料范围。 + - 孔深调整计划会记录当前深度、目标深度、变化比例、疑似底面、开口端和工具参数。 + - 当前深度会优先使用疑似底面 face 在圆柱轴线上的位置计算;如果相邻端面找不到底面,会额外扫描同一 solid 内轴线附近的平面圆盘候选。 +- 支持第一版槽/半孔候选结构化识别: + - 对局部圆柱形 `hole/groove candidate`,会标记为 `partial-cylindrical-groove`。 + - 会估算槽宽、圆弧长度和槽深。 + - 会识别槽圆柱 face 和槽边界相邻 face,并在 Feature 模式下一起高亮。 + - 这些仍是 B-Rep 几何估算,不是 CAD 历史里的槽特征参数。 +- 支持第一版已有圆角/倒圆候选结构化识别: + - 对局部小半径圆柱面,会标记为 `round/fillet candidate`。 + - 会估算已有圆角半径、圆弧角度和圆弧长度。 + - 会识别已有圆角 face 和相邻支撑 face,并在 Feature 模式下一起高亮。 + - `第一版可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径`,点击后会选中该圆角 face,并把圆角半径输入框预填为当前估算半径的参考目标值。 + - 选中已有圆角候选时,不会再误把它填入 `孔直径`,避免把圆角当孔径修改。 + - 第一版已有圆角半径修改会先尝试移除圆角 face,再在恢复出的锐边上重新倒圆;只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。 +- 支持第一版圆柱孔封堵: + - 只对 `hole/groove candidate` 且角度跨度接近完整圆柱的孔开放。 + - 不对半孔/槽开放,避免用完整圆柱补料时把不该补的位置也补上。 + - 封堵会用略带半径重叠的圆柱补料体执行 Fuse。 + - 绿色半透明预览表示即将补上的材料范围。 + - 当前默认模型可能不会在默认可编辑对象列表里显示封堵候选;其他包含完整圆柱孔的 STEP 文件可以使用这个能力。 +- 支持第一版圆柱凸台直径调整: + - 只对 `boss/outer-round candidate` 且角度跨度接近完整圆柱的凸台开放。 + - 不处理局部外圆角、圆角面或未明确圆柱面,避免把圆角当凸台修改。 + - 目标直径变大时,会生成目标直径圆柱并 Fuse。 + - 目标直径变小时,会生成环形 cutter 并 Cut。 + - 会先显示半透明预览:绿色表示扩大补料范围,红色表示缩小切削范围。 + - 当前默认模型可能没有明确凸台候选;包含完整圆柱凸台的 STEP 文件可以使用这个能力。 +- 支持第一版直线边添加圆角: + - 只对直线 edge 开放。 + - 会先检查边长、相邻 face 数量和目标半径相对边长的比例。 + - 蓝色半透明预览表示目标 edge 和输入半径的大致范围。 + - 真实编辑使用 OCCT `BRepFilletAPI_MakeFillet` 在后台执行。 + - 这一步是“给直线边添加新圆角”,还不是“修改已有圆角半径”。 + - STEP 没有建模历史,某些看起来像直线边的对象仍可能被 OCCT 拒绝;失败时会自动尝试恢复编辑前状态。 +- 支持第一版直线边添加倒角: + - 只对直线 edge 开放。 + - 会先检查边长、相邻 face 数量和倒角距离相对边长的比例。 + - 橙色半透明预览表示目标 edge 和输入倒角距离的大致范围。 + - 真实编辑使用 OCCT `BRepFilletAPI_MakeChamfer` 在后台执行。 + - STEP 没有建模历史,某些边仍可能被 OCCT 拒绝;失败时会自动尝试恢复编辑前状态。 +- 支持第一版受限直线边长度调整: + - 只对直线 edge 开放。 + - 选中直线 edge 后,`边目标长度` 会自动填入当前边长。 + - 输入目标长度后,程序会尝试寻找这条边端点附近、方向匹配的平面端面。 + - 找到端面后,会把“改边长”转换成该端面的平面推拉操作,并显示蓝绿色半透明预览。 + - 如果找不到可靠端面,或者目标长度无效,会直接阻止,不会强行修改。 + - 这不是通用参数化边长编辑;复杂拓扑、斜面过渡、圆角链或没有明确端面的边仍可能不能改。 +- 支持 part / solid 级平移: + - 输入 X/Y/Z 平移量后,可以平移当前选中零件。 + - 选中 solid、或选中属于某个 solid 的 face/edge 后,可以平移当前 solid。 + - 单 solid 零件中平移 solid 实际等同于移动整个 part shape,程序会按中风险提示。 + - 平移操作会写入历史记录,并支持撤销/重做。 +- 支持 part / solid 级旋转: + - 选择 X/Y/Z 旋转轴并输入角度后,可以旋转当前选中零件或 solid。 + - 第一版旋转中心取目标对象的包围盒中心。 + - 单 solid 零件中旋转 solid 实际等同于旋转整个 part shape,程序会按中风险提示。 + - 旋转操作会写入历史记录,并支持撤销/重做。 +- 后台编辑计算时会显示进度提示,主界面会暂时置灰,并阻止选择、扫描、导出、撤销/重做、历史详情查看或关闭窗口。 - 真实 B-Rep 结果会在布尔计算完成后一次性刷新;半透明预览不等于最终几何结果。 +- 如果后台编辑失败,程序会自动尝试恢复到编辑前快照,避免出现“提示失败但模型已经被部分改动”的状态。 - 实验性支持圆柱孔/圆柱面扩大切削。 - 实验性支持圆柱孔/圆柱面缩小孔径:先补料,再用目标直径重切。 - 圆柱孔/圆柱面孔径调整现在会先生成切削计划: - - 新直径必须大于当前直径,否则直接阻止。 + - 目标直径必须大于 0;如果目标直径与当前直径几乎相同,会直接阻止。 + - 会计算直径变化比例;变化超过当前直径 35% 会提高到中风险,超过 100% 会提高到高风险。 + - 会比较目标直径和圆柱面估算高度;比例异常时会提高风险等级。 - 候选不是 `hole/groove candidate` 时会标为高风险并要求确认。 - 圆角/倒圆候选会标为高风险,避免误切圆角。 + - 缩小孔径第一版只对 `hole/groove candidate` 开放,其他圆柱面会直接阻止。 - 选中 face 或实际切削计划会使用多点材料采样,比候选表里的快速分类更谨慎。 - 实际切削优先使用有限长度 cutter,按选中圆柱面的 V 参数范围加少量余量生成,不再默认贯穿整个零件。 - 会沿圆柱轴线采样两端材料状态,初步区分通孔/开口端、盲孔和封闭端。 + - 会把圆柱相邻平面按轴线位置分成起点端/终点端,并结合端部采样标记疑似盲孔底面;相邻端面缺失时,会尝试扫描轴线附近的平面圆盘候选作为兜底。 + - 孔径修改计划会记录疑似底面 face、开口端相邻 face 和 cutter 底面保护状态。 - 对盲孔/封闭端,有限长度 cutter 会使用更小的端部余量,降低切深变长的风险。 - 支持撤销/重做实验性编辑。 +- 撤销/重做会先确认模型恢复成功,再移动历史栈;如果恢复失败,会尽量回到操作前状态。 - 支持操作历史面板,显示当前已经成功执行的编辑。 - 操作历史详情会显示编辑类型、目标对象、参数、执行结果和拓扑数量变化。 +- 操作历史详情会额外显示目标零件自身的 solid / face / edge 数量变化。 +- 如果一次局部编辑导致目标零件 solid 数异常变化,历史详情会显示质量警告,状态栏也会提示先检查结果。 - 操作历史详情会显示编辑前后的几何差异摘要: - 体积变化。 - 表面积变化。 @@ -146,28 +252,34 @@ vertices: 3262 ## 还没有实现什么 -当前还没有实现: +这里分成两类看,不然容易误解为“完全没有推进”。 -- 稳定的复杂特征识别。 -- 圆角半径修改。 -- 稳定的盲孔底面识别和真实 CAD 语义深度恢复。 -- 槽、凸台、壳体局部区域的完整识别和编辑。 -- 任意边长直接修改。 -- 参数化建模历史恢复。 -- 工程公差级的局部偏差分析,例如只针对选中区域统计距离并导出区域偏差报告。 -- 多零件装配的大规模测试。 -- 更完整的错误恢复和模型修复流程。 +已经有第一版,但还需要继续稳定和完善: + +- 复杂特征识别:已经有平面、圆柱、边、孔/槽候选、槽/半孔候选、已有圆角候选、圆柱凸台候选等基础候选列表,但还不是完整 CAD 语义级识别。 +- 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面 face 的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。 +- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、完整圆柱凸台直径修改,复杂槽、复杂凸台和壳体局部区域仍需要继续做。 +- 已有圆角/倒圆面半径:已经有第一版受限修改实现,使用 defeature + refillet 路线,但复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。 +- 局部偏差分析:已经有编辑前后体积、面积、包围盒、距离热力图和差异报告,但还不是工程公差级的局部区域偏差报告。 +- 错误恢复和模型修复:已经有编辑前快照、失败回滚、基础 B-Rep 校验、同域面合并和布尔结果自动修复,但还不是完整的模型修复流水线。 +- 多零件装配:已经有装配/零件树和单独导出入口,但还没有做大规模、多来源 STEP 装配测试。 + +仍基本没有实现,或者 STEP 本身不一定能可靠支持: + +- 通用任意边长直接修改。当前只有第一版受限直线边长度调整,会尝试通过端面推拉来改变长度;没有明确可推拉端面的边仍不能改。 +- 参数化建模历史恢复。STEP 通常只保存结果几何/拓扑,不能稳定还原原 CAD 软件里的建模历史,只能在本程序里记录后续编辑历史,或做近似的特征语义识别。 +- 所有场景都稳定的复杂特征识别。 ## 下一步要实现什么 建议下一步按这个顺序推进: -1. 用第一版可编辑对象列表逐项验证当前模型上的平面推拉和孔径调整,记录哪些对象稳定、哪些对象会失败。 -2. 继续改进孔径修改:降低缩小孔径的布尔失败率,并更准确识别盲孔底面。 -3. 改进圆角识别,并实现圆角半径修改。 -4. 增加槽、凸台、局部区域识别。 -5. 优化导出策略,例如导出时保留更多名称、颜色和层级信息。 -6. 增强历史记录面板,增加选中局部区域的偏差统计、偏差报告导出和更接近工程公差检查的分析。 +1. 继续加固最小可用编辑闭环:选择、预览、执行、校验、修复、回滚、导出。 +2. 继续验证平面推拉、孔径调整、盲孔/盲槽深度调整和圆柱凸台直径调整,记录哪些对象稳定、哪些对象会失败。 +3. 改进盲孔/槽底面识别,减少“看起来像 blind 但找不到可靠底面 face”的候选。 +4. 继续加固已有圆角半径修改:扩大可成功场景,记录哪些圆角能 defeature + refillet,哪些需要更复杂的重建策略。 +5. 继续增加复杂槽、复杂凸台、局部区域识别。 +6. 优化导出策略,例如导出时保留更多名称、颜色和层级信息。 ## 怎么运行 @@ -224,55 +336,93 @@ python main.py --smoke-test - 鼠标滚轮:缩放模型。 - 鼠标中键或组合拖动:平移模型,具体取决于 VTK 默认交互方式。 -左侧 `Selection mode` 用来切换选择模式: +左侧操作面板按功能分区排列,每个模块都有四边同色的独立边框和颜色提示,方便快速区分文件、模型结构树、选择、显示、导出、编辑、候选列表、历史记录和对象信息。 + +左侧 `鼠标选择模式` 用来切换选择模式: - `Part`:选择整个零件。 - `Solid`:选择实体。 - `Face`:选择面。 - `Edge`:选择边。 -- `Feature`:按特征候选方式选择,目前主要用于圆柱面候选。 +- `Feature`:按局部特征候选方式选择。平面会识别为可推拉平面候选;圆柱面会进一步识别为圆柱孔、槽/半孔、圆角/倒圆、凸台/外圆等候选。 + 圆柱候选会额外显示侧壁 face、端面 face、疑似底面 face、开口端相邻 face 和边界 edge。 +- 鼠标悬停时,当前模式下将要选中的对象会显示为红色;真正选中后会显示为黄色。 +- 如果当前模式是 `Edge`,即使鼠标点到的是面,程序也会从这个面的边界里选取距离鼠标拾取点最近的 edge。 +- `鼠标选择模式` 下拉框不响应鼠标滚轮,鼠标停在上面滚动时不会自动切换选项;需要点击下拉框后手动选择。 -左侧 `零件 / 装配树` 会显示 STEP 里的 part / assembly 层级;如果某个 part 下有 solid,会展开显示 `Solid 0`、`Solid 1` 这样的子节点。点击 solid 子节点会自动切换到 `Solid` 选择,并高亮对应实体。 +左侧 `模型结构树` 会显示 STEP 里的装配、零件和实体层级;如果某个零件下有实体,会展开显示 `实体 0`、`实体 1` 这样的子节点。点击实体子节点会自动切换到 `Solid` 选择,并高亮对应实体。 + +普通单零件 STEP 通常会显示成类似: + +```text +零件 1:2001-LOWER_HOUSING 零件 | 实体 1 个 | 面 1800 个 | 边 4982 条 + 实体 0 面 1800 个 | 边 4982 条 +``` + +如果 STEP 是多零件装配,树里可能会先出现 `装配` 节点,下面再挂多个 `零件` 节点;装配可以理解为“多个零件组成的一组结构”。 左侧 `按 ID 选择` 可以直接输入编号选择对象: +- 按 ID 选择会复用上方 `鼠标选择模式` 的当前类型;先切换模式,再输入 ID,点击 `选择`。 +- 输入框右侧会同步显示当前类型,例如 `Face`、`Edge` 或 `Solid`,用于确认这个 ID 会按哪类对象解释。 - `Part` 的 ID 从 1 开始。 -- `Solid`、`Face`、`Edge` 的 ID 从 0 开始。 +- `Solid`、`Face`、`Edge`、`Feature` 的 ID 从 0 开始;`Feature` 使用来源 face 的 ID。 - 这个功能适合配合圆柱候选表格、操作历史和属性面板使用。 +左侧 `显示` 用来临时缩小三维视图范围: + +- `只显示选中`:只显示当前选中的 part、solid、face、edge 或 feature 区域。 +- `对准选中`:不改变显示范围,只把相机对准当前选中的 part、solid、face、edge 或 feature,适合快速查看小孔、小边和局部特征。 +- `显示全部`:恢复显示完整模型。 +- solid / edge 的显示查询已经使用拓扑索引,避免每次隔离或高亮时反复全模型查找。 +- 这是视图操作,不会修改 STEP 几何,也不会写入操作历史。 + 左侧 `第一版可编辑对象` 是当前 MVP 的主要入口: - 点击 `扫描可编辑对象` 会列出当前第一版能尝试编辑的对象。 +- 点击 `深度扫描` 会用更高上限重新扫描,可能需要等待更久。 - 为了避免打开大模型时界面卡住,程序不会在加载 STEP 时自动扫描;需要编辑时再手动点击扫描。 -- 当前默认显示前 40 个可编辑候选,详细几何判断会尽量延后到选中对象或执行编辑前。 +- 当前默认显示一批代表性的可编辑候选,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。 - `推拉平面` 行表示这个 face 是平面,可以配合 `面偏移` 和 `推拉平面` 使用。 - `调整圆柱孔径` 行表示这个 face 是圆柱候选,可以配合 `孔直径` 和 `调整圆柱孔径` 使用。 +- `调整圆柱凸台直径` 行表示这个 face 是较明确的完整圆柱凸台候选,可以配合 `凸台直径` 和 `调整圆柱凸台直径` 使用。 +- `封堵圆柱孔` 行表示这个 face 是接近完整圆柱的孔候选,可以点击后直接使用 `封堵圆柱孔`。 +- `调整盲孔深度` 行表示这个 face 是较明确的盲孔/盲槽候选,可以配合 `孔深度` 和 `调整盲孔深度` 使用。 +- `给边添加圆角` 行表示这个对象是直线 edge,可以配合 `圆角半径` 和 `给边添加圆角` 使用。 +- `给边添加倒角` 行表示这个对象是直线 edge,可以配合 `倒角距离` 和 `给边添加倒角` 使用。 +- `修改已有圆角半径` 行表示这个 face 是已有圆角/倒圆候选,点击后会切到 `Feature` 模式并预填圆角半径参考值;真正执行需要再点击编辑区里的 `修改已有圆角半径` 按钮并确认。 - `状态` 为 `ready` 表示比较适合尝试,`caution` 表示可以尝试但风险更高,`blocked` 表示当前参数或对象不适合执行。 - `风险` 越高越应该谨慎,尤其是圆角、凸柱或未明确圆柱面,不要直接当孔修改。 -- 点击任意一行会自动选中并高亮对应 face。 +- 点击任意一行会自动选中并高亮对应 face 或 edge。 选择对象后: -- 被选对象会高亮。 -- 下方 `Selection info` 会显示属性。 +- 鼠标经过对象时会出现红色预高亮;被选对象会显示为黄色高亮。 +- 如果处于 `Feature` 模式,程序会尽量高亮这个特征相关的局部区域,例如圆柱侧壁、相邻端面和槽边界相邻面。 +- 对槽/半孔候选,属性表会显示 `槽宽估算`、`槽圆弧长度估算`、`槽深估算` 和 `槽边界相邻 Face`。 +- 对圆角/倒圆候选,属性表会显示 `已有圆角半径估算`、`已有圆角圆弧角度`、`已有圆角圆弧长度估算` 和 `已有圆角支撑 Face`。 +- 对盲孔/槽候选,属性表中的 `疑似底面 Face` 是根据圆柱端部 inside / outside 采样和相邻平面位置推断的,不等于 CAD 原始建模历史里的孔深参数。 +- 下方 `选中对象信息` 会显示属性。 - `属性表` 页签会把属性分成身份、拓扑、测量、位置、方向/轴线、参数、特征判断等分组。 - `原始文本` 页签会显示同一份信息的纯文本版本,也用于显示候选列表、操作历史详情和普通提示。 - `复制 ID` 会复制当前选中对象,例如 `face 2`。 - `复制坐标` 会复制当前拾取点坐标。 - `复制信息` 会复制当前信息面板里的完整文本。 -- 用鼠标在模型上选择时,`Selection info` 和窗口底部状态栏会显示 `pick_position`,也就是拾取点的三维坐标。 +- 用鼠标在模型上选择时,`选中对象信息` 和窗口底部状态栏会显示 `pick_position`,也就是拾取点的三维坐标。 - 如果选中圆柱面,会自动给 `Hole diameter` 填一个略大的建议直径。 +- 如果选中直线 edge,会自动给 `圆角半径` 和 `倒角距离` 填一个保守的建议值,并把 `边目标长度` 填成当前边长。 圆柱候选: - 点击 `列出圆柱候选` 会刷新候选表格。 - 圆柱候选也是手动扫描,避免加载模型和普通选择操作被候选识别拖慢。 - 可以通过候选表格上方的类型下拉框筛选候选;筛选只过滤已经扫描出的缓存结果,不会重新做几何识别。 +- 类型下拉框不响应鼠标滚轮,避免鼠标经过时误切换筛选条件。 - 当前默认显示前 60 个圆柱候选;如果模型很复杂,扫描仍可能需要等待一会儿,但扫描过程中界面会尽量保持响应。 - 表格里的每一行对应一个圆柱面候选。 - 点击候选行会自动切到 `Feature` 模式,并选中对应 face。 - `guess` 列是程序通过几何采样得到的初步判断,不是 STEP 文件里自带的 CAD 历史特征。 -- `round/fillet candidate` 只是圆角/倒圆候选,当前还不能直接修改半径。 +- `round/fillet candidate` 是圆角/倒圆候选;部分规整圆柱圆角可以通过 `修改已有圆角半径` 进行第一版受限修改。 - `diameter` 是圆柱直径。 - `span` 是圆柱面的角度跨度,接近 `6.283` 表示接近完整圆柱,接近 `3.142` 表示半圆柱。 - `height` 是根据圆柱面积估算出来的高度。 @@ -283,17 +433,26 @@ python main.py --smoke-test - `导出当前完整 STEP`:导出当前完整模型。 - `导出选中零件`:导出当前选中的零件。 -- `导出选中 solid`:先选中一个 solid,或选中属于某个 solid 的 face,再导出该 solid。 -- `导出选中 face`:先切换到 `Face` 或 `Feature` 选择模式并选中一个 face,再导出该 face。 +- `导出选中 solid`:先选中一个 solid,再导出该 solid。 +- `导出选中 face`:先切换到 `Face` 选择模式并选中一个 face,再导出该 face。 +- `导出选中特征区域`:先切换到 `Feature` 选择模式并选中局部特征,再导出当前特征高亮的 face 集合。 +- `导出选中 edge`:先切换到 `Edge` 选择模式并选中一个 edge,再导出该 edge。 +- `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。 +- 导出按钮会根据当前选中对象自动启用或禁用,避免选中 face 时误点 `导出选中 edge` 这类不匹配的操作。 +- 所有导出按钮都会先做质量检查。发现 B-Rep 无效、没有 solid、零件包含多个 solid、体积接近 0 等风险时,会先弹窗确认是否继续导出。 实验性编辑: +- 编辑按钮会根据当前选择自动启用或禁用。例如未选中平面时不能点击 `推拉平面`,未选中直线 edge 时不能点击 `给边添加圆角` / `给边添加倒角`,未选中明确孔/凸台/盲孔候选时对应圆柱编辑按钮会禁用。 - `面偏移` + `推拉平面`: - 先选择一个平面 face。 - 输入偏移距离。 - 正数表示沿程序判断的外法向加料。 - 负数表示沿内侧方向切削。 - 点击 `推拉平面` 后,会先出现半透明预览体;绿色表示向外加料方向,红色表示向内切削方向。 + - 如果距离为 0 会直接阻止;如果方向置信度低或距离相对 face 尺寸偏大,会先弹窗确认。 + - 如果选中 face 周围有共享边且共面的碎面,程序会自动把这些 face 合成同一片推拉区域,属性表和历史记录会显示 `推拉共面区域 Face 数`。 + - 加料时程序会让拉伸体和原实体产生极小重叠,并在布尔后尝试合并同域面/边,使导出的 STEP 更像一个整体实体。 - 程序随后在后台执行真实布尔运算,完成后再刷新为真正修改后的模型。 - 选中平面 face 后,可以在 `属性表` 的方向/轴线分组里查看 `推拉向外方向`、`推拉向内方向` 和 `推拉方向置信度`。 - 当前方向判断通过 solid 的 inside / outside 采样得到;如果采样不明确,会退回到拓扑方向法向,并在属性里显示低置信度说明。 @@ -303,9 +462,13 @@ python main.py --smoke-test - 输入新的直径。 - 目标直径大于当前直径时,会使用有限长度 cutter 扩大切削。 - 目标直径小于当前直径时,会先在原圆柱面范围内补料,再按目标直径重切。 + - 点击 `调整圆柱孔径` 后,会先显示半透明预览;红色是切削范围,绿色是缩小孔径时的补料范围。 - 程序会先生成切削计划,检查当前直径、目标直径、候选类型、材料投票和风险。 + - 切削计划会显示直径变化量、直径变化比例,以及目标直径相对圆柱面估算高度的比例。 - 切削计划会显示端部类型、深度估算、起点端状态和终点端状态。 + - 切削计划会显示疑似底面 face、开口端相邻 face 和底面保护说明;这些信息会写入操作历史。 - 如果目标直径小于等于 0,或几乎等于当前直径,程序会直接阻止。 + - 如果目标直径变化过大,或者目标直径相对圆柱估算高度明显异常,程序会提高风险并要求确认。 - 缩小孔径第一版只对 `hole/groove candidate` 开放;圆角、凸柱或未明确圆柱面会被直接阻止。 - 缩小孔径属于高风险实验功能,因为它依赖“补料 + 重切”的布尔近似,不是 CAD 历史参数修改。 - 如果当前 face 的猜测类型不是 `hole/groove candidate`,或风险不是低风险,程序会先弹窗确认。 @@ -313,13 +476,88 @@ python main.py --smoke-test - 缩小孔径时,操作历史会额外记录补料策略、补料半径和补料高度。 - 端部类型是通过轴线端部 inside / outside 采样得到的估算,不等于 CAD 原始建模历史里的“孔深”参数。 - 如果选到的圆柱面不是孔,而是柱或圆角,结果可能不是你想要的,所以它目前仍是实验功能。 +- `凸台直径` + `调整圆柱凸台直径`: + - 先选择一个完整圆柱凸台 face,建议从 `第一版可编辑对象` 中点击 `调整圆柱凸台直径` 行进入。 + - 只有接近完整圆柱的 `boss/outer-round candidate` 会放行;局部外圆角、圆角面、孔/槽和未明确圆柱面会被阻止。 + - 目标直径大于当前直径时,会在选中圆柱面的轴向范围内生成目标直径圆柱并 Fuse。 + - 目标直径小于当前直径时,会生成环形 cutter,把目标直径外侧到原直径附近的材料 Cut 掉。 + - 点击后会显示半透明预览:绿色表示扩大补料范围,红色表示缩小切削范围。 + - 这不是 CAD 历史里的“修改凸台特征参数”,而是基于当前 B-Rep 的受限几何布尔编辑。 +- `封堵圆柱孔`: + - 先选择一个完整圆柱孔 face,建议从 `第一版可编辑对象` 中点击 `封堵圆柱孔` 行进入。 + - 点击 `封堵圆柱孔` 后,程序会先生成补料计划。 + - 只有接近完整圆柱的 `hole/groove candidate` 会放行;半孔/槽、圆角、凸柱、未明确圆柱面会被阻止。 + - 通过检查后,会显示绿色半透明补料体预览。 + - 真实编辑会把补料体和当前零件 Fuse,并尝试合并同域面/边。 + - 这个功能适合封堵通孔或盲孔,但仍是基于 B-Rep 几何的实验性补料,不是 CAD 历史里的“删除孔特征”。 +- `孔深度` + `调整盲孔深度`: + - 先选择一个盲孔或盲槽的圆柱面,建议从 `第一版可编辑对象` 中点击 `调整盲孔深度` 行进入。 + - 选中符合条件的盲孔/盲槽后,`孔深度` 会自动填入一个比当前估算深度略大的建议值。 + - 输入目标深度后点击 `调整盲孔深度`。 + - 目标深度大于当前估算深度时,程序会用有限长度 cutter 沿孔方向加深切削,预览显示为红色。 + - 目标深度小于当前估算深度时,程序会在旧底面方向补料,让孔变浅,预览显示为绿色。 + - 目标深度必须大于 0;如果和当前估算深度几乎相同,会直接阻止。 + - 当前只支持 `hole/groove candidate`、端部类型为 `blind`、并且已经识别到 `疑似底面 Face` 的对象。 + - 通孔、封闭内腔、圆角、凸柱和未明确圆柱面会被阻止,避免误切。 + - 这个深度来自圆柱面 V 参数范围和端部 inside / outside 采样,是几何估算,不是 CAD 历史参数。 + - 操作历史会记录当前深度、目标深度、深度变化比例、底面 face、开口端 face 和工具参数。 +- `圆角半径` + `给边添加圆角`: + - 先切换到 `Edge` 选择模式并选择一条直线 edge,或从 `第一版可编辑对象` 中点击 `给边添加圆角` 行进入。 + - 选中直线 edge 后,`圆角半径` 会自动填入一个较小的建议半径。 + - 输入目标圆角半径后点击 `给边添加圆角`。 + - 程序会先检查半径是否大于 0、是否相对边长过大,以及该 edge 是否检测到至少两个相邻 face。 + - 点击后会显示蓝色半透明圆管预览,用于提示目标边和半径范围。 + - 真实编辑会在后台调用 OCCT 倒圆 API,完成后刷新模型。 + - 这是“给直线边添加新圆角”,不是修改已有圆角面。 + - 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒圆;失败时程序会提示并尝试恢复编辑前状态。 +- `圆角半径` + `修改已有圆角半径`: + - 先切换到 `Feature` 选择模式并选择一个已有圆角/倒圆候选 face,或从 `第一版可编辑对象` 中点击 `修改已有圆角半径` 行进入。 + - 选中已有圆角候选后,`圆角半径` 会自动填入一个基于当前估算半径的参考目标值。 + - 输入目标半径后点击 `修改已有圆角半径`。 + - 程序会先检查该 face 是否为 `round/fillet candidate`、是否识别到至少两个支撑 face、目标半径是否有效,以及当前零件是否为单 solid。 + - 通过检查后,会显示蓝色半透明预览,表示将尝试移除并重建的已有圆角面。 + - 真实编辑会先用 OCCT defeaturing 移除已有圆角面,再从恢复出的候选锐边中寻找可重新倒圆的边并调用 OCCT 倒圆 API。 + - 这是 B-Rep 几何重建,不是 CAD 历史圆角参数编辑;复杂 blend、支撑面不明确或恢复锐边失败时会失败并回滚。 +- `倒角距离` + `给边添加倒角`: + - 先切换到 `Edge` 选择模式并选择一条直线 edge,或从 `第一版可编辑对象` 中点击 `给边添加倒角` 行进入。 + - 选中直线 edge 后,`倒角距离` 会自动填入一个较小的建议距离。 + - 输入目标倒角距离后点击 `给边添加倒角`。 + - 程序会先检查倒角距离是否大于 0、是否相对边长过大,以及该 edge 是否检测到至少两个相邻 face。 + - 点击后会显示橙色半透明圆管预览,用于提示目标边和倒角距离范围。 + - 真实编辑会在后台调用 OCCT 倒角 API,完成后刷新模型。 + - 当前只支持对称距离倒角,不支持两侧不同距离或距离+角度倒角。 + - 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒角;失败时程序会提示并尝试恢复编辑前状态。 +- `边目标长度` + `调整直线边长度`: + - 先切换到 `Edge` 选择模式并选择一条直线 edge,或从 `第一版可编辑对象` 中点击 `调整直线边长度` 行进入。 + - 选中直线 edge 后,`边目标长度` 会自动填入当前边长。 + - 把它改成目标长度后点击 `调整直线边长度`。 + - 程序会先查找该边端点附近是否有可推拉的平面端面,并计算需要移动的端面 face 和推拉距离。 + - 通过检查后,会显示蓝绿色半透明预览,表示将要移动的端面推拉范围。 + - 真实编辑复用平面推拉的布尔流程,执行失败时会回滚到编辑前状态。 + - 如果这条边没有明确端面,或端面方向与边长变化不匹配,会被阻止。这是受限功能,不是任意边参数化改长。 +- `平移 X/Y/Z` + `平移选中零件` / `平移选中 solid`: + - 输入 X、Y、Z 三个方向的平移量。 + - 选择 `Part` 后点击 `平移选中零件`,会移动整个 part。 + - 选择 `Solid`,或选择属于某个 solid 的 face/edge 后,点击 `平移选中 solid`,会移动当前 solid。 + - 如果当前 part 只有一个 solid,平移 solid 实际会移动整个 part shape,程序会提示中风险。 + - 平移向量为 0 会直接阻止。 + - 如果平移距离相对对象包围盒过大,程序会提高风险并要求确认。 + - 平移属于真实 B-Rep 变换,会写入操作历史,可以撤销/重做。 +- `旋转轴/角度` + `旋转选中零件` / `旋转选中 solid`: + - 选择 X、Y 或 Z 旋转轴。 + - 输入旋转角度,单位是度。 + - 选择 `Part` 后点击 `旋转选中零件`,会绕该 part 的包围盒中心旋转整个 part。 + - 选择 `Solid`,或选择属于某个 solid 的 face/edge 后,点击 `旋转选中 solid`,会绕该 solid 的包围盒中心旋转当前 solid。 + - 如果当前 part 只有一个 solid,旋转 solid 实际会旋转整个 part shape,程序会提示中风险。 + - 旋转角度为 0 会直接阻止;角度超过 360 度会提高风险并要求确认。 + - 旋转属于真实 B-Rep 变换,会写入操作历史,可以撤销/重做。 - `撤销`: - 撤销上一次成功的实验性编辑。 - `重做`: - 重做刚刚撤销的实验性编辑。 - `操作历史`: - 显示当前模型已经成功执行的实验性编辑。 - - 点击某条历史记录,会在 `Selection info` 中显示该次编辑的目标、参数、执行结果和拓扑数量变化。 + - 点击某条历史记录,会在 `选中对象信息` 中显示该次编辑的目标、参数、执行结果和拓扑数量变化。 - 历史详情也会显示几何差异摘要,包括体积、表面积、包围盒尺寸和包围盒对角线的前后变化。 - 点击某条历史记录时,3D 窗口会显示红/绿半透明差异叠加预览:红色表示编辑前,绿色表示编辑后。 - 编辑后模型会覆盖距离热力图:蓝色接近无变化,黄色/红色表示变化更大。 diff --git a/main.py b/main.py index ddc540e..aac473c 100644 --- a/main.py +++ b/main.py @@ -66,6 +66,11 @@ class EditWorker(QObject): self.failed.emit(str(exc)) +class NoWheelComboBox(QComboBox): + def wheelEvent(self, event) -> None: + event.accept() + + INFO_GROUPS: list[tuple[str, list[str]]] = [ ( "身份", @@ -81,6 +86,8 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [ "parent_id", "depth", "feature_mode", + "feature_type", + "feature_source_face_id", ], ), ( @@ -95,6 +102,8 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [ "orientation", "surface", "curve", + "adjacent_face_ids", + "adjacent_face_count", ], ), ( @@ -104,8 +113,24 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [ "surface_area", "area", "length", + "edge_length", + "current_length", + "target_length", + "delta_length", + "length_change_ratio", "radius", "diameter", + "target_radius", + "radius_to_length_ratio", + "target_distance", + "distance_to_length_ratio", + "translation_distance", + "rotation_angle_degrees", + "current_diameter", + "target_diameter", + "delta_diameter", + "diameter_delta_ratio", + "target_to_height_ratio", "major_radius", "minor_radius", "reference_radius", @@ -113,6 +138,16 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [ "angular_span", "height_estimate", "hole_depth_estimate", + "slot_chord_width_estimate", + "slot_arc_length_estimate", + "slot_sagitta_depth_estimate", + "existing_fillet_radius_estimate", + "existing_fillet_angular_span", + "existing_fillet_arc_length_estimate", + "current_depth", + "target_depth", + "delta_depth", + "depth_delta_ratio", "is_full_cylinder", "bbox_diagonal", ], @@ -131,6 +166,12 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [ "bbox_size", "start_point", "end_point", + "translation_vector", + "rotation_center", + "end_face_id", + "end_face_label", + "end_face_plane_distance", + "push_pull_distance", ], ), ( @@ -143,12 +184,21 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [ "axis", "direction", "line_origin", + "rotation_axis", + "end_face_outward_direction", + "desired_movement_vector", "push_pull_outward_direction", "push_pull_inward_direction", "push_pull_plus_side", "push_pull_minus_side", "push_pull_confidence", "push_pull_note", + "push_pull_status", + "push_pull_risk", + "push_pull_message", + "push_pull_scope_face_ids", + "push_pull_scope_face_count", + "push_pull_scope_note", ], ), ( @@ -180,12 +230,54 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [ "end_sample_offset", "end_sample_note", "note", + "feature_face_ids", + "feature_side_face_ids", + "feature_end_face_ids", + "feature_bottom_face_ids", + "feature_opening_face_ids", + "feature_start_end_face_ids", + "feature_end_end_face_ids", + "feature_highlight_face_ids", + "feature_adjacent_face_ids", + "feature_boundary_edge_ids", + "feature_bottom_confidence", + "feature_bottom_detection", + "feature_bottom_note", + "feature_slot_face_ids", + "feature_slot_boundary_face_ids", + "slot_kind", + "slot_status", + "slot_angular_span", + "slot_open_angle", + "slot_note", + "feature_existing_fillet_face_ids", + "feature_existing_fillet_support_face_ids", + "existing_fillet_kind", + "existing_fillet_status", + "existing_fillet_note", + "feature_edit_actions", "resize_status", "resize_mode", "resize_risk", "resize_warnings", "resize_blockers", "resize_note", + "boss_resize_status", + "boss_resize_risk", + "boss_resize_warnings", + "boss_resize_blockers", + "boss_resize_note", + "suppress_status", + "suppress_risk", + "suppress_warnings", + "suppress_blockers", + "suppress_note", + "depth_status", + "depth_mode", + "depth_risk", + "depth_warnings", + "depth_blockers", + "depth_note", "cutter_strategy", "cutter_height", "cutter_margin", @@ -196,6 +288,10 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [ "cutter_end_parameter", "cutter_axis_direction", "cutter_start_point", + "cutter_bottom_protection", + "cutter_protected_bottom_face_ids", + "cutter_opening_face_ids", + "cutter_bottom_note", "cutter_note", "fill_strategy", "fill_height", @@ -203,6 +299,61 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [ "fill_radius_overlap", "fill_start_point", "fill_note", + "boss_tool_strategy", + "boss_tool_note", + "boss_tool_height", + "boss_tool_radius", + "boss_tool_old_radius", + "boss_tool_outer_radius", + "boss_tool_inner_radius", + "boss_tool_axial_margin", + "boss_tool_radial_overlap", + "boss_tool_start_parameter", + "boss_tool_end_parameter", + "boss_tool_axis_point", + "boss_tool_axis_direction", + "boss_tool_start_point", + "depth_tool_strategy", + "depth_tool_role", + "depth_tool_note", + "depth_tool_height", + "depth_tool_radius", + "depth_tool_radius_overlap", + "depth_tool_start_parameter", + "depth_tool_end_parameter", + "depth_open_parameter", + "depth_bottom_parameter", + "depth_nominal_bottom_parameter", + "depth_bottom_parameter_source", + "depth_current_depth", + "depth_current_depth_source", + "depth_target_bottom_parameter", + "depth_axis_direction", + "depth_open_point", + "depth_current_bottom_point", + "depth_target_bottom_point", + "depth_tool_start_point", + "fillet_status", + "fillet_risk", + "fillet_warnings", + "fillet_blockers", + "fillet_note", + "chamfer_status", + "chamfer_risk", + "chamfer_warnings", + "chamfer_blockers", + "chamfer_note", + "translate_status", + "translate_risk", + "translate_warnings", + "translate_blockers", + "translate_note", + "part_solid_count", + "rotate_status", + "rotate_risk", + "rotate_warnings", + "rotate_blockers", + "rotate_note", ], ), ] @@ -220,6 +371,8 @@ INFO_LABELS = { "parent_id": "父级 ID", "depth": "层级深度", "feature_mode": "特征模式说明", + "feature_type": "特征类型", + "feature_source_face_id": "特征来源 Face", "parts": "零件数", "solids": "Solid 数", "faces": "Face 数", @@ -229,12 +382,30 @@ INFO_LABELS = { "orientation": "拓扑方向", "surface": "曲面类型", "curve": "曲线类型", + "adjacent_face_ids": "相邻 Face", + "adjacent_face_count": "相邻 Face 数", "volume": "体积", "surface_area": "表面积", "area": "面积", "length": "长度", + "edge_length": "边长", + "current_length": "当前边长", + "target_length": "目标边长", + "delta_length": "边长变化量", + "length_change_ratio": "边长变化比例", "radius": "半径", "diameter": "直径", + "target_radius": "目标圆角半径", + "radius_to_length_ratio": "半径/边长比例", + "target_distance": "目标倒角距离", + "distance_to_length_ratio": "倒角距离/边长比例", + "translation_distance": "平移距离", + "rotation_angle_degrees": "旋转角度", + "current_diameter": "当前直径", + "target_diameter": "目标直径", + "delta_diameter": "直径变化量", + "diameter_delta_ratio": "直径变化比例", + "target_to_height_ratio": "目标直径/估算高度", "major_radius": "主半径", "minor_radius": "小半径", "reference_radius": "参考半径", @@ -242,6 +413,10 @@ INFO_LABELS = { "angular_span": "角度跨度", "height_estimate": "估算高度", "hole_depth_estimate": "孔/槽深度估算", + "current_depth": "当前深度", + "target_depth": "目标深度", + "delta_depth": "深度变化量", + "depth_delta_ratio": "深度变化比例", "is_full_cylinder": "接近完整圆柱", "bbox_diagonal": "包围盒对角线", "pick_position": "拾取点", @@ -255,6 +430,12 @@ INFO_LABELS = { "bbox_size": "包围盒尺寸", "start_point": "起点", "end_point": "终点", + "translation_vector": "平移向量", + "rotation_center": "旋转中心", + "end_face_id": "端面 Face", + "end_face_label": "端面位置", + "end_face_plane_distance": "端面匹配距离", + "push_pull_distance": "端面推拉距离", "normal": "几何法向", "oriented_normal": "拓扑修正法向", "plane_origin": "平面原点", @@ -262,12 +443,21 @@ INFO_LABELS = { "axis": "轴方向", "direction": "方向", "line_origin": "直线原点", + "rotation_axis": "旋转轴", + "end_face_outward_direction": "端面向外方向", + "desired_movement_vector": "目标移动向量", "push_pull_outward_direction": "推拉向外方向", "push_pull_inward_direction": "推拉向内方向", "push_pull_plus_side": "原始法向侧", "push_pull_minus_side": "反向法向侧", "push_pull_confidence": "推拉方向置信度", "push_pull_note": "推拉方向说明", + "push_pull_status": "推拉状态", + "push_pull_risk": "推拉风险", + "push_pull_message": "推拉说明", + "push_pull_scope_face_ids": "推拉共面区域 Face", + "push_pull_scope_face_count": "推拉共面区域 Face 数", + "push_pull_scope_note": "推拉共面区域说明", "u_range": "U 参数范围", "v_range": "V 参数范围", "first_parameter": "起始参数", @@ -289,12 +479,60 @@ INFO_LABELS = { "end_sample_offset": "端部采样偏移", "end_sample_note": "端部采样说明", "note": "备注", + "feature_face_ids": "特征 Face", + "feature_side_face_ids": "特征侧壁 Face", + "feature_end_face_ids": "特征端面 Face", + "feature_bottom_face_ids": "疑似底面 Face", + "feature_opening_face_ids": "开口端相邻 Face", + "feature_start_end_face_ids": "起点端 Face", + "feature_end_end_face_ids": "终点端 Face", + "feature_highlight_face_ids": "特征高亮 Face", + "feature_adjacent_face_ids": "相邻 Face", + "feature_boundary_edge_ids": "特征边界 Edge", + "feature_bottom_confidence": "底面判断置信度", + "feature_bottom_detection": "底面识别来源", + "feature_bottom_note": "底面判断说明", + "feature_slot_face_ids": "槽圆柱 Face", + "feature_slot_boundary_face_ids": "槽边界相邻 Face", + "slot_kind": "槽类型", + "slot_status": "槽识别状态", + "slot_angular_span": "槽圆弧角度", + "slot_open_angle": "槽开口角度", + "slot_chord_width_estimate": "槽宽估算", + "slot_arc_length_estimate": "槽圆弧长度估算", + "slot_sagitta_depth_estimate": "槽深估算", + "slot_note": "槽识别说明", + "feature_existing_fillet_face_ids": "已有圆角 Face", + "feature_existing_fillet_support_face_ids": "已有圆角支撑 Face", + "existing_fillet_kind": "已有圆角类型", + "existing_fillet_status": "已有圆角识别状态", + "existing_fillet_radius_estimate": "已有圆角半径估算", + "existing_fillet_angular_span": "已有圆角圆弧角度", + "existing_fillet_arc_length_estimate": "已有圆角圆弧长度估算", + "existing_fillet_note": "已有圆角识别说明", + "feature_edit_actions": "当前可用操作", "resize_status": "切削状态", "resize_mode": "调整模式", "resize_risk": "切削风险", "resize_warnings": "切削警告", "resize_blockers": "切削阻止原因", "resize_note": "切削说明", + "boss_resize_status": "凸台调整状态", + "boss_resize_risk": "凸台调整风险", + "boss_resize_warnings": "凸台调整警告", + "boss_resize_blockers": "凸台调整阻止原因", + "boss_resize_note": "凸台调整说明", + "suppress_status": "封堵状态", + "suppress_risk": "封堵风险", + "suppress_warnings": "封堵警告", + "suppress_blockers": "封堵阻止原因", + "suppress_note": "封堵说明", + "depth_status": "孔深状态", + "depth_mode": "孔深调整模式", + "depth_risk": "孔深风险", + "depth_warnings": "孔深警告", + "depth_blockers": "孔深阻止原因", + "depth_note": "孔深说明", "cutter_strategy": "Cutter 策略", "cutter_height": "Cutter 高度", "cutter_margin": "Cutter 余量", @@ -305,6 +543,10 @@ INFO_LABELS = { "cutter_end_parameter": "Cutter 结束参数", "cutter_axis_direction": "Cutter 轴方向", "cutter_start_point": "Cutter 起点", + "cutter_bottom_protection": "Cutter 底面保护", + "cutter_protected_bottom_face_ids": "Cutter 保护底面 Face", + "cutter_opening_face_ids": "Cutter 开口端 Face", + "cutter_bottom_note": "Cutter 底面保护说明", "cutter_note": "Cutter 说明", "fill_strategy": "补料策略", "fill_height": "补料高度", @@ -312,14 +554,70 @@ INFO_LABELS = { "fill_radius_overlap": "补料重叠量", "fill_start_point": "补料起点", "fill_note": "补料说明", + "boss_tool_strategy": "凸台工具策略", + "boss_tool_note": "凸台工具说明", + "boss_tool_height": "凸台工具高度", + "boss_tool_radius": "凸台目标半径", + "boss_tool_old_radius": "凸台原半径", + "boss_tool_outer_radius": "凸台环形 cutter 外半径", + "boss_tool_inner_radius": "凸台环形 cutter 内半径", + "boss_tool_axial_margin": "凸台工具轴向余量", + "boss_tool_radial_overlap": "凸台工具径向重叠", + "boss_tool_start_parameter": "凸台工具起始参数", + "boss_tool_end_parameter": "凸台工具结束参数", + "boss_tool_axis_point": "凸台工具轴线点", + "boss_tool_axis_direction": "凸台工具轴线方向", + "boss_tool_start_point": "凸台工具起点", + "depth_tool_strategy": "孔深工具策略", + "depth_tool_role": "孔深工具类型", + "depth_tool_note": "孔深工具说明", + "depth_tool_height": "孔深工具高度", + "depth_tool_radius": "孔深工具半径", + "depth_tool_radius_overlap": "孔深工具半径重叠", + "depth_tool_start_parameter": "孔深工具起始参数", + "depth_tool_end_parameter": "孔深工具结束参数", + "depth_open_parameter": "孔开口参数", + "depth_bottom_parameter": "当前底面参数", + "depth_nominal_bottom_parameter": "圆柱参数底面", + "depth_bottom_parameter_source": "底面参数来源", + "depth_current_depth": "当前几何深度", + "depth_current_depth_source": "当前深度来源", + "depth_target_bottom_parameter": "目标底面参数", + "depth_axis_direction": "孔深方向", + "depth_open_point": "孔开口点", + "depth_current_bottom_point": "当前底面点", + "depth_target_bottom_point": "目标底面点", + "depth_tool_start_point": "孔深工具起点", + "fillet_status": "圆角状态", + "fillet_risk": "圆角风险", + "fillet_warnings": "圆角警告", + "fillet_blockers": "圆角阻止原因", + "fillet_note": "圆角说明", + "chamfer_status": "倒角状态", + "chamfer_risk": "倒角风险", + "chamfer_warnings": "倒角警告", + "chamfer_blockers": "倒角阻止原因", + "chamfer_note": "倒角说明", + "translate_status": "平移状态", + "translate_risk": "平移风险", + "translate_warnings": "平移警告", + "translate_blockers": "平移阻止原因", + "translate_note": "平移说明", + "part_solid_count": "Part 内 Solid 数", + "rotate_status": "旋转状态", + "rotate_risk": "旋转风险", + "rotate_warnings": "旋转警告", + "rotate_blockers": "旋转阻止原因", + "rotate_note": "旋转说明", } PART_TREE_KIND_ROLE = Qt.UserRole PART_TREE_ID_ROLE = Qt.UserRole + 1 PART_TREE_PART_ID_ROLE = Qt.UserRole + 2 -EDITABLE_FACE_ROLE = Qt.UserRole +EDITABLE_TARGET_ID_ROLE = Qt.UserRole EDITABLE_ACTION_ROLE = Qt.UserRole + 1 +EDITABLE_TARGET_KIND_ROLE = Qt.UserRole + 2 class StepEditorWindow(QMainWindow): @@ -341,14 +639,23 @@ class StepEditorWindow(QMainWindow): self.edge_actor = None self.highlight_actor = None self.edge_highlight_actor = None + self.hover_face_actor = None + self.hover_edge_actor = None + self.hover_signature: tuple[str, int] | None = None + self.pending_hover_position: tuple[int, int] | None = None self.pick_marker_actor = None self.edit_preview_actor = None + self.edit_preview_actors: list[object] = [] self.edit_preview_timer: QTimer | None = None + self.hover_timer = QTimer(self) + self.hover_timer.setSingleShot(True) + self.hover_timer.timeout.connect(self._update_hover_target) self.edit_preview_phase = 0.0 self.edit_preview_base_opacity = 0.35 self.diff_actors: list[object] = [] self.model_polydata = None self.edge_polydata = None + self.scene_isolated = False self.undo_stack: list[dict[int, object]] = [] self.redo_stack: list[dict[int, object]] = [] self.operation_history: list[OperationRecord] = [] @@ -362,6 +669,8 @@ class StepEditorWindow(QMainWindow): self.edit_worker: EditWorker | None = None self.edit_progress: QProgressDialog | None = None self.pending_edit_context: dict[str, object] | None = None + self.main_content_enabled_before_edit = True + self.last_id_kind = "Face" self._build_ui() self._build_vtk() @@ -384,14 +693,153 @@ class StepEditorWindow(QMainWindow): panel.setFixedWidth(420) panel_layout = QVBoxLayout(panel) panel_layout.setContentsMargins(0, 0, 0, 0) - panel_layout.setSpacing(8) + panel_layout.setSpacing(10) panel_scroll.setWidget(panel) + panel_scroll.setStyleSheet( + """ + QScrollArea { + background: #f4f7fb; + border: none; + } + QScrollArea > QWidget > QWidget { + background: #f4f7fb; + } + QGroupBox { + background: #ffffff; + border: 2px solid #6b7cff; + border-radius: 8px; + color: #1f2937; + margin-top: 12px; + padding: 12px 10px 10px 10px; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 12px; + padding: 0 6px; + color: #172033; + background: #f4f7fb; + font-weight: 700; + } + QGroupBox#fileSection { + border-color: #3478f6; + } + QGroupBox#treeSection { + border-color: #0f9f8f; + } + QGroupBox#modeSection, + QGroupBox#idSection { + border-color: #6b5cff; + } + QGroupBox#viewSection { + border-color: #0e9bd8; + } + QGroupBox#exportSection { + border-color: #1f9d55; + } + QGroupBox#editSection { + border-color: #d97706; + } + QGroupBox#editableSection, + QGroupBox#candidateSection { + border-color: #b45309; + } + QGroupBox#historySection { + border-color: #be3b6b; + } + QGroupBox#infoSection { + border-color: #64748b; + } + QLabel { + color: #2f3846; + } + QLineEdit, + QComboBox { + background: #ffffff; + border: 1px solid #c8d2df; + border-radius: 5px; + color: #172033; + min-height: 24px; + padding: 4px 6px; + } + QLineEdit:focus, + QComboBox:focus { + border: 1px solid #3478f6; + } + QLineEdit#idModeDisplay { + background: #eef2ff; + border-color: #93a5e8; + color: #2d3a8c; + font-weight: 700; + } + QPushButton { + background: #eef3f8; + border: 1px solid #c8d2df; + border-radius: 6px; + color: #172033; + font-weight: 600; + min-height: 24px; + padding: 5px 8px; + } + QPushButton:hover { + background: #e3edf9; + border-color: #8fb0dc; + } + QPushButton:pressed { + background: #d7e6f6; + } + QPushButton:disabled { + background: #eef0f3; + border-color: #dfe3ea; + color: #7a7f86; + } + QTreeWidget, + QTableWidget, + QListWidget, + QPlainTextEdit { + background: #ffffff; + border: 1px solid #d8e0eb; + border-radius: 6px; + color: #172033; + selection-background-color: #dceafe; + selection-color: #0f172a; + } + QHeaderView::section { + background: #edf2f7; + border: 0; + border-bottom: 1px solid #d8e0eb; + color: #334155; + font-weight: 700; + padding: 4px 6px; + } + QTabWidget::pane { + border: 1px solid #d8e0eb; + border-radius: 6px; + top: -1px; + } + QTabBar::tab { + background: #e9eef6; + border: 1px solid #d8e0eb; + border-bottom: none; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + color: #334155; + padding: 5px 10px; + } + QTabBar::tab:selected { + background: #ffffff; + color: #172033; + font-weight: 700; + } + """ + ) - panel_layout.addWidget(QLabel("STEP 文件")) + file_box = QGroupBox("STEP 文件") + file_box.setObjectName("fileSection") + file_layout = QVBoxLayout(file_box) self.path_label = QLabel("") self.path_label.setTextInteractionFlags(Qt.TextSelectableByMouse) self.path_label.setWordWrap(True) - panel_layout.addWidget(self.path_label) + file_layout.addWidget(self.path_label) file_buttons = QHBoxLayout() open_button = QPushButton("打开") @@ -400,88 +848,218 @@ class StepEditorWindow(QMainWindow): reload_button.clicked.connect(self.reload_step) file_buttons.addWidget(open_button) file_buttons.addWidget(reload_button) - panel_layout.addLayout(file_buttons) + file_layout.addLayout(file_buttons) + panel_layout.addWidget(file_box) - panel_layout.addWidget(QLabel("零件 / 装配树")) + tree_box = QGroupBox("模型结构树") + tree_box.setObjectName("treeSection") + tree_layout = QVBoxLayout(tree_box) self.part_tree = QTreeWidget() - self.part_tree.setHeaderLabels(["名称", "类型 / 数量"]) + self.part_tree.setHeaderLabels(["对象", "内容"]) self.part_tree.setMinimumHeight(180) self.part_tree.currentItemChanged.connect(self.on_part_tree_select) - panel_layout.addWidget(self.part_tree) + tree_layout.addWidget(self.part_tree) + panel_layout.addWidget(tree_box) - panel_layout.addWidget(QLabel("选择模式")) - self.mode_combo = QComboBox() + mode_box = QGroupBox("鼠标选择模式") + mode_box.setObjectName("modeSection") + mode_layout = QVBoxLayout(mode_box) + self.mode_combo = NoWheelComboBox() self.mode_combo.addItems(["Part", "Solid", "Face", "Edge", "Feature"]) self.mode_combo.setCurrentText("Face") - panel_layout.addWidget(self.mode_combo) + self.mode_combo.currentTextChanged.connect(self._on_mode_changed) + mode_layout.addWidget(self.mode_combo) + panel_layout.addWidget(mode_box) select_box = QGroupBox("按 ID 选择") + select_box.setObjectName("idSection") select_layout = QGridLayout(select_box) - self.id_kind_combo = QComboBox() - self.id_kind_combo.addItems(["Part", "Solid", "Face", "Edge"]) self.id_input = QLineEdit("") + self.id_input.setPlaceholderText("输入 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) + self.id_mode_display.setToolTip("当前按这个鼠标选择模式解释输入的 ID。") select_button = QPushButton("选择") - select_button.clicked.connect(self.select_by_id) - self.id_input.returnPressed.connect(self.select_by_id) - select_layout.addWidget(self.id_kind_combo, 0, 0) + select_button.clicked.connect(lambda _checked=False: self.select_by_id(self.mode_combo.currentText())) + 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(select_button, 0, 2) + select_layout.addWidget(self.id_mode_display, 0, 2) + select_layout.addWidget(select_button, 0, 3) panel_layout.addWidget(select_box) + view_box = QGroupBox("显示") + view_box.setObjectName("viewSection") + view_layout = QHBoxLayout(view_box) + isolate_button = QPushButton("只显示选中") + isolate_button.clicked.connect(self.isolate_selected) + fit_button = QPushButton("对准选中") + fit_button.clicked.connect(self.fit_selected) + show_all_button = QPushButton("显示全部") + show_all_button.clicked.connect(self.show_all_geometry) + view_layout.addWidget(isolate_button) + view_layout.addWidget(fit_button) + view_layout.addWidget(show_all_button) + panel_layout.addWidget(view_box) + export_box = QGroupBox("导出") + export_box.setObjectName("exportSection") export_layout = QVBoxLayout(export_box) - export_all_button = QPushButton("导出当前完整 STEP") - export_all_button.clicked.connect(self.export_all) - export_part_button = QPushButton("导出选中零件") - export_part_button.clicked.connect(self.export_selected_part) - export_solid_button = QPushButton("导出选中 solid") - export_solid_button.clicked.connect(self.export_selected_solid) - export_face_button = QPushButton("导出选中 face") - export_face_button.clicked.connect(self.export_selected_face) - export_layout.addWidget(export_all_button) - export_layout.addWidget(export_part_button) - export_layout.addWidget(export_solid_button) - export_layout.addWidget(export_face_button) + self.export_all_button = QPushButton("导出当前完整 STEP") + self.export_all_button.clicked.connect(self.export_all) + self.export_part_button = QPushButton("导出选中零件") + self.export_part_button.clicked.connect(self.export_selected_part) + self.export_solid_button = QPushButton("导出选中 solid") + self.export_solid_button.clicked.connect(self.export_selected_solid) + self.export_face_button = QPushButton("导出选中 face") + self.export_face_button.clicked.connect(self.export_selected_face) + self.export_feature_button = QPushButton("导出选中特征区域") + self.export_feature_button.clicked.connect(self.export_selected_feature) + self.export_edge_button = QPushButton("导出选中 edge") + self.export_edge_button.clicked.connect(self.export_selected_edge) + self.export_check_button = QPushButton("检查导出质量") + self.export_check_button.clicked.connect(self.check_export_quality) + 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) panel_layout.addWidget(export_box) edit_box = QGroupBox("实验性编辑") + edit_box.setObjectName("editSection") edit_layout = QGridLayout(edit_box) edit_layout.addWidget(QLabel("面偏移"), 0, 0) self.offset_input = QLineEdit("5.0") edit_layout.addWidget(self.offset_input, 0, 1) - push_button = QPushButton("推拉平面") - push_button.clicked.connect(self.push_pull_face) - edit_layout.addWidget(push_button, 1, 0, 1, 2) + self.push_button = QPushButton("推拉平面") + self.push_button.clicked.connect(self.push_pull_face) + edit_layout.addWidget(self.push_button, 1, 0, 1, 2) edit_layout.addWidget(QLabel("孔直径"), 2, 0) self.hole_diameter_input = QLineEdit("") edit_layout.addWidget(self.hole_diameter_input, 2, 1) - resize_button = QPushButton("调整圆柱孔径") - resize_button.clicked.connect(self.resize_hole) - edit_layout.addWidget(resize_button, 3, 0, 1, 2) + self.resize_button = QPushButton("调整圆柱孔径") + self.resize_button.clicked.connect(self.resize_hole) + edit_layout.addWidget(self.resize_button, 3, 0, 1, 2) - cylinders_button = QPushButton("列出圆柱候选") - cylinders_button.clicked.connect(self.list_cylinders) - edit_layout.addWidget(cylinders_button, 4, 0, 1, 2) + edit_layout.addWidget(QLabel("凸台直径"), 4, 0) + self.boss_diameter_input = QLineEdit("") + edit_layout.addWidget(self.boss_diameter_input, 4, 1) + self.resize_boss_button = QPushButton("调整圆柱凸台直径") + self.resize_boss_button.clicked.connect(self.resize_boss) + edit_layout.addWidget(self.resize_boss_button, 5, 0, 1, 2) - undo_button = QPushButton("撤销") - undo_button.clicked.connect(self.undo_edit) - redo_button = QPushButton("重做") - redo_button.clicked.connect(self.redo_edit) - edit_layout.addWidget(undo_button, 5, 0) - edit_layout.addWidget(redo_button, 5, 1) + self.suppress_button = QPushButton("封堵圆柱孔") + self.suppress_button.clicked.connect(self.suppress_hole) + edit_layout.addWidget(self.suppress_button, 6, 0, 1, 2) + + edit_layout.addWidget(QLabel("孔深度"), 7, 0) + self.hole_depth_input = QLineEdit("") + edit_layout.addWidget(self.hole_depth_input, 7, 1) + self.resize_depth_button = QPushButton("调整盲孔深度") + self.resize_depth_button.clicked.connect(self.resize_hole_depth) + edit_layout.addWidget(self.resize_depth_button, 8, 0, 1, 2) + + edit_layout.addWidget(QLabel("圆角半径"), 9, 0) + self.edge_fillet_radius_input = QLineEdit("") + edit_layout.addWidget(self.edge_fillet_radius_input, 9, 1) + self.fillet_edge_button = QPushButton("给边添加圆角") + self.fillet_edge_button.clicked.connect(self.fillet_edge) + edit_layout.addWidget(self.fillet_edge_button, 10, 0, 1, 2) + + self.resize_existing_fillet_button = QPushButton("修改已有圆角半径") + self.resize_existing_fillet_button.clicked.connect(self.resize_existing_fillet) + edit_layout.addWidget(self.resize_existing_fillet_button, 11, 0, 1, 2) + + edit_layout.addWidget(QLabel("倒角距离"), 12, 0) + self.edge_chamfer_distance_input = QLineEdit("") + edit_layout.addWidget(self.edge_chamfer_distance_input, 12, 1) + self.chamfer_edge_button = QPushButton("给边添加倒角") + self.chamfer_edge_button.clicked.connect(self.chamfer_edge) + edit_layout.addWidget(self.chamfer_edge_button, 13, 0, 1, 2) + + edit_layout.addWidget(QLabel("边目标长度"), 14, 0) + self.edge_target_length_input = QLineEdit("") + edit_layout.addWidget(self.edge_target_length_input, 14, 1) + self.resize_edge_length_button = QPushButton("调整直线边长度") + self.resize_edge_length_button.clicked.connect(self.resize_edge_length) + edit_layout.addWidget(self.resize_edge_length_button, 15, 0, 1, 2) + + edit_layout.addWidget(QLabel("平移 X/Y/Z"), 16, 0) + translate_inputs = QWidget() + translate_layout = QHBoxLayout(translate_inputs) + translate_layout.setContentsMargins(0, 0, 0, 0) + self.translate_x_input = QLineEdit("0") + self.translate_y_input = QLineEdit("0") + self.translate_z_input = QLineEdit("0") + self.translate_x_input.setPlaceholderText("X") + self.translate_y_input.setPlaceholderText("Y") + self.translate_z_input.setPlaceholderText("Z") + translate_layout.addWidget(self.translate_x_input) + translate_layout.addWidget(self.translate_y_input) + translate_layout.addWidget(self.translate_z_input) + edit_layout.addWidget(translate_inputs, 16, 1) + self.translate_part_button = QPushButton("平移选中零件") + self.translate_part_button.clicked.connect(self.translate_selected_part) + self.translate_solid_button = QPushButton("平移选中 solid") + self.translate_solid_button.clicked.connect(self.translate_selected_solid) + edit_layout.addWidget(self.translate_part_button, 17, 0, 1, 2) + edit_layout.addWidget(self.translate_solid_button, 18, 0, 1, 2) + + edit_layout.addWidget(QLabel("旋转轴/角度"), 19, 0) + rotate_inputs = QWidget() + rotate_layout = QHBoxLayout(rotate_inputs) + rotate_layout.setContentsMargins(0, 0, 0, 0) + self.rotate_axis_combo = QComboBox() + self.rotate_axis_combo.addItems(["X", "Y", "Z"]) + self.rotate_axis_combo.setCurrentText("Z") + self.rotate_angle_input = QLineEdit("90") + self.rotate_angle_input.setPlaceholderText("度") + rotate_layout.addWidget(self.rotate_axis_combo) + rotate_layout.addWidget(self.rotate_angle_input) + edit_layout.addWidget(rotate_inputs, 19, 1) + self.rotate_part_button = QPushButton("旋转选中零件") + self.rotate_part_button.clicked.connect(self.rotate_selected_part) + self.rotate_solid_button = QPushButton("旋转选中 solid") + self.rotate_solid_button.clicked.connect(self.rotate_selected_solid) + edit_layout.addWidget(self.rotate_part_button, 20, 0, 1, 2) + edit_layout.addWidget(self.rotate_solid_button, 21, 0, 1, 2) + + self.cylinders_button = QPushButton("列出圆柱候选") + self.cylinders_button.clicked.connect(self.list_cylinders) + edit_layout.addWidget(self.cylinders_button, 22, 0, 1, 2) + + self.undo_button = QPushButton("撤销") + self.undo_button.clicked.connect(self.undo_edit) + self.redo_button = QPushButton("重做") + self.redo_button.clicked.connect(self.redo_edit) + edit_layout.addWidget(self.undo_button, 23, 0) + edit_layout.addWidget(self.redo_button, 23, 1) panel_layout.addWidget(edit_box) editable_box = QGroupBox("第一版可编辑对象") + editable_box.setObjectName("editableSection") editable_layout = QVBoxLayout(editable_box) editable_button_row = QHBoxLayout() editable_refresh_button = QPushButton("扫描可编辑对象") editable_refresh_button.clicked.connect(lambda _checked=False: self.refresh_editable_candidates(show_info=True)) + editable_deep_scan_button = QPushButton("深度扫描") + editable_deep_scan_button.clicked.connect( + lambda _checked=False: self.refresh_editable_candidates(show_info=True, deep_scan=True) + ) editable_button_row.addWidget(editable_refresh_button) + editable_button_row.addWidget(editable_deep_scan_button) editable_layout.addLayout(editable_button_row) self.editable_table = QTableWidget(0, 8) self.editable_table.setHorizontalHeaderLabels( - ["操作", "face", "对象", "当前值", "状态", "风险", "置信度", "说明"] + ["操作", "ID", "对象", "当前值", "状态", "风险", "置信度", "说明"] ) self.editable_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.editable_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) @@ -492,10 +1070,11 @@ class StepEditorWindow(QMainWindow): panel_layout.addWidget(editable_box) candidate_box = QGroupBox("圆柱候选") + candidate_box.setObjectName("candidateSection") candidate_layout = QVBoxLayout(candidate_box) filter_layout = QHBoxLayout() filter_layout.addWidget(QLabel("类型")) - self.candidate_filter_combo = QComboBox() + self.candidate_filter_combo = NoWheelComboBox() self.candidate_filter_combo.addItems( [ "All", @@ -523,6 +1102,7 @@ class StepEditorWindow(QMainWindow): panel_layout.addWidget(candidate_box) history_box = QGroupBox("操作历史") + history_box.setObjectName("historySection") history_layout = QVBoxLayout(history_box) self.history_list = QListWidget() self.history_list.setMinimumHeight(110) @@ -538,7 +1118,9 @@ class StepEditorWindow(QMainWindow): history_layout.addLayout(history_buttons) panel_layout.addWidget(history_box) - panel_layout.addWidget(QLabel("选中对象信息")) + info_box = QGroupBox("选中对象信息") + info_box.setObjectName("infoSection") + info_layout = QVBoxLayout(info_box) info_buttons = QHBoxLayout() copy_id_button = QPushButton("复制 ID") copy_id_button.clicked.connect(self.copy_selected_id) @@ -549,7 +1131,7 @@ class StepEditorWindow(QMainWindow): info_buttons.addWidget(copy_id_button) info_buttons.addWidget(copy_pick_button) info_buttons.addWidget(copy_info_button) - panel_layout.addLayout(info_buttons) + info_layout.addLayout(info_buttons) self.info_tabs = QTabWidget() self.info_tree = QTreeWidget() @@ -561,7 +1143,10 @@ class StepEditorWindow(QMainWindow): self.info_text.setReadOnly(True) self.info_tabs.addTab(self.info_tree, "属性表") self.info_tabs.addTab(self.info_text, "原始文本") - panel_layout.addWidget(self.info_tabs, stretch=1) + info_layout.addWidget(self.info_tabs) + panel_layout.addWidget(info_box, stretch=1) + + self._update_action_states() self.vtk_widget = QVTKRenderWindowInteractor(central) root_layout.addWidget(self.vtk_widget, stretch=1) @@ -579,6 +1164,7 @@ class StepEditorWindow(QMainWindow): self.picker = vtk.vtkCellPicker() self.picker.SetTolerance(0.003) self.interactor.AddObserver("LeftButtonPressEvent", self.on_left_click) + self.interactor.AddObserver("MouseMoveEvent", self.on_mouse_move) light = vtk.vtkLight() light.SetLightTypeToSceneLight() @@ -596,21 +1182,34 @@ class StepEditorWindow(QMainWindow): super().closeEvent(event) def load_step(self, path: str | Path) -> None: + new_path = Path(path) + self.statusBar().showMessage(f"正在加载 {new_path.name}...") + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + QApplication.processEvents() + load_error: Exception | None = None try: - self.model = StepModel.load(path) + new_model = StepModel.load(new_path) + stats = new_model.stats() + model_polydata = new_model.build_face_polydata() + edge_polydata = new_model.build_edge_polydata() except Exception as exc: - QMessageBox.critical(self, "加载失败", str(exc)) + load_error = exc + finally: + QApplication.restoreOverrideCursor() + if load_error is not None: + QMessageBox.critical(self, "加载失败", str(load_error)) + self.statusBar().showMessage("STEP 加载失败,当前模型保持不变") return - self.step_path = Path(path) + self.model = new_model + self.step_path = new_path self._clear_history() self.path_label.setText(str(self.step_path)) self._populate_part_tree() self._reset_selection() - self._rebuild_scene(reset_camera=True) + self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=True) self._clear_editable_candidates() self._clear_cylinder_candidates() - stats = self.model.stats() self.set_info( { "file": str(self.step_path), @@ -621,6 +1220,7 @@ class StepEditorWindow(QMainWindow): "vertices": stats.vertices, } ) + self._update_action_states() self.statusBar().showMessage(f"已加载 {self.step_path.name}") def open_step(self) -> None: @@ -659,6 +1259,8 @@ class StepEditorWindow(QMainWindow): self.history_list.blockSignals(was_blocked) def on_history_row_changed(self, row: int) -> None: + if self._edit_busy("编辑计算中,暂时不能查看历史记录详情。"): + return if 0 <= row < len(self.operation_history): record = self.operation_history[row] locate_message = self._locate_operation_record(record) @@ -673,6 +1275,8 @@ class StepEditorWindow(QMainWindow): def export_diff_report(self) -> None: if self.model is None: return + if self._edit_busy("编辑计算中,暂时不能导出差异报告。"): + return row = self.history_list.currentRow() if row < 0 or row >= len(self.operation_history): QMessageBox.information(self, "未选择历史记录", "请先选择一条操作历史。") @@ -705,9 +1309,16 @@ class StepEditorWindow(QMainWindow): return inserted: dict[int, QTreeWidgetItem] = {} for part in self.model.parts: - item = QTreeWidgetItem([part.name, self._part_tree_part_detail(part.id, part.kind)]) + item = QTreeWidgetItem( + [ + self._part_tree_part_name(part.id, part.name, part.kind), + self._part_tree_part_detail(part.id, part.kind), + ] + ) item.setData(0, PART_TREE_KIND_ROLE, part.kind) item.setData(0, PART_TREE_ID_ROLE, part.id) + item.setToolTip(0, self._part_tree_part_tooltip(part.id)) + item.setToolTip(1, self._part_tree_part_tooltip(part.id)) parent = inserted.get(part.parent_id) if parent is None: self.part_tree.addTopLevelItem(item) @@ -719,10 +1330,12 @@ class StepEditorWindow(QMainWindow): parent = inserted.get(part_id) if parent is None: continue - item = QTreeWidgetItem([f"Solid {solid_id}", self._part_tree_solid_detail(solid_id)]) + item = QTreeWidgetItem([f"实体 {solid_id}", self._part_tree_solid_detail(solid_id)]) item.setData(0, PART_TREE_KIND_ROLE, "solid") item.setData(0, PART_TREE_ID_ROLE, solid_id) item.setData(0, PART_TREE_PART_ID_ROLE, part_id) + item.setToolTip(0, f"Solid ID: {solid_id}\n所属零件 ID: {part_id}") + item.setToolTip(1, f"Solid ID: {solid_id}\n所属零件 ID: {part_id}") parent.addChild(item) self.part_tree.expandAll() @@ -731,23 +1344,52 @@ class StepEditorWindow(QMainWindow): finally: self.part_tree.blockSignals(was_blocked) + def _part_tree_part_name(self, part_id: int, name: str, kind: str) -> str: + kind_label = _part_tree_kind_label(kind) + clean_name = str(name).strip() + if clean_name: + return f"{kind_label} {part_id}:{clean_name}" + return f"{kind_label} {part_id}" + def _part_tree_part_detail(self, part_id: int, kind: str) -> str: if self.model is None: - return kind + return _part_tree_kind_label(kind) try: info = self.model.part_info(part_id) except Exception: - return kind - return f"{kind} | solids {info.get('solids', 0)} | faces {info.get('faces', 0)}" + return _part_tree_kind_label(kind) + return ( + f"{_part_tree_kind_label(kind)} | " + f"实体 {info.get('solids', 0)} 个 | " + f"面 {info.get('faces', 0)} 个 | " + f"边 {info.get('edges', 0)} 条" + ) + + def _part_tree_part_tooltip(self, part_id: int) -> str: + if self.model is None: + return f"Part ID: {part_id}" + try: + info = self.model.part_info(part_id) + except Exception: + return f"Part ID: {part_id}" + lines = [ + f"ID: {part_id}", + f"类型: {_part_tree_kind_label(str(info.get('kind', '')))}", + f"名称: {info.get('name', '')}", + ] + path = str(info.get("path", "")).strip() + if path: + lines.append(f"路径: {path}") + return "\n".join(lines) def _part_tree_solid_detail(self, solid_id: int) -> str: if self.model is None: - return "solid" + return "实体" try: info = self.model.solid_info(solid_id) except Exception: - return "solid" - return f"solid | faces {info.get('faces', 0)} | edges {info.get('edges', 0)}" + return "实体" + return f"面 {info.get('faces', 0)} 个 | 边 {info.get('edges', 0)} 条" def _rebuild_scene(self, reset_camera: bool = False) -> None: if self.model is None: @@ -756,9 +1398,132 @@ class StepEditorWindow(QMainWindow): edge_polydata = self.model.build_edge_polydata() self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=reset_camera) + def isolate_selected(self) -> None: + if self.model is None: + return + if self._edit_busy("编辑计算中,暂时不能切换显示范围。"): + return + face_ids: list[int] | None = None + edge_ids: list[int] | None = None + part_ids: list[int] | None = None + label = "" + + 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}" + elif self.selected_kind == "solid" and self.selected_solid_id is not None: + face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == self.selected_solid_id] + edge_ids = self.model.edge_ids_for_solid(self.selected_solid_id) + label = f"solid {self.selected_solid_id}" + elif self.selected_kind == "feature" and self.selected_face_id is not None: + info = self.model.feature_info(self.selected_face_id) + face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id] + edge_ids = _int_values(info.get("feature_boundary_edge_ids")) + label = f"feature face {self.selected_face_id}" + elif self.selected_kind == "face" and self.selected_face_id is not None: + face_ids = [self.selected_face_id] + edge_ids = _int_values(self.model.face_info(self.selected_face_id).get("feature_boundary_edge_ids")) + label = f"face {self.selected_face_id}" + elif self.selected_kind == "edge" and self.selected_edge_id is not None: + info = self.model.edge_info(self.selected_edge_id) + face_ids = _int_values(info.get("adjacent_face_ids")) + edge_ids = [self.selected_edge_id] + label = f"edge {self.selected_edge_id}" + else: + QMessageBox.information(self, "未选择对象", "请先选择 part、solid、face、edge 或 feature。") + return + + model_polydata = self.model.build_face_polydata(face_ids=face_ids, part_ids=part_ids) + edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids, part_ids=part_ids) + self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=True) + self.scene_isolated = True + self._refresh_selection_highlight() + self.statusBar().showMessage(f"已只显示选中对象:{label}") + + def show_all_geometry(self) -> None: + if self.model is None: + return + if self._edit_busy("编辑计算中,暂时不能切换显示范围。"): + return + self._rebuild_scene(reset_camera=True) + self._refresh_selection_highlight() + self.statusBar().showMessage("已显示完整模型") + + def fit_selected(self) -> None: + if self.model is None: + return + if self._edit_busy("编辑计算中,暂时不能调整视角。"): + return + bounds = self._selected_focus_bounds() + if bounds is None: + QMessageBox.information(self, "未选择对象", "请先选择 part、solid、face、edge 或 feature。") + return + self._fit_camera_to_bounds(bounds) + self.render_window.Render() + self.statusBar().showMessage("已对准选中对象") + + def _selected_focus_bounds(self) -> tuple[float, float, float, float, float, float] | None: + if self.model is None: + return None + face_polydata = None + edge_polydata = None + + if self.selected_kind == "part" and self.selected_part_id is not None: + face_polydata = self.model.build_face_polydata(part_ids=[self.selected_part_id]) + edge_polydata = self.model.build_edge_polydata(part_ids=[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) + face_polydata = self.model.build_face_polydata(face_ids=face_ids) + edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids) + elif self.selected_kind == "feature" and self.selected_face_id is not None: + info = self.model.feature_info(self.selected_face_id) + face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id] + edge_ids = _int_values(info.get("feature_boundary_edge_ids")) + face_polydata = self.model.build_face_polydata(face_ids=face_ids) + edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids) + elif self.selected_kind == "face" and self.selected_face_id is not None: + face_polydata = self.model.build_face_polydata(face_ids=[self.selected_face_id]) + elif self.selected_kind == "edge" and self.selected_edge_id is not None: + edge_polydata = self.model.build_edge_polydata(edge_ids=[self.selected_edge_id]) + + return _merge_polydata_bounds(face_polydata, edge_polydata) + + def _fit_camera_to_bounds(self, bounds: tuple[float, float, float, float, float, float]) -> None: + x0, x1, y0, y1, z0, z1 = bounds + dx = max(x1 - x0, 0.0) + dy = max(y1 - y0, 0.0) + dz = max(z1 - z0, 0.0) + diagonal = max(math.sqrt(dx * dx + dy * dy + dz * dz), 1.0) + pad = diagonal * 0.18 + padded_bounds = (x0 - pad, x1 + pad, y0 - pad, y1 + pad, z0 - pad, z1 + pad) + try: + self.renderer.ResetCamera(padded_bounds) + except TypeError: + self.renderer.ResetCamera(*padded_bounds) + self.renderer.ResetCameraClippingRange() + + def _refresh_selection_highlight(self) -> None: + if self.model is None: + return + if self.selected_kind == "part" and self.selected_part_id is not None: + self._highlight_faces(part_ids=[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] + self._highlight_faces(face_ids=face_ids) + elif self.selected_kind == "feature" and self.selected_face_id is not None: + info = self.model.feature_info(self.selected_face_id) + face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id] + self._highlight_faces(face_ids=face_ids) + elif self.selected_kind == "face" and self.selected_face_id is not None: + self._highlight_faces(face_ids=[self.selected_face_id]) + elif self.selected_kind == "edge" and self.selected_edge_id is not None: + self._highlight_edge(self.selected_edge_id) + def _rebuild_scene_from_polydata(self, model_polydata, edge_polydata, reset_camera: bool = False) -> None: self.renderer.RemoveAllViewProps() self.renderer.SetBackground(0.11, 0.13, 0.15) + self.scene_isolated = False self.model_polydata = model_polydata mapper = vtk.vtkPolyDataMapper() @@ -782,13 +1547,23 @@ class StepEditorWindow(QMainWindow): self.highlight_actor = None self.edge_highlight_actor = None + self.hover_face_actor = None + self.hover_edge_actor = None + self.hover_signature = None + self.pending_hover_position = None self.pick_marker_actor = None self.edit_preview_actor = None + self.edit_preview_actors = [] self.diff_actors = [] if reset_camera: self.renderer.ResetCamera() self.render_window.Render() + 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) + def on_left_click(self, _obj, _event) -> None: if self.operation_in_progress: self.statusBar().showMessage("编辑计算中,请等待当前操作完成") @@ -797,24 +1572,183 @@ class StepEditorWindow(QMainWindow): return mode = self.mode_combo.currentText() x, y = self.interactor.GetEventPosition() - actor = self.edge_actor if mode == "Edge" else self.model_actor - if actor is None: - return - - self.picker.InitializePickList() - self.picker.PickFromListOn() - self.picker.AddPickList(actor) - picked = self.picker.Pick(x, y, 0, self.renderer) - if not picked or self.picker.GetCellId() < 0: + target = self._pick_selection_target(mode, x, y) + if target is None: self.statusBar().showMessage("未选中对象") return - cell_id = self.picker.GetCellId() - pick_position = _vector_tuple(self.picker.GetPickPosition()) + self._clear_hover(render=False) + self._select_pick_target(target) + + def on_mouse_move(self, _obj, _event) -> None: + if self.operation_in_progress or self.model is None: + self._clear_hover(render=True) + return + x, y = self.interactor.GetEventPosition() + self.pending_hover_position = (int(x), int(y)) + if not self.hover_timer.isActive(): + self.hover_timer.start(35) + + def _update_hover_target(self) -> None: + if self.operation_in_progress or self.model is None or self.pending_hover_position is None: + self._clear_hover(render=True) + return + x, y = self.pending_hover_position + target = self._pick_selection_target(self.mode_combo.currentText(), x, y) + self._show_hover_target(target) + + def _pick_selection_target(self, mode: str, x: int, y: int) -> dict[str, object] | None: + if self.model is None: + return None if mode == "Edge": - self._select_edge_from_cell(cell_id, pick_position=pick_position) + edge_hit = self._pick_edge_cell(x, y) + if edge_hit is not None: + return self._edge_target_from_cell_hit(edge_hit) + face_hit = self._pick_face_cell(x, y) + if face_hit is not None: + return self._edge_target_from_face_hit(face_hit) + return None + + face_hit = self._pick_face_cell(x, y) + if face_hit is not None: + return self._target_from_face_hit(face_hit, mode) + + edge_hit = self._pick_edge_cell(x, y) + if edge_hit is not None: + edge_target = self._edge_target_from_cell_hit(edge_hit) + if edge_target is not None: + return self._target_from_edge_id(int(edge_target["target_id"]), mode, edge_target["pick_position"]) + return None + + def _pick_actor_cell(self, actor, x: int, y: int) -> dict[str, object] | None: + if actor is None: + return None + self.picker.InitializePickList() + self.picker.PickFromListOn() + self.picker.AddPickList(actor) + picked = self.picker.Pick(int(x), int(y), 0, self.renderer) + self.picker.PickFromListOff() + if not picked or self.picker.GetCellId() < 0: + return None + return { + "cell_id": int(self.picker.GetCellId()), + "pick_position": _vector_tuple(self.picker.GetPickPosition()), + } + + def _pick_face_cell(self, x: int, y: int) -> dict[str, object] | None: + hit = self._pick_actor_cell(self.model_actor, x, y) + if hit is None or self.model_polydata is None: + return None + cell_data = self._face_cell_data(int(hit["cell_id"])) + if cell_data is None: + return None + hit.update(cell_data) + return hit + + def _pick_edge_cell(self, x: int, y: int) -> dict[str, object] | None: + hit = self._pick_actor_cell(self.edge_actor, x, y) + if hit is None or self.edge_polydata is None: + return None + edge_id = self._edge_id_from_cell(int(hit["cell_id"])) + if edge_id is None: + return None + hit["edge_id"] = edge_id + return hit + + def _face_cell_data(self, cell_id: int) -> dict[str, int] | None: + if self.model_polydata is None or cell_id < 0: + return None + face_arr = self.model_polydata.GetCellData().GetArray("face_id") + part_arr = self.model_polydata.GetCellData().GetArray("part_id") + solid_arr = self.model_polydata.GetCellData().GetArray("solid_id") + if face_arr is None or part_arr is None or solid_arr is None: + return None + return { + "face_id": int(face_arr.GetValue(cell_id)), + "part_id": int(part_arr.GetValue(cell_id)), + "solid_id": int(solid_arr.GetValue(cell_id)), + } + + def _edge_id_from_cell(self, cell_id: int) -> int | None: + if self.edge_polydata is None or cell_id < 0: + return None + edge_arr = self.edge_polydata.GetCellData().GetArray("edge_id") + if edge_arr is None: + return None + return int(edge_arr.GetValue(cell_id)) + + def _target_from_face_hit(self, hit: dict[str, object], mode: str) -> dict[str, object] | None: + face_id = int(hit["face_id"]) + part_id = int(hit["part_id"]) + solid_id = int(hit["solid_id"]) + pick_position = hit["pick_position"] + if mode == "Part": + if part_id < 0: + return None + return {"kind": "part", "target_id": part_id, "pick_position": pick_position} + if mode == "Solid": + if solid_id < 0: + return None + return {"kind": "solid", "target_id": solid_id, "part_id": part_id, "pick_position": pick_position} + if mode == "Feature": + return {"kind": "feature", "target_id": face_id, "pick_position": pick_position} + return {"kind": "face", "target_id": face_id, "pick_position": pick_position} + + def _edge_target_from_cell_hit(self, hit: dict[str, object]) -> dict[str, object] | None: + edge_id = hit.get("edge_id") + if edge_id is None: + return None + return {"kind": "edge", "target_id": int(edge_id), "pick_position": hit["pick_position"]} + + def _edge_target_from_face_hit(self, hit: dict[str, object]) -> dict[str, object] | None: + if self.model is None: + return None + face_id = int(hit["face_id"]) + pick_position = hit["pick_position"] + edge_ids = self.model.face_boundary_edge_ids(face_id) + edge_id = self.model.nearest_edge_id_to_point(edge_ids, pick_position) + if edge_id is None: + return None + return {"kind": "edge", "target_id": edge_id, "pick_position": pick_position} + + def _target_from_edge_id( + self, + edge_id: int, + mode: str, + pick_position: tuple[float, float, float] | None, + ) -> dict[str, object] | None: + if self.model is None or edge_id < 0 or edge_id >= len(self.model.edges): + return None + info = self.model.edge_info(edge_id) + part_id = int(info.get("part_id", -1)) + solid_id = int(info.get("solid_id", -1)) + adjacent_face_ids = _int_values(info.get("adjacent_face_ids")) + if mode == "Part" and part_id >= 0: + return {"kind": "part", "target_id": part_id, "pick_position": pick_position} + if mode == "Solid" and solid_id >= 0: + return {"kind": "solid", "target_id": solid_id, "part_id": part_id, "pick_position": pick_position} + if mode == "Feature" and adjacent_face_ids: + return {"kind": "feature", "target_id": adjacent_face_ids[0], "pick_position": pick_position} + if mode == "Face" and adjacent_face_ids: + return {"kind": "face", "target_id": adjacent_face_ids[0], "pick_position": pick_position} + if mode == "Edge": + return {"kind": "edge", "target_id": edge_id, "pick_position": pick_position} + return None + + def _select_pick_target(self, target: dict[str, object]) -> None: + kind = str(target["kind"]) + target_id = int(target["target_id"]) + pick_position = target.get("pick_position") + if kind == "part": + self.select_part(target_id, pick_position=pick_position) + elif kind == "solid": + self.select_solid(target_id, int(target["part_id"]), pick_position=pick_position) + elif kind == "feature": + self.select_feature(target_id, pick_position=pick_position) + elif kind == "edge": + self.select_edge(target_id, pick_position=pick_position) else: - self._select_face_cell(cell_id, mode, pick_position=pick_position) + self.select_face(target_id, pick_position=pick_position) def _select_face_cell( self, @@ -836,7 +1770,7 @@ class StepEditorWindow(QMainWindow): elif mode == "Solid": self.select_solid(solid_id, part_id, pick_position=pick_position) elif mode == "Feature": - self.select_face(face_id, feature_mode=True, pick_position=pick_position) + self.select_feature(face_id, pick_position=pick_position) else: self.select_face(face_id, pick_position=pick_position) @@ -852,6 +1786,8 @@ class StepEditorWindow(QMainWindow): 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("编辑计算中,暂时不能切换零件树选择。"): + return if current is None: return node_kind = current.data(0, PART_TREE_KIND_ROLE) @@ -867,6 +1803,8 @@ class StepEditorWindow(QMainWindow): self.select_part(int(target_id)) def on_cylinder_row_clicked(self, row: int, _column: int) -> None: + if self._edit_busy("编辑计算中,暂时不能切换圆柱候选。"): + return item = self.cylinder_table.item(row, 0) if item is None: return @@ -874,26 +1812,61 @@ class StepEditorWindow(QMainWindow): if face_id is None: return self.mode_combo.setCurrentText("Feature") - self.select_face(int(face_id), feature_mode=True) + self.select_feature(int(face_id)) def on_editable_row_clicked(self, row: int, _column: int) -> None: + if self._edit_busy("编辑计算中,暂时不能切换可编辑对象。"): + return item = self.editable_table.item(row, 0) if item is None: return - face_id = item.data(EDITABLE_FACE_ROLE) + target_id = item.data(EDITABLE_TARGET_ID_ROLE) + target_kind = item.data(EDITABLE_TARGET_KIND_ROLE) action = item.data(EDITABLE_ACTION_ROLE) - if face_id is None: + if target_id is None: return if action == "resize_cylinder": self.mode_combo.setCurrentText("Feature") - self.select_face(int(face_id), feature_mode=True) - self.statusBar().showMessage(f"已选择可调整孔径候选 face {face_id}") + self.select_feature(int(target_id)) + self.statusBar().showMessage(f"已选择可调整孔径候选 face {target_id}") + elif action == "resize_boss": + self.mode_combo.setCurrentText("Feature") + self.select_feature(int(target_id)) + self.statusBar().showMessage(f"已选择可调整凸台直径候选 face {target_id}") + elif action == "suppress_cylinder": + self.mode_combo.setCurrentText("Feature") + self.select_feature(int(target_id)) + self.statusBar().showMessage(f"已选择可封堵圆柱孔 face {target_id}") + elif action == "resize_depth": + self.mode_combo.setCurrentText("Feature") + self.select_feature(int(target_id)) + self.statusBar().showMessage(f"已选择可调整盲孔深度候选 face {target_id}") + elif action == "inspect_existing_fillet": + self.mode_combo.setCurrentText("Feature") + self.select_feature(int(target_id)) + self.statusBar().showMessage(f"已选择已有圆角/倒圆候选 face {target_id}") + elif action == "fillet_edge": + self.mode_combo.setCurrentText("Edge") + self.select_edge(int(target_id)) + self.statusBar().showMessage(f"已选择可添加圆角 edge {target_id}") + elif action == "chamfer_edge": + self.mode_combo.setCurrentText("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.select_edge(int(target_id)) + self.statusBar().showMessage(f"已选择可尝试调整长度的直线 edge {target_id}") + elif target_kind == "edge": + self.mode_combo.setCurrentText("Edge") + self.select_edge(int(target_id)) + self.statusBar().showMessage(f"已选择 edge {target_id}") else: self.mode_combo.setCurrentText("Face") - self.select_face(int(face_id)) - self.statusBar().showMessage(f"已选择可推拉平面 face {face_id}") + self.select_face(int(target_id)) + self.statusBar().showMessage(f"已选择可推拉平面 face {target_id}") - def select_by_id(self) -> None: + def select_by_id(self, kind: str | None = None) -> None: if self.model is None: return if self._edit_busy("编辑计算中,暂时不能切换选择对象。"): @@ -904,7 +1877,7 @@ class StepEditorWindow(QMainWindow): QMessageBox.information(self, "ID 无效", "请输入整数 ID。Part ID 从 1 开始,Solid/Face/Edge ID 从 0 开始。") return - kind = self.id_kind_combo.currentText() + kind = kind or self.mode_combo.currentText() try: if kind == "Part": if self.model.part_by_id(target_id) is None: @@ -922,17 +1895,25 @@ class StepEditorWindow(QMainWindow): raise ValueError(f"不存在 Face ID {target_id}") self.mode_combo.setCurrentText("Face") self.select_face(target_id) + elif kind == "Feature": + if target_id < 0 or target_id >= len(self.model.faces): + raise ValueError(f"不存在 Feature 来源 Face ID {target_id}") + self.mode_combo.setCurrentText("Feature") + self.select_feature(target_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.select_edge(target_id) + self.last_id_kind = kind except ValueError as exc: QMessageBox.information(self, "未找到对象", str(exc)) def select_part(self, part_id: int, pick_position: tuple[float, float, float] | None = None) -> None: if self.model is None: return + if self._edit_busy("编辑计算中,暂时不能选择零件。"): + return info = self.model.part_info(part_id) self._reset_selection(clear_highlight=False) self.selected_kind = "part" @@ -942,7 +1923,9 @@ class StepEditorWindow(QMainWindow): self._show_pick_marker(pick_position) self._sync_id_picker("Part", part_id) self.set_info(self._with_pick_info(info, pick_position)) - self.statusBar().showMessage(self._selection_status(f"已选择零件 {part_id}", pick_position)) + self._update_action_states() + kind_label = _part_tree_kind_label(str(info.get("kind", "part"))) + self.statusBar().showMessage(self._selection_status(f"已选择{kind_label} {part_id}", pick_position)) def select_solid( self, @@ -952,6 +1935,8 @@ class StepEditorWindow(QMainWindow): ) -> None: if self.model is None: return + if self._edit_busy("编辑计算中,暂时不能选择 solid。"): + return info = self.model.solid_info(solid_id) self._reset_selection(clear_highlight=False) self.selected_kind = "solid" @@ -963,6 +1948,7 @@ class StepEditorWindow(QMainWindow): self._show_pick_marker(pick_position) self._sync_id_picker("Solid", solid_id) self.set_info(self._with_pick_info(info, pick_position)) + self._update_action_states() self.statusBar().showMessage(self._selection_status(f"已选择 solid {solid_id}", pick_position)) def select_face( @@ -973,6 +1959,8 @@ class StepEditorWindow(QMainWindow): ) -> None: if self.model is None: return + if self._edit_busy("编辑计算中,暂时不能选择 face。"): + return self._reset_selection(clear_highlight=False) self.selected_kind = "feature" if feature_mode else "face" self.selected_face_id = face_id @@ -980,29 +1968,107 @@ class StepEditorWindow(QMainWindow): info = self.model.face_info(face_id) if feature_mode: info["feature_mode"] = "当前是几何候选判断,不等同于 CAD 历史特征" - if "diameter" in info: - self.hole_diameter_input.setText(_format_float(float(info["diameter"]) * 1.2)) + self._sync_cylindrical_edit_inputs(info) self.selected_part_id = int(info["part_id"]) self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None self._highlight_faces(face_ids=[face_id]) self._show_pick_marker(pick_position) - self._sync_id_picker("Face", face_id) + self._sync_id_picker("Feature" if feature_mode else "Face", face_id) self.set_info(self._with_pick_info(info, pick_position)) + self._update_action_states() self.statusBar().showMessage(self._selection_status(f"已选择 face {face_id}", pick_position)) + def select_feature(self, face_id: int, pick_position: tuple[float, float, float] | None = None) -> None: + if self.model is None: + return + if self._edit_busy("编辑计算中,暂时不能选择特征。"): + return + info = self.model.feature_info(face_id) + self._reset_selection(clear_highlight=False) + self.selected_kind = "feature" + self.selected_face_id = face_id + self.selected_pick_position = pick_position + self._sync_cylindrical_edit_inputs(info) + self.selected_part_id = int(info["part_id"]) + self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None + highlight_face_ids = _int_values(info.get("feature_highlight_face_ids")) + self._highlight_faces(face_ids=highlight_face_ids or [face_id]) + 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", "局部特征候选")) + self._update_action_states() + self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 face {face_id}", pick_position)) + + def _sync_cylindrical_edit_inputs(self, info: dict[str, object]) -> None: + fillet_radius_suggestion: str | None = None + if "diameter" in info: + feature_guess = str(info.get("feature_guess", "")) + if feature_guess == "boss/outer-round candidate": + suggested_diameter = _format_float(float(info["diameter"]) * 1.2) + self.hole_diameter_input.clear() + self.boss_diameter_input.setText(suggested_diameter) + elif feature_guess == "round/fillet candidate": + radius = _float_or_none(info.get("existing_fillet_radius_estimate")) + if radius is None: + radius = _float_or_none(info.get("radius")) + if radius is not None: + fillet_radius_suggestion = _format_float(max(radius * 1.2, 0.01)) + self.hole_diameter_input.clear() + self.boss_diameter_input.clear() + else: + suggested_diameter = _format_float(float(info["diameter"]) * 1.2) + self.hole_diameter_input.setText(suggested_diameter) + self.boss_diameter_input.clear() + else: + self.hole_diameter_input.clear() + self.boss_diameter_input.clear() + if info.get("cylinder_end_type") == "blind" and "hole_depth_estimate" in info: + self.hole_depth_input.setText(_format_float(float(info["hole_depth_estimate"]) * 1.2)) + else: + self.hole_depth_input.clear() + if hasattr(self, "edge_fillet_radius_input"): + if fillet_radius_suggestion is None: + self.edge_fillet_radius_input.clear() + else: + self.edge_fillet_radius_input.setText(fillet_radius_suggestion) + if hasattr(self, "edge_chamfer_distance_input"): + self.edge_chamfer_distance_input.clear() + if hasattr(self, "edge_target_length_input"): + self.edge_target_length_input.clear() + + def _sync_edge_edit_inputs(self, info: dict[str, object]) -> None: + self.hole_diameter_input.clear() + self.boss_diameter_input.clear() + self.hole_depth_input.clear() + if info.get("curve") == "line" and "length" in info: + length = float(info["length"]) + self.edge_fillet_radius_input.setText(_format_float(max(length * 0.05, 0.01))) + self.edge_chamfer_distance_input.setText(_format_float(max(length * 0.04, 0.01))) + self.edge_target_length_input.setText(_format_float(length)) + else: + self.edge_fillet_radius_input.clear() + self.edge_chamfer_distance_input.clear() + self.edge_target_length_input.clear() + def select_edge(self, edge_id: int, pick_position: tuple[float, float, float] | None = None) -> None: if self.model is None: return + if self._edit_busy("编辑计算中,暂时不能选择 edge。"): + return self._reset_selection(clear_highlight=False) self.selected_kind = "edge" self.selected_edge_id = edge_id self.selected_pick_position = pick_position info = self.model.edge_info(edge_id) + self._sync_edge_edit_inputs(info) self.selected_part_id = int(info["part_id"]) + self.selected_solid_id = int(info["solid_id"]) if int(info.get("solid_id", -1)) >= 0 else None self._highlight_edge(edge_id) self._show_pick_marker(pick_position) self._sync_id_picker("Edge", edge_id) self.set_info(self._with_pick_info(info, pick_position)) + self._update_action_states() self.statusBar().showMessage(self._selection_status(f"已选择 edge {edge_id}", pick_position)) def _highlight_faces(self, face_ids=None, part_ids=None) -> None: @@ -1012,10 +2078,13 @@ class StepEditorWindow(QMainWindow): polydata = self.model.build_face_polydata(face_ids=face_ids, part_ids=part_ids) mapper = vtk.vtkPolyDataMapper() mapper.SetInputData(polydata) + _enable_overlay_depth_offset(mapper) actor = vtk.vtkActor() actor.SetMapper(mapper) actor.GetProperty().SetColor(1.0, 0.72, 0.08) - actor.GetProperty().SetOpacity(0.75) + actor.GetProperty().SetOpacity(0.82) + actor.GetProperty().SetAmbient(0.45) + actor.GetProperty().SetDiffuse(0.65) actor.GetProperty().SetSpecular(0.35) actor.GetProperty().SetLineWidth(2) self.highlight_actor = actor @@ -1026,27 +2095,101 @@ class StepEditorWindow(QMainWindow): if self.model is None: return self._clear_highlight() - polydata = self.model.build_edge_polydata() - extract = vtk.vtkExtractCells() - ids = vtk.vtkIdList() - edge_arr = polydata.GetCellData().GetArray("edge_id") - for cell_id in range(polydata.GetNumberOfCells()): - if int(edge_arr.GetValue(cell_id)) == edge_id: - ids.InsertNextId(cell_id) - break - extract.SetInputData(polydata) - extract.SetCellList(ids) - extract.Update() + polydata = self.model.build_edge_polydata(edge_ids=[edge_id]) mapper = vtk.vtkDataSetMapper() - mapper.SetInputConnection(extract.GetOutputPort()) + mapper.SetInputData(polydata) + _enable_overlay_depth_offset(mapper) actor = vtk.vtkActor() actor.SetMapper(mapper) actor.GetProperty().SetColor(1.0, 0.78, 0.0) + actor.GetProperty().SetAmbient(0.7) + actor.GetProperty().SetDiffuse(0.8) actor.GetProperty().SetLineWidth(5) self.edge_highlight_actor = actor self.renderer.AddActor(actor) self.render_window.Render() + def _show_hover_target(self, target: dict[str, object] | None) -> None: + if self.model is None: + self._clear_hover(render=True) + return + signature = None if target is None else (str(target["kind"]), int(target["target_id"])) + if signature == self.hover_signature: + return + + self._clear_hover(render=False) + if target is None: + self.render_window.Render() + return + + kind = str(target["kind"]) + target_id = int(target["target_id"]) + if kind == "part": + self._highlight_hover_faces(part_ids=[target_id]) + elif kind == "solid": + face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == target_id] + self._highlight_hover_faces(face_ids=face_ids) + elif kind == "feature": + info = self.model.feature_info(target_id) + face_ids = _int_values(info.get("feature_highlight_face_ids")) or [target_id] + self._highlight_hover_faces(face_ids=face_ids) + elif kind == "edge": + self._highlight_hover_edge(target_id) + else: + self._highlight_hover_faces(face_ids=[target_id]) + self.hover_signature = signature + self.render_window.Render() + + def _highlight_hover_faces(self, face_ids=None, part_ids=None) -> None: + if self.model is None: + return + polydata = self.model.build_face_polydata(face_ids=face_ids, part_ids=part_ids) + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputData(polydata) + _enable_overlay_depth_offset(mapper) + actor = vtk.vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetColor(1.0, 0.12, 0.06) + actor.GetProperty().SetOpacity(0.58) + actor.GetProperty().SetAmbient(0.5) + actor.GetProperty().SetDiffuse(0.7) + actor.GetProperty().SetSpecular(0.35) + actor.GetProperty().SetLineWidth(2) + self.hover_face_actor = actor + self.renderer.AddActor(actor) + + def _highlight_hover_edge(self, edge_id: int) -> None: + if self.model is None: + return + polydata = self.model.build_edge_polydata(edge_ids=[edge_id]) + mapper = vtk.vtkDataSetMapper() + mapper.SetInputData(polydata) + _enable_overlay_depth_offset(mapper) + actor = vtk.vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetColor(1.0, 0.08, 0.02) + actor.GetProperty().SetAmbient(0.75) + actor.GetProperty().SetDiffuse(0.8) + actor.GetProperty().SetLineWidth(4) + self.hover_edge_actor = actor + self.renderer.AddActor(actor) + + def _clear_hover(self, render: bool = False) -> None: + if not hasattr(self, "renderer"): + return + removed = False + if self.hover_face_actor is not None: + self.renderer.RemoveActor(self.hover_face_actor) + self.hover_face_actor = None + removed = True + if self.hover_edge_actor is not None: + self.renderer.RemoveActor(self.hover_edge_actor) + self.hover_edge_actor = None + removed = True + self.hover_signature = None + if render and removed and hasattr(self, "render_window"): + self.render_window.Render() + def _clear_highlight(self) -> None: if self.highlight_actor is not None: self.renderer.RemoveActor(self.highlight_actor) @@ -1061,12 +2204,34 @@ class StepEditorWindow(QMainWindow): def clear_edit_preview(self, render: bool = True) -> None: if self.edit_preview_timer is not None: self.edit_preview_timer.stop() - if self.edit_preview_actor is not None and hasattr(self, "renderer"): - self.renderer.RemoveActor(self.edit_preview_actor) - self.edit_preview_actor = None + if hasattr(self, "renderer"): + for actor in self.edit_preview_actors: + self.renderer.RemoveActor(actor) + if self.edit_preview_actor is not None and self.edit_preview_actor not in self.edit_preview_actors: + self.renderer.RemoveActor(self.edit_preview_actor) + self.edit_preview_actors = [] + self.edit_preview_actor = None if render and hasattr(self, "render_window"): self.render_window.Render() + def _add_edit_preview_actor( + self, + polydata, + color: tuple[float, float, float], + opacity: float | None = None, + ) -> None: + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputData(polydata) + actor = vtk.vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetColor(*color) + actor.GetProperty().SetOpacity(opacity if opacity is not None else self.edit_preview_base_opacity) + actor.GetProperty().SetSpecular(0.28) + actor.GetProperty().SetLineWidth(1) + self.edit_preview_actors.append(actor) + self.edit_preview_actor = self.edit_preview_actor or actor + self.renderer.AddActor(actor) + def _show_push_pull_preview(self, face_id: int, distance: float) -> None: if self.model is None: return @@ -1076,19 +2241,149 @@ class StepEditorWindow(QMainWindow): except Exception as exc: self.statusBar().showMessage(f"推拉预览不可用:{exc}") return - mapper = vtk.vtkPolyDataMapper() - mapper.SetInputData(polydata) - actor = vtk.vtkActor() - actor.SetMapper(mapper) if distance >= 0: - actor.GetProperty().SetColor(0.0, 0.86, 0.34) + color = (0.0, 0.86, 0.34) else: - actor.GetProperty().SetColor(1.0, 0.18, 0.06) - actor.GetProperty().SetOpacity(self.edit_preview_base_opacity) - actor.GetProperty().SetSpecular(0.28) - actor.GetProperty().SetLineWidth(1) - self.edit_preview_actor = actor - self.renderer.AddActor(actor) + color = (1.0, 0.18, 0.06) + self._add_edit_preview_actor(polydata, color) + self._start_edit_preview_pulse() + self.render_window.Render() + + def _show_cylinder_resize_preview(self, face_id: int, diameter: float) -> None: + if self.model is None: + return + self.clear_edit_preview(render=False) + try: + previews = self.model.cylindrical_resize_preview_polydata(face_id, diameter) + except Exception as exc: + self.statusBar().showMessage(f"孔径调整预览不可用:{exc}") + return + for preview in previews: + role = str(preview["role"]) + color = (0.0, 0.86, 0.34) if role == "fill" else (1.0, 0.18, 0.06) + opacity = 0.28 if role == "fill" else 0.32 + self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity) + self._start_edit_preview_pulse() + self.render_window.Render() + + def _show_cylinder_boss_resize_preview(self, face_id: int, diameter: float) -> None: + if self.model is None: + return + self.clear_edit_preview(render=False) + try: + previews = self.model.cylindrical_boss_resize_preview_polydata(face_id, diameter) + except Exception as exc: + self.statusBar().showMessage(f"凸台直径调整预览不可用:{exc}") + return + for preview in previews: + role = str(preview["role"]) + color = (0.0, 0.86, 0.34) if role == "fill" else (1.0, 0.18, 0.06) + opacity = 0.3 if role == "fill" else 0.34 + self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity) + self._start_edit_preview_pulse() + self.render_window.Render() + + def _show_cylinder_suppress_preview(self, face_id: int) -> None: + if self.model is None: + return + self.clear_edit_preview(render=False) + try: + previews = self.model.cylindrical_suppress_preview_polydata(face_id) + except Exception as exc: + self.statusBar().showMessage(f"封堵圆柱孔预览不可用:{exc}") + return + for preview in previews: + self._add_edit_preview_actor(preview["polydata"], (0.0, 0.86, 0.34), opacity=0.3) + self._start_edit_preview_pulse() + self.render_window.Render() + + def _show_cylinder_depth_preview(self, face_id: int, target_depth: float) -> None: + if self.model is None: + return + self.clear_edit_preview(render=False) + try: + previews = self.model.cylindrical_depth_preview_polydata(face_id, target_depth) + except Exception as exc: + self.statusBar().showMessage(f"孔深调整预览不可用:{exc}") + return + for preview in previews: + role = str(preview["role"]) + color = (0.0, 0.86, 0.34) if role == "fill" else (1.0, 0.18, 0.06) + opacity = 0.3 if role == "fill" else 0.34 + self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity) + self._start_edit_preview_pulse() + self.render_window.Render() + + def _show_existing_fillet_resize_preview(self, face_id: int, target_radius: float) -> None: + if self.model is None: + return + self.clear_edit_preview(render=False) + try: + previews = self.model.existing_fillet_resize_preview_polydata(face_id, target_radius) + except Exception as exc: + self.statusBar().showMessage(f"已有圆角半径修改预览不可用:{exc}") + return + for preview in previews: + self._add_edit_preview_actor(preview["polydata"], (0.35, 0.45, 1.0), opacity=0.36) + self._start_edit_preview_pulse() + self.render_window.Render() + + def _show_edge_fillet_preview(self, edge_id: int, radius: float) -> None: + if self.model is None: + return + self._show_edge_tube_preview(edge_id, radius, (0.1, 0.62, 1.0), "圆角预览") + + def _show_edge_chamfer_preview(self, edge_id: int, distance: float) -> None: + if self.model is None: + return + self._show_edge_tube_preview(edge_id, distance, (1.0, 0.55, 0.08), "倒角预览") + + def _show_edge_length_preview(self, edge_id: int, target_length: float) -> None: + if self.model is None: + return + self.clear_edit_preview(render=False) + try: + polydata = self.model.straight_edge_length_preview_polydata(edge_id, target_length) + except Exception as exc: + self.statusBar().showMessage(f"直线边长度调整预览不可用:{exc}") + return + self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34) + self._start_edit_preview_pulse() + self.render_window.Render() + + def _show_edge_tube_preview( + self, + edge_id: int, + radius: float, + color: tuple[float, float, float], + label: str, + ) -> None: + if self.model is None: + return + self.clear_edit_preview(render=False) + polydata = self.model.build_edge_polydata() + edge_arr = polydata.GetCellData().GetArray("edge_id") + ids = vtk.vtkIdList() + for cell_id in range(polydata.GetNumberOfCells()): + if int(edge_arr.GetValue(cell_id)) == edge_id: + ids.InsertNextId(cell_id) + break + if ids.GetNumberOfIds() == 0: + self.statusBar().showMessage(f"{label}不可用:没有找到选中的 edge。") + return + extract = vtk.vtkExtractCells() + extract.SetInputData(polydata) + extract.SetCellList(ids) + extract.Update() + geometry = vtk.vtkGeometryFilter() + geometry.SetInputConnection(extract.GetOutputPort()) + tube = vtk.vtkTubeFilter() + tube.SetInputConnection(geometry.GetOutputPort()) + tube.SetRadius(radius) + tube.SetNumberOfSides(24) + tube.CappingOn() + tube.Update() + self._add_edit_preview_actor(tube.GetOutput(), color, opacity=0.34) self._start_edit_preview_pulse() self.render_window.Render() @@ -1100,13 +2395,14 @@ class StepEditorWindow(QMainWindow): self.edit_preview_timer.start(120) def _pulse_edit_preview(self) -> None: - if self.edit_preview_actor is None: + if not self.edit_preview_actors: if self.edit_preview_timer is not None: self.edit_preview_timer.stop() return self.edit_preview_phase += 0.35 opacity = self.edit_preview_base_opacity * (0.75 + 0.25 * (math.sin(self.edit_preview_phase) + 1.0) / 2.0) - self.edit_preview_actor.GetProperty().SetOpacity(opacity) + for actor in self.edit_preview_actors: + actor.GetProperty().SetOpacity(opacity) self.render_window.Render() def clear_diff_preview(self, render: bool = True) -> None: @@ -1316,6 +2612,7 @@ class StepEditorWindow(QMainWindow): self.selected_pick_position = None if clear_highlight: self._clear_highlight() + self._update_action_states() def _show_pick_marker(self, pick_position: tuple[float, float, float] | None) -> None: if pick_position is None: @@ -1371,9 +2668,163 @@ class StepEditorWindow(QMainWindow): return True def _sync_id_picker(self, kind: str, target_id: int) -> None: - self.id_kind_combo.setCurrentText(kind) + self.last_id_kind = kind self.id_input.setText(str(target_id)) + def _update_action_states(self) -> None: + if not hasattr(self, "export_all_button"): + return + has_model = self.model is not None and not self.operation_in_progress + selected_kind = self.selected_kind + self.export_all_button.setEnabled(has_model) + self.export_check_button.setEnabled(has_model) + self.export_part_button.setEnabled(has_model and selected_kind == "part" and self.selected_part_id is not None) + self.export_solid_button.setEnabled(has_model and selected_kind == "solid" and self.selected_solid_id is not None) + self.export_face_button.setEnabled(has_model and selected_kind == "face" and self.selected_face_id is not None) + self.export_feature_button.setEnabled(has_model and selected_kind == "feature" and self.selected_face_id is not None) + self.export_edge_button.setEnabled(has_model and selected_kind == "edge" and self.selected_edge_id is not None) + self._update_edit_action_states(has_model) + + def _update_edit_action_states(self, has_model: bool) -> None: + if not hasattr(self, "push_button"): + return + + action_info = self._selected_action_info() + 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_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_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" + + self._set_control_state( + self.push_button, + has_model and is_plane, + "对当前平面 face 执行推拉。", + "请先选择一个平面 face,或在 Feature 模式下选择可推拉平面候选。", + ) + self._set_control_state( + self.resize_button, + has_model and is_hole_or_groove, + "调整当前圆柱孔/槽候选的直径。", + "请先选择被识别为孔/槽候选的圆柱 face。", + ) + self._set_control_state( + self.resize_boss_button, + has_model and is_boss and is_full_cylinder, + "调整当前完整圆柱凸台候选的直径。", + "请先选择被识别为完整凸台/外圆候选的圆柱 face。", + ) + self._set_control_state( + self.suppress_button, + has_model and is_hole_or_groove and is_full_cylinder, + "封堵当前完整圆柱孔候选。", + "请先选择接近完整圆柱的孔候选;半孔/槽不会放行。", + ) + self._set_control_state( + self.resize_depth_button, + has_model and is_hole_or_groove and is_blind and has_bottom, + "调整当前已识别底面的盲孔/盲槽深度。", + "请先选择已识别出疑似底面的盲孔/盲槽候选。", + ) + self._set_control_state( + self.fillet_edge_button, + has_model and is_line_edge, + "给当前直线 edge 添加新圆角。", + "请先选择一条直线 edge。", + ) + self._set_control_state( + self.resize_existing_fillet_button, + has_model and is_existing_fillet and has_fillet_support, + "尝试修改当前已有圆角/倒圆候选的半径。", + "请先选择一个已有圆角/倒圆候选 face;第一版需要识别到至少两个支撑 face。", + ) + self._set_control_state( + self.chamfer_edge_button, + has_model and is_line_edge, + "给当前直线 edge 添加倒角。", + "请先选择一条直线 edge。", + ) + self._set_control_state( + self.resize_edge_length_button, + has_model and is_line_edge, + "通过移动端面尝试调整当前直线 edge 的长度。", + "请先选择一条直线 edge;第一版会寻找可推拉的端面来改变长度。", + ) + self._set_control_state( + self.translate_part_button, + has_model and self.selected_part_id is not None, + "平移当前选中对象所属零件。", + "请先选择一个零件,或选择属于某个零件的对象。", + ) + self._set_control_state( + self.rotate_part_button, + has_model and self.selected_part_id is not None, + "旋转当前选中对象所属零件。", + "请先选择一个零件,或选择属于某个零件的对象。", + ) + self._set_control_state( + self.translate_solid_button, + has_model and self.selected_solid_id is not None, + "平移当前选中对象所属 solid。", + "请先选择一个 solid,或选择属于某个 solid 的 face/edge。", + ) + self._set_control_state( + self.rotate_solid_button, + has_model and self.selected_solid_id is not None, + "旋转当前选中对象所属 solid。", + "请先选择一个 solid,或选择属于某个 solid 的 face/edge。", + ) + self._set_control_state( + self.cylinders_button, + has_model, + "扫描当前模型中的圆柱候选。", + "请先加载 STEP 文件。", + ) + 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), + "重做刚撤销的编辑。", + "当前没有可重做的编辑。", + ) + + def _set_control_state(self, widget, enabled: bool, enabled_tip: str, disabled_tip: str) -> None: + widget.setEnabled(enabled) + widget.setToolTip(enabled_tip if enabled else disabled_tip) + + def _selected_action_info(self) -> dict[str, object]: + if self.model is None: + return {} + try: + if self.selected_kind == "feature" and self.selected_face_id is not None: + return self.model.feature_info(self.selected_face_id) + if self.selected_kind == "face" and self.selected_face_id is not None: + info = self.model.face_info(self.selected_face_id) + if info.get("surface") == "cylinder": + return self.model.feature_info(self.selected_face_id) + return info + if self.selected_kind == "edge" and self.selected_edge_id is not None: + return self.model.edge_info(self.selected_edge_id) + except Exception: + return dict(self.current_info_values) + return dict(self.current_info_values) + def _locate_operation_record(self, record: OperationRecord) -> str: if self.model is None: return "" @@ -1400,6 +2851,25 @@ class StepEditorWindow(QMainWindow): else: 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) + self.selected_kind = "edge" + self.selected_edge_id = record.target_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) + located = True + locator_note = ( + f"定位: 已尝试高亮当前模型中的 edge {record.target_id}。" + "布尔/倒圆编辑后 edge ID 可能发生语义变化,请结合拾取点确认。" + ) + else: + locator_note = f"定位: 原目标 edge {record.target_id} 在当前模型索引中已经不存在。" + if record.pick_position is not None: self._show_pick_marker(record.pick_position) if not locator_note: @@ -1411,37 +2881,62 @@ class StepEditorWindow(QMainWindow): if not locator_note: locator_note = "定位: 这条历史记录没有可定位的目标或拾取点。" + self._update_action_states() self.statusBar().showMessage(locator_note.splitlines()[0]) return locator_note def undo_edit(self) -> None: if self.model is None: return + if self._edit_busy("编辑计算中,暂时不能撤销。"): + return if not self.undo_stack: self.statusBar().showMessage("没有可撤销的编辑") return current = self.model.snapshot() - snapshot = self.undo_stack.pop() - undone = self.operation_history.pop() if self.operation_history else OperationRecord("编辑", "编辑") + snapshot = self.undo_stack[-1] + undone = self.operation_history[-1] if self.operation_history else OperationRecord("编辑", "编辑") + try: + self._restore_snapshot(snapshot) + except Exception as exc: + rollback_message = self._restore_after_failed_undo_redo(current) + QMessageBox.critical(self, "撤销失败", f"{exc}\n\n{rollback_message}") + self.statusBar().showMessage("撤销失败,模型已尽量恢复到撤销前状态") + return + self.undo_stack.pop() + if self.operation_history: + self.operation_history.pop() self.redo_stack.append(current) self.redo_history.append(undone) - self._restore_snapshot(snapshot) self._refresh_history_list() + self._update_action_states() self.statusBar().showMessage(f"已撤销:{undone.summary}") def redo_edit(self) -> None: if self.model is None: return + if self._edit_busy("编辑计算中,暂时不能重做。"): + return if not self.redo_stack: self.statusBar().showMessage("没有可重做的编辑") return current = self.model.snapshot() - snapshot = self.redo_stack.pop() - redone = self.redo_history.pop() if self.redo_history else OperationRecord("编辑", "编辑") + snapshot = self.redo_stack[-1] + redone = self.redo_history[-1] if self.redo_history else OperationRecord("编辑", "编辑") + try: + self._restore_snapshot(snapshot) + except Exception as exc: + rollback_message = self._restore_after_failed_undo_redo(current) + QMessageBox.critical(self, "重做失败", f"{exc}\n\n{rollback_message}") + self.statusBar().showMessage("重做失败,模型已尽量恢复到重做前状态") + return + self.redo_stack.pop() + if self.redo_history: + self.redo_history.pop() self.undo_stack.append(current) self.operation_history.append(redone) - self._restore_snapshot(snapshot) self._refresh_history_list() + self._update_action_states() self.statusBar().showMessage(f"已重做:{redone.summary}") def _restore_snapshot(self, snapshot: dict[int, object]) -> None: @@ -1464,11 +2959,20 @@ class StepEditorWindow(QMainWindow): } ) + def _restore_after_failed_undo_redo(self, snapshot: dict[int, object]) -> str: + try: + self._restore_snapshot(snapshot) + except Exception as rollback_exc: + return f"恢复原状态也失败:{rollback_exc}。建议重新加载 STEP 文件。" + return "模型已恢复到操作前状态,历史记录未移动。" + def export_all(self) -> None: if self.model is None: return if self._edit_busy("请等待当前编辑完成后再导出。"): return + if not self._confirm_export_quality("all"): + return target, _ = QFileDialog.getSaveFileName( self, "导出当前完整 STEP", @@ -1487,6 +2991,8 @@ class StepEditorWindow(QMainWindow): if self.selected_part_id is None: QMessageBox.information(self, "未选择零件", "请先选择一个零件。") return + if not self._confirm_export_quality("part", self.selected_part_id): + return part = self.model.part_by_id(self.selected_part_id) default_name = f"{part.name if part else 'part'}_export.step".replace(" ", "_") target, _ = QFileDialog.getSaveFileName( @@ -1510,6 +3016,8 @@ class StepEditorWindow(QMainWindow): if self.selected_solid_id is None: QMessageBox.information(self, "未选择 solid", "请先选择一个 solid,或选择一个属于 solid 的 face。") return + if not self._confirm_export_quality("solid", self.selected_solid_id): + return target, _ = QFileDialog.getSaveFileName( self, "导出选中 solid", @@ -1531,6 +3039,8 @@ class StepEditorWindow(QMainWindow): if self.selected_face_id is None: QMessageBox.information(self, "未选择 face", "请先切换到 Face 或 Feature 模式并选择一个 face。") return + if not self._confirm_export_quality("face", self.selected_face_id): + return target, _ = QFileDialog.getSaveFileName( self, "导出选中 face", @@ -1544,6 +3054,111 @@ class StepEditorWindow(QMainWindow): f"已导出选中 face 到 {Path(target).name}", ) + def export_selected_feature(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再导出。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择特征", "请先切换到 Feature 模式并选择一个局部特征。") + return + if not self._confirm_export_quality("feature", self.selected_face_id): + return + target, _ = QFileDialog.getSaveFileName( + self, + "导出选中特征区域", + str(self.step_path.parent / f"feature_face_{self.selected_face_id}_export.step"), + "STEP 文件 (*.step *.stp);;所有文件 (*.*)", + ) + if not target: + return + self._run_action( + lambda: self.model.export_feature(self.selected_face_id, target), + f"已导出选中特征区域到 {Path(target).name}", + ) + + def export_selected_edge(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再导出。"): + return + if self.selected_edge_id is None: + QMessageBox.information(self, "未选择 edge", "请先切换到 Edge 模式并选择一个 edge。") + return + if not self._confirm_export_quality("edge", self.selected_edge_id): + return + target, _ = QFileDialog.getSaveFileName( + self, + "导出选中 edge", + str(self.step_path.parent / f"edge_{self.selected_edge_id}_export.step"), + "STEP 文件 (*.step *.stp);;所有文件 (*.*)", + ) + if not target: + return + self._run_action( + lambda: self.model.export_edge(self.selected_edge_id, target), + f"已导出选中 edge 到 {Path(target).name}", + ) + + def check_export_quality(self) -> None: + if self.model is None: + return + if self._edit_busy("编辑计算中,暂时不能检查导出质量。"): + return + scope, target_id = self._current_export_quality_target() + try: + info = self.model.export_quality_info(scope, target_id) + except Exception as exc: + QMessageBox.critical(self, "质量检查失败", str(exc)) + self.statusBar().showMessage("导出质量检查失败") + return + report = _export_quality_text(info) + self.set_plain_info(report) + if info.get("quality_status") == "ok": + self.statusBar().showMessage("导出质量检查通过") + else: + self.statusBar().showMessage("导出质量检查发现警告") + + def _current_export_quality_target(self) -> tuple[str, int | None]: + if self.selected_kind == "feature" and self.selected_face_id is not None: + return "feature", self.selected_face_id + if self.selected_kind == "face" and self.selected_face_id is not None: + return "face", self.selected_face_id + if self.selected_kind == "edge" and self.selected_edge_id is not None: + return "edge", self.selected_edge_id + if self.selected_kind == "edge": + if self.selected_solid_id is not None: + return "solid", self.selected_solid_id + if self.selected_part_id is not None: + return "part", self.selected_part_id + if self.selected_kind == "solid" and self.selected_solid_id is not None: + return "solid", self.selected_solid_id + if self.selected_kind == "part" and self.selected_part_id is not None: + return "part", self.selected_part_id + return "all", None + + def _confirm_export_quality(self, scope: str, target_id: int | None = None) -> bool: + if self.model is None: + return False + try: + info = self.model.export_quality_info(scope, target_id) + except Exception as exc: + QMessageBox.critical(self, "导出质量检查失败", str(exc)) + self.statusBar().showMessage("导出质量检查失败") + return False + report = _export_quality_text(info) + self.set_plain_info(report) + if info.get("quality_status") == "ok": + return True + result = QMessageBox.question( + self, + "导出质量警告", + f"{report}\n\n仍然继续导出吗?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + return result == QMessageBox.StandardButton.Yes + def push_pull_face(self) -> None: if self.model is None: return @@ -1558,10 +3173,34 @@ class StepEditorWindow(QMainWindow): QMessageBox.critical(self, "距离无效", "请输入数字形式的面偏移距离。") return face_id = self.selected_face_id - face_info = self.model.face_info(face_id) - if face_info.get("surface") != "plane": - QMessageBox.information(self, "不是平面", "当前选中的 face 不是平面,不能执行推拉平面。") + plan = self.model.push_pull_plan(face_id, distance) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能推拉平面", str(plan["message"])) + self.statusBar().showMessage("推拉平面已阻止") return + if plan["risk"] != "low": + result = QMessageBox.question( + self, + "确认推拉平面", + ( + f"face: {plan['face_id']}\n" + f"推拉距离: {_format_value(plan['distance'])}\n" + f"共面推拉范围: {plan.get('push_pull_scope_face_count', 1)} 个 face\n" + f"{plan.get('push_pull_scope_note', '')}\n" + f"风险: {plan['risk']}\n" + f"方向置信度: {plan['direction_confidence']}\n" + f"方向说明: {plan['direction_note']}\n" + f"face 尺寸参考: bbox_diagonal={_format_value(plan.get('bbox_diagonal', ''))}\n\n" + f"{plan['message']}\n\n" + "继续操作会对当前零件执行实验性布尔修改。\n\n" + "确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if result != QMessageBox.StandardButton.Yes: + self.statusBar().showMessage("已取消推拉平面") + return self._show_push_pull_preview(face_id, distance) def action(): @@ -1572,12 +3211,21 @@ class StepEditorWindow(QMainWindow): operation_name="推拉平面", target=f"face {face_id}", parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), "semantic_distance": distance, "distance_rule": "positive=outward fuse, negative=inward cut", - "surface": face_info.get("surface"), - "outward_direction": face_info.get("push_pull_outward_direction"), - "direction_confidence": face_info.get("push_pull_confidence"), - "direction_note": face_info.get("push_pull_note"), + "surface": plan.get("surface"), + "outward_direction": plan.get("outward_direction"), + "direction_confidence": plan.get("direction_confidence"), + "direction_note": plan.get("direction_note"), + "push_pull_risk": plan.get("risk"), + "push_pull_status": plan.get("status"), + "push_pull_message": plan.get("message"), + "push_pull_scope_face_ids": plan.get("push_pull_scope_face_ids"), + "push_pull_scope_face_count": plan.get("push_pull_scope_face_count"), + "push_pull_scope_note": plan.get("push_pull_scope_note"), + "bbox_diagonal": plan.get("bbox_diagonal"), }, target_kind="face", target_id=face_id, @@ -1607,6 +3255,14 @@ class StepEditorWindow(QMainWindow): 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, "确认圆柱孔径调整", @@ -1614,18 +3270,26 @@ class StepEditorWindow(QMainWindow): 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" "继续操作会对当前零件执行实验性布尔修改。\n\n" "确定继续吗?" @@ -1637,6 +3301,7 @@ class StepEditorWindow(QMainWindow): self.statusBar().showMessage("已取消圆柱孔径调整") return face_id = self.selected_face_id + self._show_cylinder_resize_preview(face_id, diameter) def action(): return self.model.resize_cylindrical_hole(face_id, diameter) @@ -1646,14 +3311,25 @@ class StepEditorWindow(QMainWindow): operation_name="调整圆柱孔径", target=f"face {face_id}", parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), "new_diameter": diameter, "old_diameter": info.get("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"), "resize_mode": plan.get("resize_mode"), + "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, "confidence": plan.get("confidence"), + "resize_status": plan.get("status"), "resize_risk": plan.get("risk"), "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), "material_vote_summary": plan.get("material_vote_summary"), "cylinder_end_type": plan.get("cylinder_end_type"), "hole_depth_estimate": plan.get("hole_depth_estimate"), @@ -1664,6 +3340,10 @@ class StepEditorWindow(QMainWindow): "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"), @@ -1675,6 +3355,878 @@ class StepEditorWindow(QMainWindow): target_id=face_id, ) + def resize_boss(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再调整凸台直径。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择圆柱凸台", "请先选择一个圆柱凸台候选 face。") + return + try: + diameter = float(self.boss_diameter_input.text()) + 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_boss_resize_plan(self.selected_face_id, diameter) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能调整凸台直径", str(plan["message"])) + self.statusBar().showMessage("圆柱凸台直径调整已阻止") + return + 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['boss_tool_strategy']}\n" + f"工具高度: {_format_value(plan['boss_tool_height'])}\n" + f"轴向重叠余量: {_format_value(plan['boss_tool_axial_margin'])}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会对当前零件执行实验性布尔修改。\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_boss_resize_preview(face_id, diameter) + + def action(): + return self.model.resize_cylindrical_boss(face_id, diameter) + + self._run_edit_action( + action, + operation_name="调整圆柱凸台直径", + target=f"face {face_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "new_diameter": diameter, + "old_diameter": info.get("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"), + "resize_mode": plan.get("resize_mode"), + "feature_type": plan.get("feature_type"), + "feature_guess": plan.get("feature_guess"), + "confidence": plan.get("confidence"), + "boss_resize_status": plan.get("status"), + "boss_resize_risk": plan.get("risk"), + "boss_resize_message": plan.get("message"), + "boss_resize_warnings": plan.get("warnings"), + "boss_resize_blockers": plan.get("blockers"), + "material_vote_summary": plan.get("material_vote_summary"), + "boss_tool_strategy": plan.get("boss_tool_strategy"), + "boss_tool_height": plan.get("boss_tool_height"), + "boss_tool_axial_margin": plan.get("boss_tool_axial_margin"), + "boss_tool_radius": plan.get("boss_tool_radius"), + "boss_tool_outer_radius": plan.get("boss_tool_outer_radius"), + "boss_tool_inner_radius": plan.get("boss_tool_inner_radius"), + "boss_tool_radial_overlap": plan.get("boss_tool_radial_overlap"), + "boss_tool_note": plan.get("boss_tool_note"), + "feature_adjacent_face_ids": plan.get("feature_adjacent_face_ids"), + "feature_boundary_edge_ids": plan.get("feature_boundary_edge_ids"), + }, + target_kind="face", + target_id=face_id, + ) + + def suppress_hole(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再封堵圆柱孔。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择圆柱面", "请先选择一个圆柱孔 face。") + return + + info = self.model.face_info(self.selected_face_id) + if info.get("surface") != "cylinder" or "diameter" not in info: + QMessageBox.information(self, "不是圆柱孔", "当前选中的 face 不是可封堵的圆柱孔。") + return + plan = self.model.cylindrical_suppress_plan(self.selected_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['diameter'])}\n" + f"估算高度: {_format_value(plan['height_estimate'])}\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"风险: {plan['risk']}\n\n" + f"补料策略: {plan['fill_strategy']}\n" + f"补料半径: {_format_value(plan['fill_radius'])}\n" + f"补料高度: {_format_value(plan['fill_height'])}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会用补料体封堵当前孔,并对当前零件执行实验性 Fuse。\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_suppress_preview(face_id) + + def action(): + return self.model.suppress_cylindrical_hole(face_id) + + self._run_edit_action( + action, + operation_name="封堵圆柱孔", + target=f"face {face_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "diameter": plan.get("diameter"), + "height_estimate": plan.get("height_estimate"), + "feature_type": plan.get("feature_type"), + "feature_guess": plan.get("feature_guess"), + "confidence": plan.get("confidence"), + "suppress_status": plan.get("status"), + "suppress_risk": plan.get("risk"), + "suppress_message": plan.get("message"), + "suppress_warnings": plan.get("warnings"), + "suppress_blockers": plan.get("blockers"), + "cylinder_end_type": plan.get("cylinder_end_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"), + "fill_strategy": plan.get("fill_strategy"), + "fill_height": plan.get("fill_height"), + "fill_radius": plan.get("fill_radius"), + "fill_radius_overlap": plan.get("fill_radius_overlap"), + "fill_note": plan.get("fill_note"), + }, + target_kind="face", + target_id=face_id, + ) + + def resize_hole_depth(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再调整孔深。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择圆柱面", "请先选择一个盲孔或盲槽的圆柱面。") + return + try: + target_depth = float(self.hole_depth_input.text()) + except ValueError: + QMessageBox.critical(self, "深度无效", "请输入数字形式的目标孔深。") + return + + info = self.model.face_info(self.selected_face_id) + if info.get("surface") != "cylinder" or "hole_depth_estimate" not in info: + QMessageBox.information(self, "不是圆柱孔/槽", "当前选中的 face 不是可调整孔深的圆柱孔/槽。") + return + plan = self.model.cylindrical_depth_plan(self.selected_face_id, target_depth) + 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"底面识别来源: {_format_value(plan.get('feature_bottom_detection', ''))}\n" + f"当前深度来源: {_format_value(plan.get('depth_current_depth_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" + "继续操作会对当前零件执行实验性布尔修改。\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) + + def action(): + return self.model.resize_cylindrical_depth(face_id, target_depth) + + self._run_edit_action( + action, + operation_name="调整盲孔深度", + target=f"face {face_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "old_depth": plan.get("current_depth"), + "new_depth": target_depth, + "delta_depth": plan.get("delta_depth"), + "depth_delta_ratio": plan.get("depth_delta_ratio"), + "depth_mode": plan.get("depth_mode"), + "feature_type": plan.get("feature_type"), + "feature_guess": plan.get("feature_guess"), + "confidence": plan.get("confidence"), + "depth_status": plan.get("status"), + "depth_risk": plan.get("risk"), + "depth_message": plan.get("message"), + "depth_warnings": plan.get("warnings"), + "depth_blockers": plan.get("blockers"), + "cylinder_end_type": plan.get("cylinder_end_type"), + "feature_bottom_face_ids": plan.get("feature_bottom_face_ids"), + "feature_opening_face_ids": plan.get("feature_opening_face_ids"), + "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_point": plan.get("depth_open_point"), + "depth_current_bottom_point": plan.get("depth_current_bottom_point"), + "depth_target_bottom_point": plan.get("depth_target_bottom_point"), + }, + target_kind="face", + target_id=face_id, + ) + + def resize_existing_fillet(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再修改已有圆角。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择已有圆角", "请先选择一个已有圆角/倒圆候选 face。") + return + try: + target_radius = float(self.edge_fillet_radius_input.text()) + except ValueError: + QMessageBox.critical(self, "半径无效", "请输入数字形式的目标圆角半径。") + return + + face_id = self.selected_face_id + plan = self.model.existing_fillet_resize_plan(face_id, target_radius) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能修改已有圆角", str(plan["message"])) + self.statusBar().showMessage("已有圆角半径修改已阻止") + return + + 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_radius'])}\n" + f"目标半径: {_format_value(plan['target_radius'])}\n" + f"半径变化量: {_format_value(plan['delta_radius'])}\n" + f"半径变化比例: {_format_percent(plan['radius_delta_ratio'])}\n" + f"圆弧跨度: {_format_value(plan.get('angular_span', ''))}\n" + f"长度估算: {_format_value(plan.get('height_estimate', ''))}\n" + f"支撑 Face: {_format_value(plan.get('feature_existing_fillet_support_face_ids', ''))}\n" + f"边界 Edge: {_format_value(plan.get('feature_boundary_edge_ids', ''))}\n" + f"策略: {plan['resize_strategy']}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会先尝试移除当前圆角面,再对恢复出的锐边重新倒圆。\n" + "这不是 CAD 历史特征参数编辑,复杂圆角可能失败。\n\n" + "确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if result != QMessageBox.StandardButton.Yes: + self.statusBar().showMessage("已取消修改已有圆角半径") + return + + self._show_existing_fillet_resize_preview(face_id, target_radius) + + def action(): + return self.model.resize_existing_fillet(face_id, target_radius) + + self._run_edit_action( + action, + operation_name="修改已有圆角半径", + target=f"face {face_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "old_radius": plan.get("current_radius"), + "new_radius": target_radius, + "delta_radius": plan.get("delta_radius"), + "radius_delta_ratio": plan.get("radius_delta_ratio"), + "feature_type": plan.get("feature_type"), + "feature_guess": plan.get("feature_guess"), + "confidence": plan.get("confidence"), + "existing_fillet_status": plan.get("status"), + "existing_fillet_risk": plan.get("risk"), + "existing_fillet_message": plan.get("message"), + "existing_fillet_warnings": plan.get("warnings"), + "existing_fillet_blockers": plan.get("blockers"), + "feature_existing_fillet_support_face_ids": plan.get("feature_existing_fillet_support_face_ids"), + "feature_boundary_edge_ids": plan.get("feature_boundary_edge_ids"), + "resize_strategy": plan.get("resize_strategy"), + "resize_note": plan.get("resize_note"), + "axis_point": plan.get("axis_point"), + "axis": plan.get("axis"), + "height_estimate": plan.get("height_estimate"), + "angular_span": plan.get("angular_span"), + }, + target_kind="face", + target_id=face_id, + ) + + def fillet_edge(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再添加圆角。"): + return + if self.selected_edge_id is None: + QMessageBox.information(self, "未选择边", "请先选择一个直线 edge。") + return + try: + radius = float(self.edge_fillet_radius_input.text()) + except ValueError: + QMessageBox.critical(self, "半径无效", "请输入数字形式的圆角半径。") + return + + edge_id = self.selected_edge_id + plan = self.model.edge_fillet_plan(edge_id, radius) + 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"edge: {plan['edge_id']}\n" + f"边长: {_format_value(plan['edge_length'])}\n" + f"目标圆角半径: {_format_value(plan['target_radius'])}\n" + f"半径/边长比例: {_format_percent(plan['radius_to_length_ratio'])}\n" + f"相邻 Face: {_format_value(plan.get('adjacent_face_ids', ''))}\n" + f"相邻 Face 数: {_format_value(plan.get('adjacent_face_count', ''))}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会对当前零件执行实验性 OCCT 倒圆。\n\n" + "确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if result != QMessageBox.StandardButton.Yes: + self.statusBar().showMessage("已取消添加圆角") + return + + self._show_edge_fillet_preview(edge_id, radius) + + def action(): + return self.model.fillet_edge(edge_id, radius) + + self._run_edit_action( + action, + operation_name="给边添加圆角", + target=f"edge {edge_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "edge_length": plan.get("edge_length"), + "fillet_radius": radius, + "radius_to_length_ratio": plan.get("radius_to_length_ratio"), + "curve": plan.get("curve"), + "adjacent_face_ids": plan.get("adjacent_face_ids"), + "adjacent_face_count": plan.get("adjacent_face_count"), + "fillet_status": plan.get("status"), + "fillet_risk": plan.get("risk"), + "fillet_message": plan.get("message"), + "fillet_warnings": plan.get("warnings"), + "fillet_blockers": plan.get("blockers"), + }, + target_kind="edge", + target_id=edge_id, + ) + + def chamfer_edge(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再添加倒角。"): + return + if self.selected_edge_id is None: + QMessageBox.information(self, "未选择边", "请先选择一个直线 edge。") + return + try: + distance = float(self.edge_chamfer_distance_input.text()) + except ValueError: + QMessageBox.critical(self, "距离无效", "请输入数字形式的倒角距离。") + return + + edge_id = self.selected_edge_id + plan = self.model.edge_chamfer_plan(edge_id, distance) + 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"edge: {plan['edge_id']}\n" + f"边长: {_format_value(plan['edge_length'])}\n" + f"目标倒角距离: {_format_value(plan['target_distance'])}\n" + f"倒角距离/边长比例: {_format_percent(plan['distance_to_length_ratio'])}\n" + f"相邻 Face: {_format_value(plan.get('adjacent_face_ids', ''))}\n" + f"相邻 Face 数: {_format_value(plan.get('adjacent_face_count', ''))}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会对当前零件执行实验性 OCCT 倒角。\n\n" + "确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if result != QMessageBox.StandardButton.Yes: + self.statusBar().showMessage("已取消添加倒角") + return + + self._show_edge_chamfer_preview(edge_id, distance) + + def action(): + return self.model.chamfer_edge(edge_id, distance) + + self._run_edit_action( + action, + operation_name="给边添加倒角", + target=f"edge {edge_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "edge_length": plan.get("edge_length"), + "chamfer_distance": distance, + "distance_to_length_ratio": plan.get("distance_to_length_ratio"), + "curve": plan.get("curve"), + "adjacent_face_ids": plan.get("adjacent_face_ids"), + "adjacent_face_count": plan.get("adjacent_face_count"), + "chamfer_status": plan.get("status"), + "chamfer_risk": plan.get("risk"), + "chamfer_message": plan.get("message"), + "chamfer_warnings": plan.get("warnings"), + "chamfer_blockers": plan.get("blockers"), + }, + target_kind="edge", + target_id=edge_id, + ) + + def resize_edge_length(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再调整直线边长度。"): + return + if self.selected_edge_id is None: + QMessageBox.information(self, "未选择边", "请先选择一条直线 edge。") + return + try: + target_length = float(self.edge_target_length_input.text()) + except ValueError: + QMessageBox.critical(self, "目标长度无效", "请输入数字形式的目标边长。") + return + + edge_id = self.selected_edge_id + plan = self.model.straight_edge_length_plan(edge_id, target_length) + 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"edge: {plan['edge_id']}\n" + f"当前边长: {_format_value(plan['current_length'])}\n" + f"目标边长: {_format_value(plan['target_length'])}\n" + f"变化量: {_format_value(plan['delta_length'])}\n" + f"端面 face: {_format_value(plan.get('end_face_id', ''))}\n" + f"端面位置: {_format_value(plan.get('end_face_label', ''))}\n" + f"端面推拉距离: {_format_value(plan.get('push_pull_distance', ''))}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "第一版会通过移动端点附近的平面端面来改变边长,不是通用参数化边长编辑。\n\n" + "确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if result != QMessageBox.StandardButton.Yes: + self.statusBar().showMessage("已取消调整直线边长度") + return + + self._show_edge_length_preview(edge_id, target_length) + + def action(): + return self.model.resize_straight_edge_length(edge_id, target_length) + + self._run_edit_action( + action, + operation_name="调整直线边长度", + target=f"edge {edge_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "current_length": plan.get("current_length"), + "target_length": plan.get("target_length"), + "delta_length": plan.get("delta_length"), + "length_change_ratio": plan.get("length_change_ratio"), + "end_face_id": plan.get("end_face_id"), + "end_face_label": plan.get("end_face_label"), + "push_pull_distance": plan.get("push_pull_distance"), + "resize_strategy": plan.get("resize_strategy"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + }, + target_kind="edge", + target_id=edge_id, + ) + + def translate_selected_part(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再平移零件。"): + return + if self.selected_part_id is None: + QMessageBox.information(self, "未选择零件", "请先选择一个 part,或选择属于某个 part 的对象。") + return + try: + vector = self._translation_vector_from_inputs() + except ValueError as exc: + QMessageBox.critical(self, "平移向量无效", str(exc)) + return + part_id = self.selected_part_id + plan = self.model.translate_part_plan(part_id, vector) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能平移零件", str(plan["message"])) + self.statusBar().showMessage("平移零件已阻止") + return + if not self._confirm_translation_plan("确认平移零件", plan): + self.statusBar().showMessage("已取消平移零件") + return + + def action(): + return self.model.translate_part(part_id, vector) + + self._run_edit_action( + action, + operation_name="平移零件", + target=f"part {part_id}", + parameters={ + "part_id": part_id, + "translation_vector": vector, + "translation_distance": plan.get("translation_distance"), + "bbox_diagonal": plan.get("bbox_diagonal"), + "translate_status": plan.get("status"), + "translate_risk": plan.get("risk"), + "translate_message": plan.get("message"), + "translate_warnings": plan.get("warnings"), + "translate_blockers": plan.get("blockers"), + }, + target_kind="part", + target_id=part_id, + ) + + def translate_selected_solid(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再平移 solid。"): + return + if self.selected_solid_id is None: + QMessageBox.information(self, "未选择 solid", "请先选择一个 solid,或选择属于 solid 的 face/edge。") + return + try: + vector = self._translation_vector_from_inputs() + except ValueError as exc: + QMessageBox.critical(self, "平移向量无效", str(exc)) + return + solid_id = self.selected_solid_id + plan = self.model.translate_solid_plan(solid_id, vector) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能平移 solid", str(plan["message"])) + self.statusBar().showMessage("平移 solid 已阻止") + return + if not self._confirm_translation_plan("确认平移 solid", plan): + self.statusBar().showMessage("已取消平移 solid") + return + + def action(): + return self.model.translate_solid(solid_id, vector) + + self._run_edit_action( + action, + operation_name="平移 solid", + target=f"solid {solid_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": solid_id, + "part_solid_count": plan.get("part_solid_count"), + "translation_vector": vector, + "translation_distance": plan.get("translation_distance"), + "bbox_diagonal": plan.get("bbox_diagonal"), + "translate_status": plan.get("status"), + "translate_risk": plan.get("risk"), + "translate_message": plan.get("message"), + "translate_warnings": plan.get("warnings"), + "translate_blockers": plan.get("blockers"), + }, + target_kind="solid", + target_id=solid_id, + ) + + def _translation_vector_from_inputs(self) -> tuple[float, float, float]: + try: + return ( + float(self.translate_x_input.text()), + float(self.translate_y_input.text()), + float(self.translate_z_input.text()), + ) + except ValueError as exc: + raise ValueError("请输入数字形式的 X/Y/Z 平移量。") from exc + + def _confirm_translation_plan(self, title: str, plan: dict[str, object]) -> bool: + if plan["risk"] == "low": + return True + warnings = str(plan.get("warnings", "")) + warnings_line = f"警告: {warnings}\n\n" if warnings else "" + result = QMessageBox.question( + self, + title, + ( + f"对象: {plan.get('target_kind', '')}\n" + f"part: {_format_value(plan.get('part_id', ''))}\n" + f"solid: {_format_value(plan.get('solid_id', ''))}\n" + f"平移向量: {_format_value(plan['translation_vector'])}\n" + f"平移距离: {_format_value(plan['translation_distance'])}\n" + f"对象 bbox_diagonal: {_format_value(plan.get('bbox_diagonal', ''))}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会移动当前对象的 B-Rep 形状,并刷新模型拓扑。\n\n" + "确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + return result == QMessageBox.StandardButton.Yes + + def rotate_selected_part(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再旋转零件。"): + return + if self.selected_part_id is None: + QMessageBox.information(self, "未选择零件", "请先选择一个 part,或选择属于某个 part 的对象。") + return + try: + axis, angle = self._rotation_values_from_inputs() + except ValueError as exc: + QMessageBox.critical(self, "旋转参数无效", str(exc)) + return + part_id = self.selected_part_id + plan = self.model.rotate_part_plan(part_id, axis, angle) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能旋转零件", str(plan["message"])) + self.statusBar().showMessage("旋转零件已阻止") + return + if not self._confirm_rotation_plan("确认旋转零件", plan): + self.statusBar().showMessage("已取消旋转零件") + return + + def action(): + return self.model.rotate_part(part_id, axis, angle) + + self._run_edit_action( + action, + operation_name="旋转零件", + target=f"part {part_id}", + parameters={ + "part_id": part_id, + "rotation_axis": plan.get("rotation_axis"), + "rotation_angle_degrees": angle, + "rotation_center": plan.get("rotation_center"), + "bbox_diagonal": plan.get("bbox_diagonal"), + "rotate_status": plan.get("status"), + "rotate_risk": plan.get("risk"), + "rotate_message": plan.get("message"), + "rotate_warnings": plan.get("warnings"), + "rotate_blockers": plan.get("blockers"), + }, + target_kind="part", + target_id=part_id, + ) + + def rotate_selected_solid(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再旋转 solid。"): + return + if self.selected_solid_id is None: + QMessageBox.information(self, "未选择 solid", "请先选择一个 solid,或选择属于 solid 的 face/edge。") + return + try: + axis, angle = self._rotation_values_from_inputs() + except ValueError as exc: + QMessageBox.critical(self, "旋转参数无效", str(exc)) + return + solid_id = self.selected_solid_id + plan = self.model.rotate_solid_plan(solid_id, axis, angle) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能旋转 solid", str(plan["message"])) + self.statusBar().showMessage("旋转 solid 已阻止") + return + if not self._confirm_rotation_plan("确认旋转 solid", plan): + self.statusBar().showMessage("已取消旋转 solid") + return + + def action(): + return self.model.rotate_solid(solid_id, axis, angle) + + self._run_edit_action( + action, + operation_name="旋转 solid", + target=f"solid {solid_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": solid_id, + "part_solid_count": plan.get("part_solid_count"), + "rotation_axis": plan.get("rotation_axis"), + "rotation_angle_degrees": angle, + "rotation_center": plan.get("rotation_center"), + "bbox_diagonal": plan.get("bbox_diagonal"), + "rotate_status": plan.get("status"), + "rotate_risk": plan.get("risk"), + "rotate_message": plan.get("message"), + "rotate_warnings": plan.get("warnings"), + "rotate_blockers": plan.get("blockers"), + }, + target_kind="solid", + target_id=solid_id, + ) + + def _rotation_values_from_inputs(self) -> tuple[str, float]: + try: + angle = float(self.rotate_angle_input.text()) + except ValueError as exc: + raise ValueError("请输入数字形式的旋转角度。") from exc + return self.rotate_axis_combo.currentText(), angle + + def _confirm_rotation_plan(self, title: str, plan: dict[str, object]) -> bool: + if plan["risk"] == "low": + return True + warnings = str(plan.get("warnings", "")) + warnings_line = f"警告: {warnings}\n\n" if warnings else "" + result = QMessageBox.question( + self, + title, + ( + f"对象: {plan.get('target_kind', '')}\n" + f"part: {_format_value(plan.get('part_id', ''))}\n" + f"solid: {_format_value(plan.get('solid_id', ''))}\n" + f"旋转轴: {_format_value(plan['rotation_axis'])}\n" + f"旋转角度: {_format_value(plan['rotation_angle_degrees'])}\n" + f"旋转中心: {_format_value(plan['rotation_center'])}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会旋转当前对象的 B-Rep 形状,并刷新模型拓扑。\n\n" + "确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + return result == QMessageBox.StandardButton.Yes + def list_cylinders(self) -> None: self.refresh_cylinder_candidates(show_info=True) @@ -1682,17 +4234,19 @@ class StepEditorWindow(QMainWindow): if hasattr(self, "editable_table"): self.editable_table.setRowCount(0) - def refresh_editable_candidates(self, show_info: bool = True) -> None: + def refresh_editable_candidates(self, show_info: bool = True, deep_scan: bool = False) -> None: if self.model is None: return if self._edit_busy("编辑计算中,暂时不能扫描可编辑对象。"): return - self.statusBar().showMessage("正在扫描第一版可编辑对象...") + limit = 48 if deep_scan else 24 + scan_label = "深度扫描" if deep_scan else "扫描" + self.statusBar().showMessage(f"正在{scan_label}第一版可编辑对象...") QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) QApplication.processEvents() try: candidates = self.model.editable_feature_candidates( - limit=40, + limit=limit, progress_callback=QApplication.processEvents, ) except Exception as exc: @@ -1702,12 +4256,15 @@ class StepEditorWindow(QMainWindow): finally: QApplication.restoreOverrideCursor() self.editable_table.setRowCount(len(candidates)) - lines = [f"第一版可编辑对象:显示 {len(candidates)} 个"] + lines = [f"第一版可编辑对象:显示 {len(candidates)} 个({scan_label}上限 {limit})"] for row, item in enumerate(candidates): + target_kind = str(item.get("target_kind", "face")) + target_id = int(item.get("target_id", item.get("face_id", item.get("edge_id", -1)))) operation_item = QTableWidgetItem(str(item["operation"])) - operation_item.setData(EDITABLE_FACE_ROLE, int(item["face_id"])) + operation_item.setData(EDITABLE_TARGET_ID_ROLE, target_id) + operation_item.setData(EDITABLE_TARGET_KIND_ROLE, target_kind) operation_item.setData(EDITABLE_ACTION_ROLE, str(item["operation_key"])) - face_item = QTableWidgetItem(str(item["face_id"])) + id_item = QTableWidgetItem(f"{target_kind} {target_id}") object_item = QTableWidgetItem(str(item["feature_guess"])) current_item = QTableWidgetItem( f"{item['current_value_label']}={_format_value(item['current_value'])}" @@ -1718,7 +4275,7 @@ class StepEditorWindow(QMainWindow): note_item = QTableWidgetItem(str(item["note"])) row_items = [ operation_item, - face_item, + id_item, object_item, current_item, status_item, @@ -1730,7 +4287,7 @@ class StepEditorWindow(QMainWindow): table_item.setToolTip(table_item.text()) self.editable_table.setItem(row, column, table_item) lines.append( - f"face {item['face_id']}: {item['operation']}, " + f"{target_kind} {target_id}: {item['operation']}, " f"part={item['part_id']}, solid={item['solid_id']}, " f"object={item['feature_guess']}, " f"{item['current_value_label']}={_format_value(item['current_value'])}, " @@ -1738,7 +4295,7 @@ class StepEditorWindow(QMainWindow): f"confidence={item['confidence']}, note={item['note']}" ) self.editable_table.resizeColumnsToContents() - self.statusBar().showMessage(f"已扫描第一版可编辑对象:{len(candidates)} 个") + self.statusBar().showMessage(f"已{scan_label}第一版可编辑对象:{len(candidates)} 个") if show_info: self.set_plain_info("\n".join(lines)) @@ -1869,7 +4426,7 @@ class StepEditorWindow(QMainWindow): self._begin_edit_task(operation_name) self.pending_edit_context = context thread = QThread(self) - worker = EditWorker(self._make_edit_job(action)) + worker = EditWorker(self._make_edit_job(action, context)) worker.moveToThread(thread) thread.started.connect(worker.run) worker.finished.connect(self._finish_edit_action) @@ -1883,26 +4440,41 @@ class StepEditorWindow(QMainWindow): self.edit_worker = worker thread.start() - def _make_edit_job(self, action): + def _make_edit_job(self, action, context: dict[str, object]): def job(): if self.model is None: raise RuntimeError("Model is not loaded.") snapshot = self.model.snapshot() + target_part_id = self._edit_context_part_id(context) before_stats = self.model.stats() + before_part_stats = self._part_stats_or_none(target_part_id) before_geometry = self.model.geometry_stats() - result = action() - after_snapshot = self.model.snapshot() - after_stats = self.model.stats() - after_geometry = self.model.geometry_stats() - model_polydata = self.model.build_face_polydata() - edge_polydata = self.model.build_edge_polydata() + try: + result = action() + after_snapshot = self.model.snapshot() + after_stats = self.model.stats() + after_part_stats = self._part_stats_or_none(target_part_id) + after_geometry = self.model.geometry_stats() + model_polydata = self.model.build_face_polydata() + edge_polydata = self.model.build_edge_polydata() + except Exception as exc: + try: + self.model.restore_snapshot(snapshot) + except Exception as rollback_exc: + raise RuntimeError( + f"编辑失败,且回滚到操作前状态也失败:{rollback_exc}\n原始错误:{exc}" + ) from exc + raise RuntimeError(f"编辑失败,模型已恢复到操作前状态:{exc}") from exc return { "message": str(result), "snapshot": snapshot, "before_stats": before_stats, + "before_part_stats": before_part_stats, "before_geometry": before_geometry, "after_snapshot": after_snapshot, "after_stats": after_stats, + "after_part_stats": after_part_stats, + "quality_warnings": _edit_quality_warnings(before_part_stats, after_part_stats), "after_geometry": after_geometry, "model_polydata": model_polydata, "edge_polydata": edge_polydata, @@ -1910,11 +4482,72 @@ class StepEditorWindow(QMainWindow): return job + def _edit_context_part_id(self, context: dict[str, object]) -> int | None: + if self.model is None: + return None + parameters = context.get("parameters") + if isinstance(parameters, dict): + part_id = parameters.get("part_id") + if part_id not in {"", None}: + try: + return int(part_id) + except (TypeError, ValueError): + pass + + target_kind = context.get("target_kind") + target_id = context.get("target_id") + if target_id is None: + return None + try: + numeric_id = int(target_id) + except (TypeError, ValueError): + return None + + if target_kind == "part": + return numeric_id + if target_kind in {"face", "feature"} and 0 <= numeric_id < len(self.model.face_part_ids): + return int(self.model.face_part_ids[numeric_id]) + if target_kind == "edge" and 0 <= numeric_id < len(self.model.edge_part_ids): + return int(self.model.edge_part_ids[numeric_id]) + if target_kind == "solid" and 0 <= numeric_id < len(self.model.solids): + return int(self.model.solids[numeric_id][0]) + return None + + def _part_stats_or_none(self, part_id: int | None): + if self.model is None or part_id is None: + return None + try: + return self.model.part_topology_stats(part_id) + except Exception: + return None + def _begin_edit_task(self, operation_name: str) -> None: self.operation_in_progress = True + self._set_main_content_busy(True) self.statusBar().showMessage(f"{operation_name} 正在后台计算...") if "推拉" 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} 正在计算,绿色预览表示封堵补料范围..." + elif "孔深" in operation_name: + progress_text = f"{operation_name} 正在计算,红色预览表示加深切削范围,绿色预览表示变浅补料范围..." + elif "圆角" in operation_name: + if "已有" in operation_name: + progress_text = f"{operation_name} 正在计算,蓝色预览表示将移除并重建的已有圆角面..." + else: + 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} 正在后台计算,完成后会刷新模型姿态和拓扑索引..." else: progress_text = f"{operation_name} 正在后台计算..." self.edit_progress = QProgressDialog(progress_text, "", 0, 0, self) @@ -1957,6 +4590,9 @@ class StepEditorWindow(QMainWindow): result_message=message, before_stats=result["before_stats"], after_stats=result["after_stats"], + before_part_stats=result.get("before_part_stats"), + after_part_stats=result.get("after_part_stats"), + quality_warnings=list(result.get("quality_warnings", [])), before_geometry=dict(result["before_geometry"]), after_geometry=dict(result["after_geometry"]), target_kind=context["target_kind"], @@ -1965,9 +4601,14 @@ class StepEditorWindow(QMainWindow): before_snapshot=result["snapshot"], after_snapshot=result["after_snapshot"], ) + self.clear_edit_preview(render=False) + self._reset_selection() + self._populate_part_tree() + self._rebuild_scene_from_polydata(result["model_polydata"], result["edge_polydata"], reset_camera=False) 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) - QMessageBox.critical(self, "操作失败", str(exc)) + QMessageBox.critical(self, "操作失败", f"编辑结果刷新失败:{exc}\n\n{rollback_message}") self.statusBar().showMessage("编辑已完成,但刷新结果时失败") return @@ -1975,34 +4616,61 @@ class StepEditorWindow(QMainWindow): self.redo_stack.clear() self.operation_history.append(record) self.redo_history.clear() - self.clear_edit_preview(render=False) - self._reset_selection() - self._populate_part_tree() - self._rebuild_scene_from_polydata(result["model_polydata"], result["edge_polydata"], reset_camera=False) self._clear_editable_candidates() self._clear_cylinder_candidates() self._refresh_history_list() self._end_edit_task(clear_preview=False) - self.statusBar().showMessage(message) + if result.get("quality_warnings"): + self.statusBar().showMessage("编辑完成,但有质量警告,请查看操作历史详情") + else: + self.statusBar().showMessage(message) self.set_plain_info(record.detail) @Slot(str) def _fail_edit_action(self, message: str) -> None: self._end_edit_task(clear_preview=True) QMessageBox.critical(self, "操作失败", message) - self.statusBar().showMessage("操作失败,模型未更新") + self._clear_editable_candidates() + self._clear_cylinder_candidates() + self.statusBar().showMessage("操作失败,模型已保持在编辑前状态") + + def _restore_failed_edit_snapshot(self, snapshot: object) -> str: + if self.model is None or not isinstance(snapshot, dict): + return "未找到可用的编辑前快照,请重新加载 STEP 文件确认状态。" + try: + self.model.restore_snapshot(snapshot) + self._reset_selection() + self._populate_part_tree() + self._rebuild_scene(reset_camera=False) + self._clear_editable_candidates() + self._clear_cylinder_candidates() + except Exception as rollback_exc: + return f"尝试回滚到编辑前状态失败:{rollback_exc}。建议重新加载 STEP 文件。" + return "模型已回滚到编辑前状态。" def _end_edit_task(self, clear_preview: bool = True) -> None: self.operation_in_progress = False self.pending_edit_context = None if clear_preview: self.clear_edit_preview(render=False) + self._set_main_content_busy(False) if self.edit_progress is not None: self.edit_progress.close() self.edit_progress.deleteLater() self.edit_progress = None QApplication.processEvents() + def _set_main_content_busy(self, busy: bool) -> None: + content = self.centralWidget() + if content is None: + return + if busy: + self.main_content_enabled_before_edit = content.isEnabled() + content.setEnabled(False) + else: + content.setEnabled(self.main_content_enabled_before_edit) + self._update_action_states() + def _forget_edit_thread(self) -> None: self.edit_thread = None self.edit_worker = None @@ -2017,6 +4685,9 @@ class StepEditorWindow(QMainWindow): after_stats, before_geometry: dict[str, object], after_geometry: dict[str, object], + before_part_stats=None, + after_part_stats=None, + quality_warnings: list[str] | None = None, target_kind: str | None = None, target_id: int | None = None, pick_position: tuple[float, float, float] | None = None, @@ -2043,6 +4714,23 @@ class StepEditorWindow(QMainWindow): lines.insert(4, f"pick_position: {_format_value(pick_position)}") for key, value in parameters.items(): lines.append(f" {key}: {_format_value(value)}") + if before_part_stats is not None and after_part_stats is not None: + lines.extend( + [ + "target part topology before:", + f" solids: {before_part_stats.solids}", + f" faces: {before_part_stats.faces}", + f" edges: {before_part_stats.edges}", + "target part topology after:", + f" solids: {after_part_stats.solids} ({_signed_delta(after_part_stats.solids - before_part_stats.solids)})", + f" faces: {after_part_stats.faces} ({_signed_delta(after_part_stats.faces - before_part_stats.faces)})", + f" edges: {after_part_stats.edges} ({_signed_delta(after_part_stats.edges - before_part_stats.edges)})", + ] + ) + if quality_warnings: + lines.append("quality warnings:") + for warning in quality_warnings: + lines.append(f" {warning}") lines.extend( [ "topology before:", @@ -2170,6 +4858,71 @@ def _info_to_text(info: dict[str, object]) -> str: return "\n".join(f"{INFO_LABELS.get(key, key)}: {_format_value(value)}" for key, value in info.items()) +def _part_tree_kind_label(kind: str) -> str: + return { + "assembly": "装配", + "part": "零件", + "solid": "实体", + }.get(kind, kind or "对象") + + +def _enable_overlay_depth_offset(mapper) -> None: + """Draw coplanar overlays in front of the base model to avoid highlight flicker.""" + if hasattr(mapper, "SetResolveCoincidentTopologyToPolygonOffset"): + mapper.SetResolveCoincidentTopologyToPolygonOffset() + if hasattr(mapper, "SetRelativeCoincidentTopologyPolygonOffsetParameters"): + mapper.SetRelativeCoincidentTopologyPolygonOffsetParameters(-6.0, -6.0) + if hasattr(mapper, "SetRelativeCoincidentTopologyLineOffsetParameters"): + mapper.SetRelativeCoincidentTopologyLineOffsetParameters(-8.0, -8.0) + if hasattr(mapper, "SetRelativeCoincidentTopologyPointOffsetParameter"): + mapper.SetRelativeCoincidentTopologyPointOffsetParameter(-8.0) + + +def _format_percent(value: object) -> str: + if value is None or value == "": + return "" + try: + return f"{float(value) * 100.0:.6g}%" + except (TypeError, ValueError): + return str(value) + + +def _edit_quality_warnings(before_part_stats, after_part_stats) -> list[str]: + if before_part_stats is None or after_part_stats is None: + return [] + warnings: list[str] = [] + if after_part_stats.solids == 0: + warnings.append("目标零件编辑后没有检测到 solid,导出前请确认模型是否有效。") + elif before_part_stats.solids > 0 and after_part_stats.solids != before_part_stats.solids: + warnings.append( + "目标零件 solid 数发生变化:" + f"{before_part_stats.solids} -> {after_part_stats.solids}。" + "如果这是一次局部推拉/孔径修改,请重点检查导出后是否仍是一体实体。" + ) + return warnings + + +def _export_quality_text(info: dict[str, object]) -> str: + warnings = str(info.get("quality_warnings", "")) + lines = [ + "导出质量检查:", + f" 对象: {info.get('quality_label', '')}", + f" 状态: {info.get('quality_status', '')}", + f" B-Rep 有效: {_format_value(info.get('brep_valid', ''))}", + f" solids: {_format_value(info.get('solids', ''))}", + f" faces: {_format_value(info.get('faces', ''))}", + f" edges: {_format_value(info.get('edges', ''))}", + f" vertices: {_format_value(info.get('vertices', ''))}", + f" volume: {_format_value(info.get('volume', ''))}", + f" bbox_diagonal: {_format_value(info.get('bbox_diagonal', ''))}", + ] + if warnings: + lines.extend([" 警告:", f" {warnings}"]) + else: + lines.append(" 警告: 无") + return "\n".join(lines) + + def _format_value(value: object) -> str: if isinstance(value, float): return _format_float(value) @@ -2178,6 +4931,52 @@ def _format_value(value: object) -> str: return str(value) +def _int_values(value: object) -> list[int]: + if value is None or value == "": + return [] + if isinstance(value, int): + return [value] + if isinstance(value, (list, tuple, set)): + result: list[int] = [] + for item in value: + try: + result.append(int(item)) + except (TypeError, ValueError): + continue + return result + return [] + + +def _float_or_none(value: object) -> float | None: + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _merge_polydata_bounds(*polydatas) -> tuple[float, float, float, float, float, float] | None: + merged: list[float] | None = None + for polydata in polydatas: + if polydata is None or polydata.GetNumberOfPoints() <= 0: + continue + bounds = polydata.GetBounds() + if bounds is None or bounds[0] > bounds[1] or bounds[2] > bounds[3] or bounds[4] > bounds[5]: + continue + values = [float(item) for item in bounds] + if merged is None: + merged = values + else: + merged[0] = min(merged[0], values[0]) + merged[1] = max(merged[1], values[1]) + merged[2] = min(merged[2], values[2]) + merged[3] = max(merged[3], values[3]) + merged[4] = min(merged[4], values[4]) + merged[5] = max(merged[5], values[5]) + return tuple(merged) if merged is not None else None + + def _format_after_delta(before: dict[str, object], after: dict[str, object], key: str) -> str: before_value = before.get(key) after_value = after.get(key) diff --git a/step_model.py b/step_model.py index 6f6b355..c58b456 100644 --- a/step_model.py +++ b/step_model.py @@ -8,11 +8,12 @@ from typing import Callable, Iterable from OCC.Core.BRep import BRep_Tool from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface -from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse +from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse from OCC.Core.BRepBndLib import brepbndlib from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform from OCC.Core.BRepCheck import BRepCheck_Analyzer from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier +from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet from OCC.Core.BRepGProp import brepgprop from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism @@ -43,6 +44,8 @@ from OCC.Core.IFSelect import IFSelect_RetDone from OCC.Core.Interface import Interface_Static from OCC.Core.STEPCAFControl import STEPCAFControl_Reader from OCC.Core.STEPControl import STEPControl_AsIs, STEPControl_Reader, STEPControl_Writer +from OCC.Core.ShapeFix import ShapeFix_Shape +from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain from OCC.Core.TDF import TDF_Label, TDF_LabelSequence from OCC.Core.TDocStd import TDocStd_Document from OCC.Core.TopAbs import ( @@ -56,11 +59,12 @@ from OCC.Core.TopAbs import ( TopAbs_REVERSED, TopAbs_SOLID, ) -from OCC.Core.TopExp import TopExp_Explorer +from OCC.Core.TopExp import TopExp_Explorer, topexp from OCC.Core.TopLoc import TopLoc_Location from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods +from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_IndexedMapOfShape from OCC.Core.XCAFDoc import XCAFDoc_DocumentTool -from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt, gp_Vec +from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge @@ -127,7 +131,12 @@ class StepModel: self.face_solid_ids: list[int] = [] self.edges: list[TopoDS_Shape] = [] self.edge_part_ids: list[int] = [] + self.edge_solid_ids: list[int] = [] self.solids: list[tuple[int, TopoDS_Shape]] = [] + self._face_info_cache: dict[int, dict[str, object]] = {} + self._edge_info_cache: dict[int, dict[str, object]] = {} + self._face_edge_ids_cache: dict[int, list[int]] = {} + self._edge_face_ids_cache: dict[int, list[int]] = {} self.refresh_topology() @classmethod @@ -160,6 +169,19 @@ class StepModel: vertices=len(list(topo.vertices())), ) + def part_topology_stats(self, part_id: int) -> TopologyStats: + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + topo = TopologyExplorer(part.shape, ignore_orientation=True) + return TopologyStats( + parts=1, + solids=len(list(topo.solids())), + faces=len(list(topo.faces())), + edges=len(list(topo.edges())), + vertices=len(list(topo.vertices())), + ) + def geometry_stats(self) -> dict[str, object]: surface_props = GProp_GProps() brepgprop.SurfaceProperties(self.shape, surface_props) @@ -178,28 +200,74 @@ class StepModel: self.face_solid_ids.clear() self.edges.clear() self.edge_part_ids.clear() + self.edge_solid_ids.clear() self.solids.clear() + self._face_info_cache.clear() + self._edge_info_cache.clear() + self._face_edge_ids_cache.clear() + self._edge_face_ids_cache.clear() solid_id = 0 for part in self.display_parts(): part_solids = _explore(part.shape, TopAbs_SOLID) + part_solid_edge_maps: list[tuple[int, TopTools_IndexedDataMapOfShapeListOfShape]] = [] + part_solid_entries: list[tuple[int, TopoDS_Shape]] = [] if part_solids: for solid in part_solids: + current_solid_id = solid_id self.solids.append((part.id, solid)) + part_solid_entries.append((current_solid_id, solid)) + edge_map = TopTools_IndexedDataMapOfShapeListOfShape() + topexp.MapShapesAndAncestors(solid, TopAbs_EDGE, TopAbs_SOLID, edge_map) + part_solid_edge_maps.append((current_solid_id, edge_map)) + solid_id += 1 + + part_edge_map = TopTools_IndexedMapOfShape() + topexp.MapShapes(part.shape, TopAbs_EDGE, part_edge_map) + part_edge_ids_by_index: dict[int, int] = {} + for local_edge_index in range(1, part_edge_map.Size() + 1): + edge = part_edge_map.FindKey(local_edge_index) + edge_id = len(self.edges) + part_edge_ids_by_index[local_edge_index] = edge_id + self.edges.append(edge) + self.edge_part_ids.append(part.id) + self.edge_solid_ids.append(_mapped_edge_solid_id(edge, part_solid_edge_maps)) + self._edge_face_ids_cache[edge_id] = [] + + if part_solids: + for current_solid_id, solid in part_solid_entries: for face in _explore(solid, TopAbs_FACE): + face_id = len(self.faces) self.faces.append(face) self.face_part_ids.append(part.id) - self.face_solid_ids.append(solid_id) - solid_id += 1 + self.face_solid_ids.append(current_solid_id) + self._cache_face_edge_links(face_id, face, part_edge_map, part_edge_ids_by_index) else: for face in _explore(part.shape, TopAbs_FACE): + face_id = len(self.faces) self.faces.append(face) self.face_part_ids.append(part.id) self.face_solid_ids.append(-1) + self._cache_face_edge_links(face_id, face, part_edge_map, part_edge_ids_by_index) - for edge in TopologyExplorer(part.shape, ignore_orientation=True).edges(): - self.edges.append(edge) - self.edge_part_ids.append(part.id) + def _cache_face_edge_links( + self, + face_id: int, + face: TopoDS_Shape, + part_edge_map: TopTools_IndexedMapOfShape, + part_edge_ids_by_index: dict[int, int], + ) -> None: + face_edge_ids: list[int] = [] + face_edge_map = TopTools_IndexedMapOfShape() + topexp.MapShapes(face, TopAbs_EDGE, face_edge_map) + for local_face_edge_index in range(1, face_edge_map.Size() + 1): + local_part_edge_index = part_edge_map.FindIndex(face_edge_map.FindKey(local_face_edge_index)) + edge_id = part_edge_ids_by_index.get(local_part_edge_index) + if edge_id is None: + continue + face_edge_ids.append(edge_id) + self._edge_face_ids_cache.setdefault(edge_id, []).append(face_id) + self._face_edge_ids_cache[face_id] = face_edge_ids def part_by_id(self, part_id: int) -> PartNode | None: return next((p for p in self.parts if p.id == part_id), None) @@ -214,6 +282,8 @@ class StepModel: self.refresh_topology() def face_info(self, face_id: int) -> dict[str, object]: + if face_id in self._face_info_cache: + return dict(self._face_info_cache[face_id]) face = self.faces[face_id] props = GProp_GProps() brepgprop.SurfaceProperties(face, props) @@ -271,6 +341,9 @@ class StepModel: info["note"] = classification["note"] info.update(self._cylinder_end_opening_info(face_id, surf)) info.update(_cylinder_resize_readiness(info)) + info.update(_cylinder_boss_resize_readiness(info)) + info.update(_cylinder_depth_readiness(info)) + info.update(_cylinder_suppress_readiness(info)) elif surface_type == GeomAbs_Cone: cone = surf.Cone() info["axis_point"] = _point_tuple(cone.Location()) @@ -288,9 +361,492 @@ class StepModel: info["axis"] = _dir_tuple(torus.Axis().Direction()) info["major_radius"] = torus.MajorRadius() info["minor_radius"] = torus.MinorRadius() - return info + self._face_info_cache[face_id] = dict(info) + return dict(info) + + def feature_info(self, face_id: int) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + info = self.face_info(face_id) + surface = str(info.get("surface", "")) + if surface == "cylinder": + return self._cylindrical_feature_info(face_id, info) + if surface == "plane": + return self._planar_feature_info(face_id, info) + boundary_edge_ids = self._face_boundary_edge_ids(face_id) + result = dict(info) + result.update( + { + "kind": "feature", + "feature_type": "暂不支持的局部曲面候选", + "feature_source_face_id": face_id, + "feature_face_ids": (face_id,), + "feature_highlight_face_ids": (face_id,), + "feature_boundary_edge_ids": tuple(boundary_edge_ids), + "feature_edit_actions": "当前第一版只能查看该局部曲面,暂不支持直接编辑。", + "feature_mode": "Feature 模式会把选中的 face 解释为局部几何特征候选。", + } + ) + return result + + def _planar_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]: + coplanar_face_ids = self._connected_coplanar_planar_face_ids(face_id) + boundary_edge_ids = self._region_boundary_edge_ids(coplanar_face_ids) + if len(coplanar_face_ids) > 1: + scope_note = f"已检测到 {len(coplanar_face_ids)} 个共享边且共面的 face,推拉时会作为同一片平面区域处理。" + else: + scope_note = "当前 face 没有检测到可一起推拉的共享边共面邻居。" + result = dict(info) + result.update( + { + "kind": "feature", + "feature_type": "可推拉平面候选", + "feature_source_face_id": face_id, + "feature_face_ids": tuple(coplanar_face_ids), + "feature_highlight_face_ids": tuple(coplanar_face_ids), + "feature_boundary_edge_ids": tuple(boundary_edge_ids), + "feature_adjacent_face_ids": tuple( + sorted(set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - set(coplanar_face_ids)) + ), + "push_pull_scope_face_ids": tuple(coplanar_face_ids), + "push_pull_scope_face_count": len(coplanar_face_ids), + "push_pull_scope_note": scope_note, + "feature_edit_actions": "推拉平面", + "feature_mode": "这是从 B-Rep 几何推断出的平面编辑候选,不是 CAD 历史特征。", + } + ) + return result + + def _cylindrical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]: + boundary_edge_ids = self._face_boundary_edge_ids(face_id) + adjacent_face_ids = self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id) + end_faces = self._cylindrical_end_face_groups(face_id, adjacent_face_ids, info) + end_face_ids = end_faces["end_face_ids"] + bottom_face_ids = end_faces["bottom_face_ids"] + opening_face_ids = end_faces["opening_face_ids"] + slot_info = self._cylindrical_slot_info(face_id, adjacent_face_ids, end_face_ids, info) + fillet_info = self._cylindrical_existing_fillet_info(face_id, adjacent_face_ids, end_face_ids, info) + + guess = str(info.get("feature_guess", "cylindrical face")) + angular_span = float(info.get("angular_span", 0.0)) + if guess == "hole/groove candidate": + if angular_span < math.tau * 0.92: + feature_type = "槽/半孔候选" + else: + feature_type = "圆柱孔候选" + if info.get("cylinder_end_type") == "blind" and bottom_face_ids: + edit_actions = "调整圆柱孔径;调整盲孔深度" + else: + edit_actions = "调整圆柱孔径;孔深调整需要明确盲孔底面" + if angular_span >= math.tau * 0.92: + edit_actions += ";封堵圆柱孔" + elif guess == "round/fillet candidate": + feature_type = "圆角/倒圆候选" + edit_actions = "当前可识别已有圆角半径和相邻支撑面;已有圆角半径修改尚未实现。" + elif guess == "boss/outer-round candidate": + feature_type = "凸台/外圆候选" + edit_actions = "调整圆柱凸台直径。" + else: + feature_type = "未明确圆柱特征" + edit_actions = "可尝试调整圆柱孔径,但风险较高。" + + highlight_face_ids = tuple( + sorted( + { + face_id, + *end_face_ids, + *slot_info.get("feature_slot_boundary_face_ids", ()), + *fillet_info.get("feature_existing_fillet_support_face_ids", ()), + } + ) + ) + result = dict(info) + result.update( + { + "kind": "feature", + "feature_type": feature_type, + "feature_source_face_id": face_id, + "feature_face_ids": (face_id,), + "feature_side_face_ids": (face_id,), + "feature_end_face_ids": tuple(end_face_ids), + "feature_bottom_face_ids": tuple(bottom_face_ids), + "feature_opening_face_ids": tuple(opening_face_ids), + "feature_highlight_face_ids": highlight_face_ids, + "feature_boundary_edge_ids": tuple(boundary_edge_ids), + "feature_adjacent_face_ids": tuple(adjacent_face_ids), + "feature_start_end_face_ids": tuple(end_faces["start_end_face_ids"]), + "feature_end_end_face_ids": tuple(end_faces["end_end_face_ids"]), + "feature_bottom_confidence": end_faces["bottom_confidence"], + "feature_bottom_detection": end_faces["bottom_detection"], + "feature_bottom_note": end_faces["bottom_note"], + "feature_edit_actions": edit_actions, + "feature_mode": "这是从 B-Rep 圆柱面、相邻面和材料采样推断出的局部特征候选。", + **slot_info, + **fillet_info, + } + ) + return result + + def _cylindrical_slot_info( + self, + face_id: int, + adjacent_face_ids: list[int], + end_face_ids: Iterable[int], + info: dict[str, object], + ) -> dict[str, object]: + guess = str(info.get("feature_guess", "cylindrical face")) + angular_span = float(info.get("angular_span", 0.0)) + if guess != "hole/groove candidate" or angular_span >= math.tau * 0.92: + return {} + + radius = max(float(info.get("radius", 0.0)), 0.0) + span = min(max(angular_span, 0.0), math.tau) + boundary_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids)) + chord_width = 2.0 * radius * math.sin(span / 2.0) if radius > 0 else 0.0 + sagitta_depth = radius * (1.0 - math.cos(min(span, math.pi) / 2.0)) if radius > 0 else 0.0 + return { + "slot_kind": "partial-cylindrical-groove", + "slot_status": "candidate", + "slot_angular_span": angular_span, + "slot_open_angle": max(math.tau - span, 0.0), + "slot_chord_width_estimate": chord_width, + "slot_arc_length_estimate": radius * span, + "slot_sagitta_depth_estimate": sagitta_depth, + "feature_slot_face_ids": (face_id,), + "feature_slot_boundary_face_ids": tuple(boundary_face_ids), + "slot_note": ( + "这是由局部圆柱面推断出的槽/半孔候选;宽度和深度是几何估算," + "不是 CAD 历史里的参数。" + ), + } + + def _cylindrical_existing_fillet_info( + self, + face_id: int, + adjacent_face_ids: list[int], + end_face_ids: Iterable[int], + info: dict[str, object], + ) -> dict[str, object]: + if str(info.get("feature_guess", "cylindrical face")) != "round/fillet candidate": + return {} + + radius = max(float(info.get("radius", 0.0)), 0.0) + angular_span = min(max(float(info.get("angular_span", 0.0)), 0.0), math.tau) + support_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids)) + return { + "existing_fillet_kind": "cylindrical-round-face", + "existing_fillet_status": "candidate", + "existing_fillet_radius_estimate": radius, + "existing_fillet_angular_span": angular_span, + "existing_fillet_arc_length_estimate": radius * angular_span, + "feature_existing_fillet_face_ids": (face_id,), + "feature_existing_fillet_support_face_ids": tuple(support_face_ids), + "existing_fillet_note": ( + "这是由局部小半径圆柱面推断出的已有圆角/倒圆候选;" + "第一版可尝试使用 defeature + 重新倒圆修改半径;" + "复杂 blend 或支撑面不明确时可能失败并回滚。" + ), + } + + def _cylindrical_end_face_groups( + self, + face_id: int, + adjacent_face_ids: list[int], + info: dict[str, object], + ) -> dict[str, object]: + axis_point_values = info.get("axis_point") + axis_values = info.get("axis") + v_range = info.get("v_range") + if not isinstance(axis_point_values, tuple) or not isinstance(axis_values, tuple) or not isinstance(v_range, tuple): + return { + "end_face_ids": [], + "start_end_face_ids": [], + "end_end_face_ids": [], + "bottom_face_ids": [], + "opening_face_ids": [], + "bottom_note": "缺少圆柱轴线或参数范围,无法判断端面/底面。", + } + + axis_point = gp_Pnt(*axis_point_values) + axis_dir = gp_Dir(float(axis_values[0]), float(axis_values[1]), float(axis_values[2])) + v_min = min(float(v_range[0]), float(v_range[1])) + v_max = max(float(v_range[0]), float(v_range[1])) + span = max(v_max - v_min, 1e-9) + radius = float(info.get("radius", 0.0)) + tolerance = max(span * 0.08, radius * 0.2, 0.05) + + start_end_face_ids: list[int] = [] + end_end_face_ids: list[int] = [] + for adjacent_id in adjacent_face_ids: + match = self._axis_end_match_for_planar_face( + adjacent_id, + axis_point, + axis_dir, + v_min, + v_max, + tolerance, + radial_tolerance=None, + ) + if match == "start": + start_end_face_ids.append(adjacent_id) + elif match == "end": + end_end_face_ids.append(adjacent_id) + + if info.get("cylinder_end_type") == "blind" and (not start_end_face_ids or not end_end_face_ids): + scanned = self._axis_cap_face_candidates( + face_id, + axis_point, + axis_dir, + v_min, + v_max, + radius, + span, + set(adjacent_face_ids), + ) + if not start_end_face_ids: + start_end_face_ids.extend(scanned["start"]) + if not end_end_face_ids: + end_end_face_ids.extend(scanned["end"]) + + bottom_face_ids: list[int] = [] + opening_face_ids: list[int] = [] + if info.get("start_end_open") is True: + opening_face_ids.extend(start_end_face_ids) + elif info.get("start_end_state") == "inside": + bottom_face_ids.extend(start_end_face_ids) + if info.get("end_end_open") is True: + opening_face_ids.extend(end_end_face_ids) + elif info.get("end_end_state") == "inside": + bottom_face_ids.extend(end_end_face_ids) + + end_face_ids = sorted({*start_end_face_ids, *end_end_face_ids}) + bottom_face_ids = sorted(set(bottom_face_ids)) + opening_face_ids = sorted(set(opening_face_ids)) + bottom_detection = "axis-cap-scan" if any( + face_id not in adjacent_face_ids for face_id in bottom_face_ids + ) else "adjacent-end-face" + bottom_confidence = "medium" if bottom_detection == "axis-cap-scan" else "high" + if not end_face_ids: + note = "没有在圆柱边界附近找到平面端面。" + elif bottom_face_ids: + if bottom_detection == "axis-cap-scan": + note = "已通过轴线端部采样和轴线附近圆盘面扫描标记疑似底面;这是几何推断,不是 CAD 历史孔深。" + else: + note = "已根据圆柱轴线端部 inside/outside 采样标记疑似底面;这是几何推断,不是 CAD 历史孔深。" + else: + note = "已找到端面候选,但端部采样显示这些端面更像开口附近的相邻面。" + return { + "end_face_ids": end_face_ids, + "start_end_face_ids": sorted(set(start_end_face_ids)), + "end_end_face_ids": sorted(set(end_end_face_ids)), + "bottom_face_ids": bottom_face_ids, + "opening_face_ids": opening_face_ids, + "bottom_confidence": bottom_confidence if bottom_face_ids else "none", + "bottom_detection": bottom_detection if bottom_face_ids else "none", + "bottom_note": note, + } + + def _axis_end_match_for_planar_face( + self, + face_id: int, + axis_point: gp_Pnt, + axis_dir: gp_Dir, + v_min: float, + v_max: float, + tolerance: float, + radial_tolerance: float | None, + ) -> str | None: + surf = BRepAdaptor_Surface(self.faces[face_id]) + if surf.GetType() != GeomAbs_Plane: + return None + normal = surf.Plane().Axis().Direction() + if abs(_direction_dot(normal, axis_dir)) < 0.65: + return None + if radial_tolerance is not None: + center = _surface_center(self.faces[face_id]) + if _point_axis_distance(axis_point, axis_dir, center) > radial_tolerance: + return None + parameters = _shape_axis_parameters(self.faces[face_id], axis_point, axis_dir) + if not parameters: + return None + start_distance = min(abs(parameter - v_min) for parameter in parameters) + end_distance = min(abs(parameter - v_max) for parameter in parameters) + if min(start_distance, end_distance) > tolerance: + return None + return "start" if start_distance <= end_distance else "end" + + def _axis_cap_face_candidates( + self, + face_id: int, + axis_point: gp_Pnt, + axis_dir: gp_Dir, + v_min: float, + v_max: float, + radius: float, + span: float, + adjacent_face_ids: set[int], + ) -> dict[str, list[int]]: + source_solid_id = self.face_solid_ids[face_id] + tolerance = max(span * 0.12, radius * 0.35, 0.08) + radial_tolerance = max(radius * 1.2, tolerance) + start: list[int] = [] + end: list[int] = [] + for candidate_id in range(len(self.faces)): + if candidate_id == face_id or candidate_id in adjacent_face_ids: + continue + if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id: + continue + match = self._axis_end_match_for_planar_face( + candidate_id, + axis_point, + axis_dir, + v_min, + v_max, + tolerance, + radial_tolerance=radial_tolerance, + ) + if match == "start": + start.append(candidate_id) + elif match == "end": + end.append(candidate_id) + return {"start": sorted(set(start)), "end": sorted(set(end))} + + def _bottom_face_axis_parameter( + self, + bottom_face_ids: Iterable[int], + axis_point: gp_Pnt, + axis_dir: gp_Dir, + expected_parameter: float, + ) -> float | None: + candidates: list[float] = [] + for bottom_face_id in bottom_face_ids: + if bottom_face_id < 0 or bottom_face_id >= len(self.faces): + continue + parameters = _shape_axis_parameters(self.faces[bottom_face_id], axis_point, axis_dir) + if not parameters: + continue + candidates.append(sum(parameters) / len(parameters)) + if not candidates: + return None + return min(candidates, key=lambda parameter: abs(parameter - expected_parameter)) + + def _face_boundary_edge_ids(self, face_id: int) -> list[int]: + if face_id in self._face_edge_ids_cache: + return list(self._face_edge_ids_cache[face_id]) + if face_id < 0 or face_id >= len(self.faces): + return [] + face_edges = list(TopologyExplorer(self.faces[face_id], ignore_orientation=True).edges()) + edge_ids: list[int] = [] + for edge_id, edge in enumerate(self.edges): + if any(_same_shape(edge, face_edge) for face_edge in face_edges): + edge_ids.append(edge_id) + self._face_edge_ids_cache[face_id] = list(edge_ids) + return edge_ids + + def face_boundary_edge_ids(self, face_id: int) -> list[int]: + if face_id < 0 or face_id >= len(self.faces): + return [] + return self._face_boundary_edge_ids(face_id) + + def nearest_edge_id_to_point( + self, + edge_ids: Iterable[int], + point: tuple[float, float, float] | None, + ) -> int | None: + valid_edge_ids = [int(edge_id) for edge_id in edge_ids if 0 <= int(edge_id) < len(self.edges)] + if not valid_edge_ids: + return None + if point is None: + return valid_edge_ids[0] + + px, py, pz = (float(point[0]), float(point[1]), float(point[2])) + best_edge_id: int | None = None + best_distance = math.inf + for edge_id in valid_edge_ids: + try: + samples = discretize_edge(self.edges[edge_id], 0.35) + except Exception: + samples = [] + if len(samples) < 2: + try: + curve = BRepAdaptor_Curve(self.edges[edge_id]) + samples = [ + _point_tuple(curve.Value(curve.FirstParameter())), + _point_tuple(curve.Value(curve.LastParameter())), + ] + except Exception: + samples = [] + if not samples: + continue + sample_points = [(float(coords[0]), float(coords[1]), float(coords[2])) for coords in samples] + if len(sample_points) == 1: + distance = _point_distance_sq((px, py, pz), sample_points[0]) + else: + distance = min( + _point_segment_distance_sq((px, py, pz), start, end) + for start, end in zip(sample_points, sample_points[1:]) + ) + if distance < best_distance: + best_distance = distance + best_edge_id = edge_id + return best_edge_id if best_edge_id is not None else valid_edge_ids[0] + + def _adjacent_face_ids_for_edges(self, edge_ids: list[int], face_id: int) -> list[int]: + if not edge_ids: + return [] + target_edges = [self.edges[edge_id] for edge_id in edge_ids if 0 <= edge_id < len(self.edges)] + source_solid_id = self.face_solid_ids[face_id] + adjacent: list[int] = [] + for candidate_id, candidate in enumerate(self.faces): + if candidate_id == face_id: + continue + if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id: + continue + candidate_edges = TopologyExplorer(candidate, ignore_orientation=True).edges() + if any(_same_shape(candidate_edge, target_edge) for candidate_edge in candidate_edges for target_edge in target_edges): + adjacent.append(candidate_id) + return adjacent + + def _connected_coplanar_planar_face_ids(self, face_id: int) -> list[int]: + if face_id < 0 or face_id >= len(self.faces): + return [] + source_surf = BRepAdaptor_Surface(self.faces[face_id]) + if source_surf.GetType() != GeomAbs_Plane: + return [face_id] + + source_solid_id = self.face_solid_ids[face_id] + tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3) + visited = {face_id} + queue = [face_id] + while queue: + current_id = queue.pop(0) + for adjacent_id in self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(current_id), current_id): + if adjacent_id in visited: + continue + if source_solid_id >= 0 and self.face_solid_ids[adjacent_id] != source_solid_id: + continue + candidate_surf = BRepAdaptor_Surface(self.faces[adjacent_id]) + if _surfaces_are_coplanar(source_surf, candidate_surf, tolerance): + visited.add(adjacent_id) + queue.append(adjacent_id) + return sorted(visited) + + def _region_boundary_edge_ids(self, face_ids: Iterable[int]) -> list[int]: + counts: dict[int, int] = {} + for face_id in face_ids: + for edge_id in self._face_boundary_edge_ids(face_id): + counts[edge_id] = counts.get(edge_id, 0) + 1 + return sorted(edge_id for edge_id, count in counts.items() if count == 1) + + def _push_pull_profile_shape(self, face_ids: Iterable[int]) -> TopoDS_Shape: + profile_faces = [self.faces[face_id] for face_id in face_ids if 0 <= face_id < len(self.faces)] + if not profile_faces: + raise ValueError("No planar faces were found for push/pull.") + return _unify_same_domain_shape(_compound_from_shapes(profile_faces)) def edge_info(self, edge_id: int) -> dict[str, object]: + if edge_id in self._edge_info_cache: + return dict(self._edge_info_cache[edge_id]) edge = self.edges[edge_id] props = GProp_GProps() brepgprop.LinearProperties(edge, props) @@ -301,6 +857,7 @@ class StepModel: "kind": "edge", "edge_id": edge_id, "part_id": self.edge_part_ids[edge_id], + "solid_id": self._edge_solid_id(edge_id), "orientation": _orientation_name(edge.Orientation()), "curve": CURVE_TYPES.get(curve_type, f"type {curve_type}"), "length": props.Mass(), @@ -321,7 +878,39 @@ class StepModel: info["axis"] = _dir_tuple(circle.Axis().Direction()) info["radius"] = circle.Radius() info["diameter"] = circle.Radius() * 2.0 - return info + adjacent_face_ids = self._edge_adjacent_face_ids(edge_id) + info["adjacent_face_ids"] = tuple(adjacent_face_ids) + info["adjacent_face_count"] = len(adjacent_face_ids) + self._edge_info_cache[edge_id] = dict(info) + return dict(info) + + def _edge_solid_id(self, edge_id: int) -> int: + if edge_id < 0 or edge_id >= len(self.edges): + return -1 + return self.edge_solid_ids[edge_id] if edge_id < len(self.edge_solid_ids) else -1 + + def edge_ids_for_solid(self, solid_id: int) -> list[int]: + if solid_id < 0 or solid_id >= len(self.solids): + raise ValueError(f"Unknown solid id {solid_id}") + return [edge_id for edge_id in range(len(self.edges)) if self._edge_solid_id(edge_id) == solid_id] + + def _edge_adjacent_face_ids(self, edge_id: int) -> list[int]: + if edge_id < 0 or edge_id >= len(self.edges): + return [] + if edge_id in self._edge_face_ids_cache: + return list(self._edge_face_ids_cache[edge_id]) + edge = self.edges[edge_id] + part_id = self.edge_part_ids[edge_id] + solid_id = self._edge_solid_id(edge_id) + adjacent: list[int] = [] + for face_id, face in enumerate(self.faces): + if self.face_part_ids[face_id] != part_id: + continue + if solid_id >= 0 and self.face_solid_ids[face_id] != solid_id: + continue + if any(_same_shape(candidate, edge) for candidate in TopologyExplorer(face, ignore_orientation=True).edges()): + adjacent.append(face_id) + return adjacent def solid_info(self, solid_id: int) -> dict[str, object]: if solid_id < 0 or solid_id >= len(self.solids): @@ -371,31 +960,168 @@ class StepModel: detailed: bool = False, progress_callback: Callable[[], None] | None = None, ) -> list[dict[str, object]]: - per_type_limit = max(1, limit // 2) + per_type_limit = max(1, limit // 5) candidates: list[dict[str, object]] = [] + diameter_count = 0 + boss_diameter_count = 0 + depth_count = 0 + suppress_count = 0 + existing_fillet_count = 0 + depth_limit = max(2, min(per_type_limit, limit // 12)) + suppress_limit = max(2, min(per_type_limit, limit // 12)) + existing_fillet_limit = max(2, min(per_type_limit, limit // 12)) + cylinder_scan_limit = max(per_type_limit * 4, 24) for item in self.cylindrical_feature_candidates( - limit=per_type_limit, - include_end_info=False, + limit=cylinder_scan_limit, + include_end_info=True, progress_callback=progress_callback, ): - candidates.append( - { - "operation_key": "resize_cylinder", - "operation": "调整圆柱孔径", - "face_id": item["face_id"], - "part_id": item["part_id"], - "solid_id": item["solid_id"], - "surface": "cylinder", - "feature_guess": item["feature_guess"], - "current_value": item["diameter"], - "current_value_label": "diameter", - "status": item["resize_status"], - "risk": item["resize_risk"], - "confidence": item["confidence"], - "note": item["resize_note"], - } - ) + feature_guess = str(item["feature_guess"]) + if existing_fillet_count < existing_fillet_limit and feature_guess == "round/fillet candidate": + feature = self.feature_info(int(item["face_id"])) + support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ())) + support_note = ( + f"支撑 Face: {support_face_ids}。" + if support_face_ids + else "暂未识别出稳定支撑 Face。" + ) + candidates.append( + { + "operation_key": "inspect_existing_fillet", + "operation": "修改已有圆角半径", + "target_kind": "face", + "target_id": item["face_id"], + "face_id": item["face_id"], + "part_id": item["part_id"], + "solid_id": item["solid_id"], + "surface": "cylinder", + "feature_guess": feature_guess, + "current_value": feature.get("existing_fillet_radius_estimate", item["radius"]), + "current_value_label": "radius", + "status": "caution", + "risk": "medium" if len(support_face_ids) >= 2 else "high", + "confidence": item["confidence"], + "note": ( + "这是已有圆角/倒圆候选;点击后会选中并预填目标半径," + "再点击“修改已有圆角半径”会尝试 defeature 后重新倒圆。" + f" {support_note}" + ), + } + ) + existing_fillet_count += 1 + + if diameter_count < per_type_limit and feature_guess != "round/fillet candidate": + candidates.append( + { + "operation_key": "resize_cylinder", + "operation": "调整圆柱孔径", + "target_kind": "face", + "target_id": item["face_id"], + "face_id": item["face_id"], + "part_id": item["part_id"], + "solid_id": item["solid_id"], + "surface": "cylinder", + "feature_guess": feature_guess, + "current_value": item["diameter"], + "current_value_label": "diameter", + "status": item["resize_status"], + "risk": item["resize_risk"], + "confidence": item["confidence"], + "note": item["resize_note"], + } + ) + diameter_count += 1 + boss_info = _cylinder_boss_resize_readiness(item) + if boss_diameter_count < per_type_limit and boss_info["boss_resize_status"] != "blocked": + candidates.append( + { + "operation_key": "resize_boss", + "operation": "调整圆柱凸台直径", + "target_kind": "face", + "target_id": item["face_id"], + "face_id": item["face_id"], + "part_id": item["part_id"], + "solid_id": item["solid_id"], + "surface": "cylinder", + "feature_guess": item["feature_guess"], + "current_value": item["diameter"], + "current_value_label": "diameter", + "status": boss_info["boss_resize_status"], + "risk": boss_info["boss_resize_risk"], + "confidence": item["confidence"], + "note": boss_info["boss_resize_note"], + } + ) + boss_diameter_count += 1 + suppress_info = _cylinder_suppress_readiness(item) + if suppress_count < suppress_limit and suppress_info["suppress_status"] != "blocked": + candidates.append( + { + "operation_key": "suppress_cylinder", + "operation": "封堵圆柱孔", + "target_kind": "face", + "target_id": item["face_id"], + "face_id": item["face_id"], + "part_id": item["part_id"], + "solid_id": item["solid_id"], + "surface": "cylinder", + "feature_guess": item["feature_guess"], + "current_value": item["diameter"], + "current_value_label": "diameter", + "status": suppress_info["suppress_status"], + "risk": suppress_info["suppress_risk"], + "confidence": item["confidence"], + "note": suppress_info["suppress_note"], + } + ) + suppress_count += 1 + depth_info = _cylinder_depth_readiness(item) + if depth_count < depth_limit and depth_info["depth_status"] != "blocked": + feature = self.feature_info(int(item["face_id"])) + bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ())) + if not bottom_face_ids: + continue + depth_context = self._blind_cylindrical_depth_context( + int(item["face_id"]), + item, + feature, + float(item["hole_depth_estimate"]), + ) + current_depth = float(depth_context.get("depth_current_depth", item["hole_depth_estimate"])) + candidates.append( + { + "operation_key": "resize_depth", + "operation": "调整盲孔深度", + "target_kind": "face", + "target_id": item["face_id"], + "face_id": item["face_id"], + "part_id": item["part_id"], + "solid_id": item["solid_id"], + "surface": "cylinder", + "feature_guess": item["feature_guess"], + "current_value": current_depth, + "current_value_label": "depth", + "status": depth_info["depth_status"], + "risk": depth_info["depth_risk"], + "confidence": item["confidence"], + "note": ( + f"{depth_info['depth_note']} " + f"底面: {bottom_face_ids}; " + f"来源: {feature.get('feature_bottom_detection')}; " + f"深度来源: {depth_context.get('depth_current_depth_source', 'cylinder-v-range')}。" + ), + } + ) + depth_count += 1 + if ( + diameter_count >= per_type_limit + and boss_diameter_count >= per_type_limit + and suppress_count >= suppress_limit + and depth_count >= depth_limit + and existing_fillet_count >= existing_fillet_limit + ): + break plane_count = 0 for face_id, face in enumerate(self.faces): @@ -423,6 +1149,8 @@ class StepModel: { "operation_key": "push_pull_plane", "operation": "推拉平面", + "target_kind": "face", + "target_id": face_id, "face_id": face_id, "part_id": self.face_part_ids[face_id], "solid_id": self.face_solid_ids[face_id], @@ -438,15 +1166,111 @@ class StepModel: ) plane_count += 1 + fillet_edge_count = 0 + chamfer_edge_count = 0 + edge_length_count = 0 + edge_type_limit = max(1, per_type_limit // 3) + for edge_id, edge in enumerate(self.edges): + if progress_callback is not None and edge_id % 80 == 0: + progress_callback() + if ( + fillet_edge_count >= edge_type_limit + and chamfer_edge_count >= edge_type_limit + and edge_length_count >= edge_type_limit + ): + break + curve = BRepAdaptor_Curve(edge) + if curve.GetType() != GeomAbs_Line: + continue + props = GProp_GProps() + brepgprop.LinearProperties(edge, props) + length = props.Mass() + if length <= 1e-9: + continue + solid_id = self._edge_solid_id(edge_id) + if fillet_edge_count < edge_type_limit: + candidates.append( + { + "operation_key": "fillet_edge", + "operation": "给边添加圆角", + "target_kind": "edge", + "target_id": edge_id, + "edge_id": edge_id, + "part_id": self.edge_part_ids[edge_id], + "solid_id": solid_id, + "surface": "edge", + "feature_guess": "linear edge fillet candidate", + "current_value": length, + "current_value_label": "length", + "status": "caution", + "risk": "medium", + "confidence": "pending", + "note": "快速扫描:添加圆角半径会在执行前根据边长和相邻面再详细判断。", + } + ) + fillet_edge_count += 1 + if chamfer_edge_count < edge_type_limit: + candidates.append( + { + "operation_key": "chamfer_edge", + "operation": "给边添加倒角", + "target_kind": "edge", + "target_id": edge_id, + "edge_id": edge_id, + "part_id": self.edge_part_ids[edge_id], + "solid_id": solid_id, + "surface": "edge", + "feature_guess": "linear edge chamfer candidate", + "current_value": length, + "current_value_label": "length", + "status": "caution", + "risk": "medium", + "confidence": "pending", + "note": "快速扫描:倒角距离会在执行前根据边长和相邻面再详细判断。", + } + ) + chamfer_edge_count += 1 + if edge_length_count < edge_type_limit: + candidates.append( + { + "operation_key": "resize_edge_length", + "operation": "调整直线边长度", + "target_kind": "edge", + "target_id": edge_id, + "edge_id": edge_id, + "part_id": self.edge_part_ids[edge_id], + "solid_id": solid_id, + "surface": "edge", + "feature_guess": "linear edge length candidate", + "current_value": length, + "current_value_label": "length", + "status": "caution", + "risk": "medium", + "confidence": "pending", + "note": "快速扫描:第一版边长调整会在执行前尝试寻找可推拉的端面,找不到端面会阻止。", + } + ) + edge_length_count += 1 + status_order = {"ready": 0, "caution": 1, "blocked": 2} risk_order = {"low": 0, "medium": 1, "high": 2, "blocked": 3} - operation_order = {"resize_cylinder": 0, "push_pull_plane": 1} + operation_order = { + "resize_cylinder": 0, + "resize_boss": 1, + "suppress_cylinder": 2, + "resize_depth": 3, + "inspect_existing_fillet": 4, + "push_pull_plane": 5, + "fillet_edge": 6, + "chamfer_edge": 7, + "resize_edge_length": 8, + } candidates.sort( key=lambda item: ( status_order.get(str(item["status"]), 9), risk_order.get(str(item["risk"]), 9), operation_order.get(str(item["operation_key"]), 9), - int(item["face_id"]), + int(item.get("target_id", item.get("face_id", item.get("edge_id", -1)))), ) ) return candidates[:limit] @@ -497,6 +1321,7 @@ class StepModel: if include_end_info: candidate.update(self._cylinder_end_opening_info(face_id, surf)) candidate.update(_cylinder_resize_readiness(candidate)) + candidate.update(_cylinder_boss_resize_readiness(candidate)) candidates.append(candidate) if len(candidates) >= limit: break @@ -515,7 +1340,12 @@ class StepModel: current_diameter = float(info["diameter"]) readiness = _cylinder_resize_readiness(info, new_diameter) resize_mode = _resize_mode(current_diameter, new_diameter) - cutter_plan = self._bounded_cylinder_cutter_plan(face_id, new_diameter) + delta_diameter = new_diameter - current_diameter + diameter_delta_ratio = abs(delta_diameter) / max(current_diameter, 1e-9) + height_estimate = float(info.get("height_estimate", 0.0)) + target_to_height_ratio = new_diameter / height_estimate if height_estimate > 1e-9 else "" + feature = self.feature_info(face_id) + cutter_plan = self._bounded_cylinder_cutter_plan(face_id, new_diameter, feature) fill_plan = self._bounded_cylinder_fill_plan(face_id) if resize_mode == "shrink" else {} return { "status": readiness["resize_status"], @@ -528,8 +1358,14 @@ class StepModel: "solid_id": info["solid_id"], "current_diameter": current_diameter, "target_diameter": new_diameter, - "delta_diameter": new_diameter - current_diameter, + "delta_diameter": delta_diameter, + "diameter_delta_ratio": diameter_delta_ratio, + "target_to_height_ratio": target_to_height_ratio, "resize_mode": resize_mode, + "feature_type": feature.get("feature_type"), + "feature_bottom_face_ids": feature.get("feature_bottom_face_ids"), + "feature_opening_face_ids": feature.get("feature_opening_face_ids"), + "feature_bottom_note": feature.get("feature_bottom_note"), "feature_guess": info.get("feature_guess"), "confidence": info.get("confidence"), "angular_span": info.get("angular_span"), @@ -540,7 +1376,270 @@ class StepModel: **fill_plan, } - def _bounded_cylinder_cutter_plan(self, face_id: int, new_diameter: float) -> dict[str, object]: + def cylindrical_boss_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + info = self.face_info(face_id) + if info.get("surface") != "cylinder" or "diameter" not in info: + return { + "status": "blocked", + "risk": "blocked", + "message": "当前选中的 face 不是圆柱面,不能调整圆柱凸台直径。", + } + + current_diameter = float(info["diameter"]) + readiness = _cylinder_boss_resize_readiness(info, new_diameter) + resize_mode = _resize_mode(current_diameter, new_diameter) + delta_diameter = new_diameter - current_diameter + diameter_delta_ratio = abs(delta_diameter) / max(current_diameter, 1e-9) + height_estimate = float(info.get("height_estimate", 0.0)) + target_to_height_ratio = new_diameter / height_estimate if height_estimate > 1e-9 else "" + feature = self.feature_info(face_id) + tool_plan = self._bounded_boss_resize_tool_plan(face_id, new_diameter) + return { + "status": readiness["boss_resize_status"], + "risk": readiness["boss_resize_risk"], + "message": readiness["boss_resize_note"], + "warnings": readiness["boss_resize_warnings"], + "blockers": readiness["boss_resize_blockers"], + "face_id": face_id, + "part_id": info["part_id"], + "solid_id": info["solid_id"], + "current_diameter": current_diameter, + "target_diameter": new_diameter, + "delta_diameter": delta_diameter, + "diameter_delta_ratio": diameter_delta_ratio, + "target_to_height_ratio": target_to_height_ratio, + "resize_mode": resize_mode, + "feature_type": feature.get("feature_type"), + "feature_guess": info.get("feature_guess"), + "confidence": info.get("confidence"), + "angular_span": info.get("angular_span"), + "height_estimate": info.get("height_estimate"), + "material_vote_summary": info.get("material_vote_summary"), + "material_sample_count": info.get("material_sample_count"), + "feature_adjacent_face_ids": feature.get("feature_adjacent_face_ids"), + "feature_boundary_edge_ids": feature.get("feature_boundary_edge_ids"), + **tool_plan, + } + + def cylindrical_suppress_plan(self, face_id: int) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + info = self.face_info(face_id) + if info.get("surface") != "cylinder" or "diameter" not in info: + return { + "status": "blocked", + "risk": "blocked", + "message": "当前选中的 face 不是圆柱面,不能封堵圆柱孔。", + } + readiness = _cylinder_suppress_readiness(info) + feature = self.feature_info(face_id) + fill_plan = self._bounded_cylinder_fill_plan(face_id) + return { + "status": readiness["suppress_status"], + "risk": readiness["suppress_risk"], + "message": readiness["suppress_note"], + "warnings": readiness["suppress_warnings"], + "blockers": readiness["suppress_blockers"], + "face_id": face_id, + "part_id": info["part_id"], + "solid_id": info["solid_id"], + "diameter": info.get("diameter"), + "radius": info.get("radius"), + "angular_span": info.get("angular_span"), + "height_estimate": info.get("height_estimate"), + "feature_type": feature.get("feature_type"), + "feature_guess": info.get("feature_guess"), + "confidence": info.get("confidence"), + "material_vote_summary": info.get("material_vote_summary"), + "cylinder_end_type": info.get("cylinder_end_type"), + "feature_bottom_face_ids": feature.get("feature_bottom_face_ids"), + "feature_opening_face_ids": feature.get("feature_opening_face_ids"), + "feature_bottom_note": feature.get("feature_bottom_note"), + **fill_plan, + } + + def cylindrical_depth_plan(self, face_id: int, target_depth: float) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + info = self.face_info(face_id) + if info.get("surface") != "cylinder" or "diameter" not in info: + return { + "status": "blocked", + "risk": "blocked", + "message": "当前选中的 face 不是圆柱面,不能调整盲孔深度。", + } + + feature = self.feature_info(face_id) + context = self._blind_cylindrical_depth_context(face_id, info, feature, target_depth) + depth_info = dict(info) + if context.get("context_status") == "ready" and isinstance(context.get("depth_current_depth"), (int, float)): + depth_info["hole_depth_estimate"] = float(context["depth_current_depth"]) + readiness = _cylinder_depth_readiness(depth_info, target_depth) + if context.get("context_status") == "blocked": + readiness = dict(readiness) + readiness["depth_status"] = "blocked" + readiness["depth_risk"] = "blocked" + readiness["depth_blockers"] = _join_nonempty( + readiness.get("depth_blockers"), + context.get("context_message"), + ) + readiness["depth_note"] = readiness["depth_blockers"] + + current_depth = float(depth_info.get("hole_depth_estimate", 0.0)) + delta_depth = target_depth - current_depth + depth_delta_ratio = abs(delta_depth) / max(current_depth, 1e-9) + plan = { + "status": readiness["depth_status"], + "risk": readiness["depth_risk"], + "message": readiness["depth_note"], + "warnings": readiness["depth_warnings"], + "blockers": readiness["depth_blockers"], + "face_id": face_id, + "part_id": info["part_id"], + "solid_id": info["solid_id"], + "current_depth": current_depth, + "target_depth": target_depth, + "delta_depth": delta_depth, + "depth_delta_ratio": depth_delta_ratio, + "depth_mode": "deepen" if delta_depth > 0 else "shallow", + "diameter": info.get("diameter"), + "radius": info.get("radius"), + "feature_type": feature.get("feature_type"), + "feature_guess": info.get("feature_guess"), + "confidence": info.get("confidence"), + "angular_span": info.get("angular_span"), + "material_vote_summary": info.get("material_vote_summary"), + "cylinder_end_type": info.get("cylinder_end_type"), + "start_end_state": info.get("start_end_state"), + "end_end_state": info.get("end_end_state"), + "feature_bottom_face_ids": feature.get("feature_bottom_face_ids"), + "feature_opening_face_ids": feature.get("feature_opening_face_ids"), + "feature_bottom_confidence": feature.get("feature_bottom_confidence"), + "feature_bottom_detection": feature.get("feature_bottom_detection"), + "feature_bottom_note": feature.get("feature_bottom_note"), + } + plan.update(context) + return plan + + def _blind_cylindrical_depth_context( + self, + face_id: int, + info: dict[str, object], + feature: dict[str, object], + target_depth: float, + ) -> dict[str, object]: + face = self.faces[face_id] + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + return { + "context_status": "blocked", + "context_message": "当前选中的 face 不是圆柱面。", + } + + bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ())) + if info.get("cylinder_end_type") != "blind" or not bottom_face_ids: + return { + "context_status": "blocked", + "context_message": "第一版孔深调整只支持已经识别出疑似底面的盲孔/盲槽。", + } + + start_open = info.get("start_end_open") is True + end_open = info.get("end_end_open") is True + if start_open == end_open: + return { + "context_status": "blocked", + "context_message": "圆柱端部开口方向不唯一,不能可靠判断孔深方向。", + } + + cyl = surf.Cylinder() + radius = float(cyl.Radius()) + axis = cyl.Axis() + axis_point = axis.Location() + axis_dir = axis.Direction() + v_min = min(float(surf.FirstVParameter()), float(surf.LastVParameter())) + v_max = max(float(surf.FirstVParameter()), float(surf.LastVParameter())) + if start_open: + open_parameter = v_min + nominal_bottom_parameter = v_max + direction_sign = 1.0 + else: + open_parameter = v_max + nominal_bottom_parameter = v_min + direction_sign = -1.0 + + bottom_parameter = self._bottom_face_axis_parameter( + bottom_face_ids, + axis_point, + axis_dir, + nominal_bottom_parameter, + ) + current_depth_source = "bottom-face-axis-parameter" if bottom_parameter is not None else "cylinder-v-range" + if bottom_parameter is None: + bottom_parameter = nominal_bottom_parameter + current_depth = max(abs(bottom_parameter - open_parameter), 1e-9) + + target_bottom_parameter = open_parameter + direction_sign * target_depth + delta_depth = target_depth - current_depth + depth_mode = "deepen" if delta_depth > 0 else "shallow" + tool_direction = ( + axis_dir.X() * direction_sign, + axis_dir.Y() * direction_sign, + axis_dir.Z() * direction_sign, + ) + open_margin = min(max(radius * 0.05, abs(delta_depth) * 0.2, 0.02), max(current_depth * 0.1, 0.2)) + bottom_overlap = min(max(radius * 0.02, abs(delta_depth) * 0.05, 0.01), max(current_depth * 0.03, 0.08)) + + if depth_mode == "deepen": + start_parameter = open_parameter - direction_sign * open_margin + end_parameter = target_bottom_parameter + tool_height = target_depth + open_margin + tool_role = "cutter" + tool_strategy = "bounded-blind-depth-cut" + tool_radius = radius + radius_overlap = 0.0 + tool_note = "加深盲孔:沿识别出的开口到疑似底面方向,使用有限长度圆柱 cutter 延伸切削。" + else: + start_parameter = target_bottom_parameter + end_parameter = bottom_parameter + direction_sign * bottom_overlap + tool_height = current_depth - target_depth + bottom_overlap + tool_role = "fill" + tool_strategy = "bounded-bottom-fill" + radius_overlap = min(max(radius * 0.001, 0.001), 0.05) + tool_radius = radius + radius_overlap + tool_note = "变浅盲孔:从目标新底面到旧底面方向补料,并让补料半径略有重叠以便和原实体合并。" + + return { + "context_status": "ready", + "depth_tool_strategy": tool_strategy, + "depth_tool_role": tool_role, + "depth_tool_note": tool_note, + "depth_axis_direction": tool_direction, + "depth_open_parameter": open_parameter, + "depth_bottom_parameter": bottom_parameter, + "depth_nominal_bottom_parameter": nominal_bottom_parameter, + "depth_bottom_parameter_source": current_depth_source, + "depth_current_depth": current_depth, + "depth_current_depth_source": current_depth_source, + "depth_target_bottom_parameter": target_bottom_parameter, + "depth_tool_start_parameter": start_parameter, + "depth_tool_end_parameter": end_parameter, + "depth_tool_height": max(tool_height, 1e-6), + "depth_tool_radius": tool_radius, + "depth_tool_radius_overlap": radius_overlap, + "depth_open_point": _point_tuple(_point_on_axis(axis_point, axis_dir, open_parameter)), + "depth_current_bottom_point": _point_tuple(_point_on_axis(axis_point, axis_dir, bottom_parameter)), + "depth_target_bottom_point": _point_tuple(_point_on_axis(axis_point, axis_dir, target_bottom_parameter)), + "depth_tool_start_point": _point_tuple(_point_on_axis(axis_point, axis_dir, start_parameter)), + } + + def _bounded_cylinder_cutter_plan( + self, + face_id: int, + new_diameter: float, + feature: dict[str, object] | None = None, + ) -> dict[str, object]: face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: @@ -562,6 +1661,14 @@ class StepModel: closed_margin = min(base_margin, max(span * 0.005, 0.02)) start_margin = base_margin if end_info["start_end_open"] else closed_margin end_margin = base_margin if end_info["end_end_open"] else closed_margin + feature = feature or self.feature_info(face_id) + bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ())) + opening_face_ids = tuple(feature.get("feature_opening_face_ids", ())) + bottom_protection = bool(bottom_face_ids) + if bottom_protection: + bottom_note = "检测到疑似盲孔底面,封闭端 cutter 只保留很小余量,避免明显加深孔。" + else: + bottom_note = "未检测到明确疑似底面,按端部开口/封闭采样设置 cutter 余量。" start_parameter = v_min - start_margin end_parameter = v_max + end_margin height = max(end_parameter - start_parameter, 1e-6) @@ -586,6 +1693,10 @@ class StepModel: "cutter_axis_point": _point_tuple(axis_point), "cutter_axis_direction": _dir_tuple(direction), "cutter_start_point": _point_tuple(start), + "cutter_bottom_protection": bottom_protection, + "cutter_protected_bottom_face_ids": bottom_face_ids, + "cutter_opening_face_ids": opening_face_ids, + "cutter_bottom_note": bottom_note, **end_info, } @@ -621,6 +1732,59 @@ class StepModel: "fill_start_point": _point_tuple(start), } + def _bounded_boss_resize_tool_plan(self, face_id: int, new_diameter: float) -> dict[str, object]: + face = self.faces[face_id] + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + return { + "boss_tool_strategy": "unavailable", + "boss_tool_note": "selected face is not cylindrical", + } + + cyl = surf.Cylinder() + old_radius = cyl.Radius() + new_radius = new_diameter / 2.0 + v1 = surf.FirstVParameter() + v2 = surf.LastVParameter() + v_min = min(v1, v2) + v_max = max(v1, v2) + span = max(v_max - v_min, 1e-6) + delta_radius = abs(new_radius - old_radius) + base_radius = max(old_radius, new_radius) + axial_margin = min( + max(base_radius * 0.001, delta_radius * 0.01, span * 0.001, 0.001), + max(span * 0.01, 0.02), + ) + radial_overlap = min(max(old_radius * 0.001, 0.001), 0.05) + start_parameter = v_min - axial_margin + end_parameter = v_max + axial_margin + height = max(end_parameter - start_parameter, 1e-6) + axis = cyl.Axis() + direction = axis.Direction() + axis_point = axis.Location() + start = _point_on_axis(axis_point, direction, start_parameter) + resize_mode = _resize_mode(old_radius * 2.0, new_diameter) + return { + "boss_tool_strategy": "bounded-cylinder-fuse" if resize_mode == "enlarge" else "bounded-annular-cut", + "boss_tool_note": ( + "扩大凸台会在选中圆柱面的 V 范围内生成目标半径圆柱并 Fuse;" + "缩小凸台会生成环形 cutter 并 Cut。第一版会给轴向两端保留少量重叠," + "让布尔结果更容易和原实体合并。" + ), + "boss_tool_start_parameter": start_parameter, + "boss_tool_end_parameter": end_parameter, + "boss_tool_height": height, + "boss_tool_axial_margin": axial_margin, + "boss_tool_radius": new_radius, + "boss_tool_old_radius": old_radius, + "boss_tool_outer_radius": old_radius + radial_overlap if resize_mode == "shrink" else new_radius, + "boss_tool_inner_radius": new_radius if resize_mode == "shrink" else "", + "boss_tool_radial_overlap": radial_overlap if resize_mode == "shrink" else "", + "boss_tool_axis_point": _point_tuple(axis_point), + "boss_tool_axis_direction": _dir_tuple(direction), + "boss_tool_start_point": _point_tuple(start), + } + def _cylinder_end_opening_info(self, face_id: int, surf: BRepAdaptor_Surface) -> dict[str, object]: solid_id = self.face_solid_ids[face_id] fallback = { @@ -713,6 +1877,14 @@ class StepModel: solid_diagonal = _shape_diagonal(solid) is_partial_cylinder = angular_span < math.tau * 0.92 is_small_radius = solid_diagonal > 0 and radius <= solid_diagonal * 0.04 + is_fillet_radius = solid_diagonal > 0 and radius <= solid_diagonal * 0.12 + is_quarter_roundish = 0.15 <= angular_span <= math.pi * 1.05 + is_fillet_like_partial = ( + is_partial_cylinder + and is_quarter_roundish + and is_fillet_radius + and boundary_edges >= 4 + ) samples = self._sample_cylinder_material_states(surf, solid, detailed=detailed) sample_count = len(samples) if sample_count == 0: @@ -749,7 +1921,7 @@ class StepModel: **base, } - if is_partial_cylinder and is_small_radius: + if is_partial_cylinder and (is_small_radius or is_fillet_like_partial): return { "feature_guess": "round/fillet candidate", "confidence": "medium" if boundary_edges >= 4 else "low", @@ -890,74 +2062,1075 @@ class StepModel: return fallback def export_all(self, filename: str | Path) -> None: - _write_step(self.shape, Path(filename)) + export_shape = _compound_from_shapes( + _prepare_shape_for_step_export(part.shape) for part in self.display_parts() + ) + _write_step(export_shape, Path(filename)) + + def export_quality_info(self, scope: str, target_id: int | None = None) -> dict[str, object]: + if scope == "all": + return _shape_quality_info("当前完整模型", self.shape, expect_solid=False) + if scope == "part": + if target_id is None: + raise ValueError("Part id is required.") + 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["part_id"] = part.id + return info + if scope == "solid": + if target_id is None or target_id < 0 or target_id >= len(self.solids): + raise ValueError(f"Unknown solid id {target_id}") + part_id, solid = self.solids[target_id] + info = _shape_quality_info(f"Solid {target_id}", solid, expect_solid=True) + info["part_id"] = part_id + info["solid_id"] = target_id + return info + if scope == "face": + if target_id is None or target_id < 0 or target_id >= len(self.faces): + raise ValueError(f"Unknown face id {target_id}") + info = _shape_quality_info(f"Face {target_id}", self.faces[target_id], expect_solid=False) + info["part_id"] = self.face_part_ids[target_id] + info["solid_id"] = self.face_solid_ids[target_id] + info["face_id"] = target_id + return info + if scope == "edge": + if target_id is None or target_id < 0 or target_id >= len(self.edges): + raise ValueError(f"Unknown edge id {target_id}") + info = _shape_quality_info(f"Edge {target_id}", self.edges[target_id], expect_solid=False) + info["part_id"] = self.edge_part_ids[target_id] + info["solid_id"] = self._edge_solid_id(target_id) + info["edge_id"] = target_id + return info + if scope == "feature": + if target_id is None or target_id < 0 or target_id >= len(self.faces): + raise ValueError(f"Unknown feature source face id {target_id}") + feature = self.feature_info(target_id) + face_ids = _int_values(feature.get("feature_highlight_face_ids")) or [target_id] + shape = _compound_from_shapes(self.faces[face_id] for face_id in face_ids if 0 <= face_id < len(self.faces)) + info = _shape_quality_info(f"Feature from face {target_id}", shape, expect_solid=False) + info["part_id"] = self.face_part_ids[target_id] + info["solid_id"] = self.face_solid_ids[target_id] + info["face_id"] = target_id + info["feature_face_ids"] = tuple(face_ids) + return info + raise ValueError(f"Unknown export quality scope: {scope}") def export_part(self, part_id: int, filename: str | Path) -> None: part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") - _write_step(part.shape, Path(filename)) + _write_step(_prepare_shape_for_step_export(part.shape), Path(filename)) def export_solid(self, solid_id: int, filename: str | Path) -> None: if solid_id < 0 or solid_id >= len(self.solids): raise ValueError(f"Unknown solid id {solid_id}") - _write_step(self.solids[solid_id][1], Path(filename)) + _write_step(_prepare_shape_for_step_export(self.solids[solid_id][1]), Path(filename)) def export_face(self, face_id: int, filename: str | Path) -> None: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") _write_step(self.faces[face_id], Path(filename)) - def push_pull_preview_polydata(self, face_id: int, distance: float, deflection: float = 0.8): + def export_edge(self, edge_id: int, filename: str | Path) -> None: + if edge_id < 0 or edge_id >= len(self.edges): + raise ValueError(f"Unknown edge id {edge_id}") + _write_step(self.edges[edge_id], Path(filename)) + + def export_feature(self, face_id: int, filename: str | Path) -> None: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown feature source face id {face_id}") + feature = self.feature_info(face_id) + face_ids = _int_values(feature.get("feature_highlight_face_ids")) or [face_id] + shapes = [self.faces[item] for item in face_ids if 0 <= item < len(self.faces)] + if not shapes: + raise ValueError("Feature export did not find any valid faces.") + _write_step(_compound_from_shapes(shapes), Path(filename)) + + def translate_part_plan(self, part_id: int, vector: tuple[float, float, float]) -> dict[str, object]: + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + readiness = _translation_readiness(vector, part.shape) + return { + "status": readiness["translate_status"], + "risk": readiness["translate_risk"], + "message": readiness["translate_note"], + "warnings": readiness["translate_warnings"], + "blockers": readiness["translate_blockers"], + "target_kind": "part", + "part_id": part.id, + "name": part.name, + "translation_vector": vector, + "translation_distance": _vector_length(vector), + "bbox_diagonal": _shape_diagonal(part.shape), + } + + def translate_solid_plan(self, solid_id: int, vector: tuple[float, float, float]) -> dict[str, object]: + if solid_id < 0 or solid_id >= len(self.solids): + raise ValueError(f"Unknown solid id {solid_id}") + part_id, solid = self.solids[solid_id] + readiness = _translation_readiness(vector, solid) + part = self.part_by_id(part_id) + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0 + warnings = readiness["translate_warnings"] + 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。") + if risk == "low": + risk = "medium" + status = "caution" + return { + "status": status, + "risk": risk, + "message": _join_nonempty(readiness["translate_note"], warnings), + "warnings": warnings, + "blockers": readiness["translate_blockers"], + "target_kind": "solid", + "part_id": part_id, + "solid_id": solid_id, + "part_solid_count": part_solid_count, + "translation_vector": vector, + "translation_distance": _vector_length(vector), + "bbox_diagonal": _shape_diagonal(solid), + } + + def translate_part(self, part_id: int, vector: tuple[float, float, float]) -> str: + plan = self.translate_part_plan(part_id, vector) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part 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"distance={float(plan['translation_distance']):g}, risk={plan['risk']}." + ) + + def translate_solid(self, solid_id: int, vector: tuple[float, float, float]) -> str: + plan = self.translate_solid_plan(solid_id, vector) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + part_id, solid = self.solids[solid_id] + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + + part_solids = _explore(part.shape, TopAbs_SOLID) + if len(part_solids) <= 1: + part.shape = _translated_shape_by_vector(part.shape, vector) + else: + translated = _translated_shape_by_vector(solid, vector) + replaced = False + shapes: list[TopoDS_Shape] = [] + for item in part_solids: + if not replaced and _same_shape(item, solid): + shapes.append(translated) + replaced = True + else: + shapes.append(item) + if not replaced: + raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.") + part.shape = _compound_from_shapes(shapes) + + _ensure_valid_shape(part.shape) + self.refresh_topology() + return ( + f"Solid translated: solid {solid_id}, part {part_id}, vector={_format_tuple(vector)}, " + f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}." + ) + + def rotate_part_plan(self, part_id: int, axis: str, angle_degrees: float) -> dict[str, object]: + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + readiness = _rotation_readiness(axis, angle_degrees) + return { + "status": readiness["rotate_status"], + "risk": readiness["rotate_risk"], + "message": readiness["rotate_note"], + "warnings": readiness["rotate_warnings"], + "blockers": readiness["rotate_blockers"], + "target_kind": "part", + "part_id": part.id, + "name": part.name, + "rotation_axis": axis.upper(), + "rotation_angle_degrees": angle_degrees, + "rotation_center": _shape_center(part.shape), + "bbox_diagonal": _shape_diagonal(part.shape), + } + + def rotate_solid_plan(self, solid_id: int, axis: str, angle_degrees: float) -> dict[str, object]: + if solid_id < 0 or solid_id >= len(self.solids): + raise ValueError(f"Unknown solid id {solid_id}") + part_id, solid = self.solids[solid_id] + readiness = _rotation_readiness(axis, angle_degrees) + part = self.part_by_id(part_id) + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0 + warnings = readiness["rotate_warnings"] + 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。") + if risk == "low": + risk = "medium" + status = "caution" + return { + "status": status, + "risk": risk, + "message": _join_nonempty(readiness["rotate_note"], warnings), + "warnings": warnings, + "blockers": readiness["rotate_blockers"], + "target_kind": "solid", + "part_id": part_id, + "solid_id": solid_id, + "part_solid_count": part_solid_count, + "rotation_axis": axis.upper(), + "rotation_angle_degrees": angle_degrees, + "rotation_center": _shape_center(solid), + "bbox_diagonal": _shape_diagonal(solid), + } + + def rotate_part(self, part_id: int, axis: str, angle_degrees: float) -> str: + plan = self.rotate_part_plan(part_id, axis, angle_degrees) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part 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"angle={float(plan['rotation_angle_degrees']):g}, center={_format_tuple(plan['rotation_center'])}, " + f"risk={plan['risk']}." + ) + + def rotate_solid(self, solid_id: int, axis: str, angle_degrees: float) -> str: + plan = self.rotate_solid_plan(solid_id, axis, angle_degrees) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + part_id, solid = self.solids[solid_id] + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + + part_solids = _explore(part.shape, TopAbs_SOLID) + if len(part_solids) <= 1: + part.shape = _rotated_shape(part.shape, str(plan["rotation_axis"]), float(plan["rotation_angle_degrees"]), plan["rotation_center"]) + else: + rotated = _rotated_shape(solid, str(plan["rotation_axis"]), float(plan["rotation_angle_degrees"]), plan["rotation_center"]) + replaced = False + shapes: list[TopoDS_Shape] = [] + for item in part_solids: + if not replaced and _same_shape(item, solid): + shapes.append(rotated) + replaced = True + else: + shapes.append(item) + if not replaced: + raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.") + part.shape = _compound_from_shapes(shapes) + + _ensure_valid_shape(part.shape) + self.refresh_topology() + return ( + f"Solid rotated: solid {solid_id}, part {part_id}, axis={plan['rotation_axis']}, " + f"angle={float(plan['rotation_angle_degrees']):g}, center={_format_tuple(plan['rotation_center'])}, " + f"risk={plan['risk']}." + ) + + def edge_fillet_plan(self, edge_id: int, radius: float) -> dict[str, object]: + if edge_id < 0 or edge_id >= len(self.edges): + raise ValueError(f"Unknown edge id {edge_id}") + info = self.edge_info(edge_id) + readiness = _edge_fillet_readiness(info, radius) + part_id = int(info["part_id"]) + part_stats = None + try: + part_stats = self.part_topology_stats(part_id) + except Exception: + part_stats = None + if part_stats is not None and part_stats.solids != 1: + readiness = dict(readiness) + if readiness["fillet_status"] != "blocked": + readiness["fillet_status"] = "caution" + readiness["fillet_risk"] = _max_risk(str(readiness["fillet_risk"]), "high") + readiness["fillet_warnings"] = _join_nonempty( + readiness["fillet_warnings"], + f"当前零件包含 {part_stats.solids} 个 solid,边倒圆会作用在整个 part shape 上,请导出前检查结果。", + ) + readiness["fillet_note"] = _join_nonempty(readiness["fillet_note"], readiness["fillet_warnings"]) + + length = float(info.get("length", 0.0)) + radius_to_length_ratio = radius / max(length, 1e-9) + return { + "status": readiness["fillet_status"], + "risk": readiness["fillet_risk"], + "message": readiness["fillet_note"], + "warnings": readiness["fillet_warnings"], + "blockers": readiness["fillet_blockers"], + "edge_id": edge_id, + "part_id": info["part_id"], + "solid_id": info.get("solid_id", -1), + "curve": info.get("curve"), + "edge_length": length, + "target_radius": radius, + "radius_to_length_ratio": radius_to_length_ratio, + "adjacent_face_ids": info.get("adjacent_face_ids", ()), + "adjacent_face_count": info.get("adjacent_face_count", 0), + "start_point": info.get("start_point"), + "end_point": info.get("end_point"), + "direction": info.get("direction"), + } + + def edge_chamfer_plan(self, edge_id: int, distance: float) -> dict[str, object]: + if edge_id < 0 or edge_id >= len(self.edges): + raise ValueError(f"Unknown edge id {edge_id}") + info = self.edge_info(edge_id) + readiness = _edge_chamfer_readiness(info, distance) + part_id = int(info["part_id"]) + part_stats = None + try: + part_stats = self.part_topology_stats(part_id) + except Exception: + part_stats = None + if part_stats is not None and part_stats.solids != 1: + readiness = dict(readiness) + if readiness["chamfer_status"] != "blocked": + readiness["chamfer_status"] = "caution" + readiness["chamfer_risk"] = _max_risk(str(readiness["chamfer_risk"]), "high") + readiness["chamfer_warnings"] = _join_nonempty( + readiness["chamfer_warnings"], + f"当前零件包含 {part_stats.solids} 个 solid,边倒角会作用在整个 part shape 上,请导出前检查结果。", + ) + readiness["chamfer_note"] = _join_nonempty(readiness["chamfer_note"], readiness["chamfer_warnings"]) + + length = float(info.get("length", 0.0)) + distance_to_length_ratio = distance / max(length, 1e-9) + return { + "status": readiness["chamfer_status"], + "risk": readiness["chamfer_risk"], + "message": readiness["chamfer_note"], + "warnings": readiness["chamfer_warnings"], + "blockers": readiness["chamfer_blockers"], + "edge_id": edge_id, + "part_id": info["part_id"], + "solid_id": info.get("solid_id", -1), + "curve": info.get("curve"), + "edge_length": length, + "target_distance": distance, + "distance_to_length_ratio": distance_to_length_ratio, + "adjacent_face_ids": info.get("adjacent_face_ids", ()), + "adjacent_face_count": info.get("adjacent_face_count", 0), + "start_point": info.get("start_point"), + "end_point": info.get("end_point"), + "direction": info.get("direction"), + } + + def straight_edge_length_plan(self, edge_id: int, target_length: float) -> dict[str, object]: + if edge_id < 0 or edge_id >= len(self.edges): + raise ValueError(f"Unknown edge id {edge_id}") + info = self.edge_info(edge_id) + current_length = float(info.get("length", 0.0)) + delta_length = float(target_length) - current_length + base: dict[str, object] = { + "edge_id": edge_id, + "part_id": info.get("part_id"), + "solid_id": info.get("solid_id", -1), + "curve": info.get("curve"), + "current_length": current_length, + "target_length": target_length, + "delta_length": delta_length, + "length_change_ratio": abs(delta_length) / max(current_length, 1e-9), + "start_point": info.get("start_point"), + "end_point": info.get("end_point"), + "direction": info.get("direction"), + "resize_strategy": "move-edge-end-plane-by-push-pull", + } + + warnings: list[str] = [ + "第一版边长调整是受限功能:只移动直线边端点附近的平面端面,不是通用参数化边长编辑。" + ] + blockers: list[str] = [] + risk = "low" + status = "ready" + + if info.get("curve") != "line": + blockers.append("当前 edge 不是直线,第一版不能调整长度。") + if current_length <= 1e-9: + blockers.append("当前 edge 长度无效。") + if target_length <= 1e-9: + blockers.append("目标边长必须大于 0。") + if abs(delta_length) <= max(current_length * 1e-7, 1e-7): + blockers.append("目标边长与当前边长几乎相同,不需要修改。") + + if not blockers: + ratio = abs(delta_length) / max(current_length, 1e-9) + if ratio > 0.5: + risk = _max_risk(risk, "high") + warnings.append("长度变化超过当前边长的 50%,布尔运算失败或形状异常的概率较高。") + elif ratio > 0.25: + risk = _max_risk(risk, "medium") + warnings.append("长度变化超过当前边长的 25%,请确认预览范围。") + + candidate: dict[str, object] | None = None + if not blockers: + candidate = self._straight_edge_length_end_face_candidate(info, delta_length) + if candidate is None: + blockers.append("没有找到可用于改变这条直线边长度的平面端面。") + else: + base.update(candidate) + push_plan = self.push_pull_plan(int(candidate["end_face_id"]), float(candidate["push_pull_distance"])) + if push_plan["status"] == "blocked": + blockers.append(str(push_plan["message"])) + else: + risk = _max_risk(risk, str(push_plan["risk"])) + push_warnings = str(push_plan.get("warnings", "")) + if push_warnings: + warnings.append(push_warnings) + base.update( + { + "push_pull_status": push_plan.get("status"), + "push_pull_risk": push_plan.get("risk"), + "push_pull_message": push_plan.get("message"), + "push_pull_scope_face_ids": push_plan.get("push_pull_scope_face_ids", ()), + "push_pull_scope_face_count": push_plan.get("push_pull_scope_face_count", 1), + "push_pull_scope_note": push_plan.get("push_pull_scope_note", ""), + } + ) + + if blockers: + status = "blocked" + risk = "blocked" + message = " ".join(blockers) + elif risk != "low": + status = "caution" + message = " ".join(warnings) + else: + message = "可以尝试通过端面推拉调整这条直线边长度。" + + base.update( + { + "status": status, + "risk": risk, + "message": message, + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + } + ) + return base + + def _straight_edge_length_end_face_candidate( + self, + edge_info: dict[str, object], + delta_length: float, + ) -> dict[str, object] | None: + start = _tuple_or_none(edge_info.get("start_point")) + end = _tuple_or_none(edge_info.get("end_point")) + if start is None or end is None: + return None + axis = _tuple_normalized(_tuple_sub(end, start)) + if axis is None: + return None + solid_id = int(edge_info.get("solid_id", -1)) + if solid_id < 0 or solid_id >= len(self.solids): + return None + solid = self.solids[solid_id][1] + tolerance = max(_shape_diagonal(solid) * 1e-5, abs(delta_length) * 1e-5, 1e-4) + candidates: list[tuple[float, dict[str, object]]] = [] + endpoint_specs = [ + ("起点端", start, _tuple_scale(axis, -delta_length)), + ("终点端", end, _tuple_scale(axis, delta_length)), + ] + + for endpoint_label, endpoint, desired_vector in endpoint_specs: + desired_unit = _tuple_normalized(desired_vector) + if desired_unit is None: + continue + for face_id, face in enumerate(self.faces): + if self.face_solid_ids[face_id] != solid_id: + continue + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Plane: + continue + plane = surf.Plane() + plane_origin = _point_tuple(plane.Location()) + plane_normal = _tuple_normalized(_dir_tuple(plane.Axis().Direction())) + if plane_normal is None: + continue + plane_distance = abs(_tuple_dot(_tuple_sub(endpoint, plane_origin), plane_normal)) + if plane_distance > tolerance: + continue + axis_alignment = abs(_tuple_dot(plane_normal, axis)) + if axis_alignment < 0.82: + continue + face_info = self.face_info(face_id) + outward = _tuple_normalized(_tuple_or_none(face_info.get("push_pull_outward_direction"))) + if outward is None: + continue + movement_alignment = abs(_tuple_dot(outward, desired_unit)) + if movement_alignment < 0.82: + continue + push_pull_distance = _tuple_dot(desired_vector, outward) + if abs(push_pull_distance) <= 1e-9: + continue + confidence_bonus = 0.0 if face_info.get("push_pull_confidence") == "high" else 0.2 + score = plane_distance / max(tolerance, 1e-9) + (1.0 - movement_alignment) + confidence_bonus + candidates.append( + ( + score, + { + "end_face_id": face_id, + "end_face_label": endpoint_label, + "end_face_plane_distance": plane_distance, + "end_face_axis_alignment": axis_alignment, + "end_face_movement_alignment": movement_alignment, + "end_face_outward_direction": outward, + "end_face_push_pull_confidence": face_info.get("push_pull_confidence", ""), + "push_pull_distance": push_pull_distance, + "desired_movement_vector": desired_vector, + }, + ) + ) + if not candidates: + return None + candidates.sort(key=lambda item: item[0]) + return candidates[0][1] + + def existing_fillet_resize_plan(self, face_id: int, target_radius: float) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + info = self.face_info(face_id) + if info.get("surface") != "cylinder" or "radius" not in info: + return { + "status": "blocked", + "risk": "blocked", + "message": "当前选中的 face 不是圆柱圆角面,不能修改已有圆角半径。", + "blockers": "当前选中的 face 不是圆柱圆角面。", + "warnings": "", + "face_id": face_id, + } + + feature = self.feature_info(face_id) + feature_guess = str(info.get("feature_guess", "")) + current_radius = float(feature.get("existing_fillet_radius_estimate", info["radius"])) + support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ())) + warnings: list[str] = [] + blockers: list[str] = [] + risk = "medium" + status = "caution" + + if feature_guess != "round/fillet candidate": + blockers.append("当前圆柱面没有被识别为已有圆角/倒圆候选。") + if target_radius <= 0: + blockers.append("目标圆角半径必须大于 0。") + if current_radius <= 0: + blockers.append("当前圆角半径估算无效。") + if current_radius > 0 and abs(target_radius - current_radius) <= max(current_radius * 1e-5, 1e-6): + blockers.append("目标圆角半径与当前估算半径几乎相同,不需要修改。") + if len(support_face_ids) < 2: + blockers.append("第一版只对识别到至少两个支撑 face 的已有圆角候选开放。") + + part_id = int(info.get("part_id", -1)) + part_stats = None + try: + part_stats = self.part_topology_stats(part_id) + except Exception: + part_stats = None + if part_stats is not None and part_stats.solids != 1: + blockers.append( + f"当前零件包含 {part_stats.solids} 个 solid;已有圆角半径修改第一版只对单 solid 零件开放。" + ) + + height_estimate = float(info.get("height_estimate", 0.0)) + angular_span = float(info.get("angular_span", 0.0)) + radius_delta = target_radius - current_radius + radius_delta_ratio = abs(radius_delta) / max(current_radius, 1e-9) + if not blockers: + if radius_delta_ratio > 1.0: + risk = "high" + warnings.append("目标半径变化超过当前半径的 100%,defeature/refillet 很可能失败。") + elif radius_delta_ratio > 0.35: + risk = _max_risk(risk, "high") + warnings.append("目标半径变化超过当前半径的 35%,请谨慎检查结果。") + if height_estimate > 0 and target_radius > height_estimate * 0.5: + risk = _max_risk(risk, "high") + warnings.append("目标半径超过圆角长度估算的一半,几何比例异常。") + if angular_span > math.pi * 1.25: + risk = _max_risk(risk, "high") + warnings.append("当前圆角圆弧跨度较大,可能不是普通边圆角。") + if str(info.get("confidence", "low")) != "high": + warnings.append("已有圆角识别置信度不是 high,执行结果需要重点检查。") + + if blockers: + status = "blocked" + risk = "blocked" + message = " ".join(blockers + warnings) + else: + message = "将尝试先移除已有圆角面,再在恢复出的锐边上按目标半径重新倒圆。" + if warnings: + message += " " + " ".join(warnings) + + 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"), + "feature_type": feature.get("feature_type"), + "feature_guess": feature_guess, + "confidence": info.get("confidence"), + "current_radius": current_radius, + "target_radius": target_radius, + "delta_radius": radius_delta, + "radius_delta_ratio": radius_delta_ratio, + "height_estimate": info.get("height_estimate"), + "angular_span": info.get("angular_span"), + "axis_point": info.get("axis_point"), + "axis": info.get("axis"), + "feature_existing_fillet_support_face_ids": support_face_ids, + "feature_boundary_edge_ids": feature.get("feature_boundary_edge_ids"), + "resize_strategy": "defeature-existing-fillet-face-then-refillet-axis-edge", + "resize_note": ( + "第一版已有圆角半径修改只支持由圆柱面表示的直线边圆角。" + "执行后 face/edge ID 会重建,请重新选择对象确认结果。" + ), + } + + def push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") face = self.faces[face_id] surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Plane: + return { + "status": "blocked", + "risk": "blocked", + "message": "当前选中的 face 不是平面,不能执行推拉平面。", + "blockers": "当前选中的 face 不是平面。", + "warnings": "", + "face_id": face_id, + "part_id": self.face_part_ids[face_id], + "solid_id": self.face_solid_ids[face_id], + "distance": distance, + } + + info = self.face_info(face_id) + scope_face_ids = self._connected_coplanar_planar_face_ids(face_id) + if len(scope_face_ids) > 1: + scope_note = f"将一起推拉 {len(scope_face_ids)} 个共享边且共面的 face,减少 STEP 碎面导致的贴块缝。" + else: + scope_note = "只推拉当前 face。" + direction_confidence = str(info.get("push_pull_confidence", "low")) + bbox_diagonal = float(info.get("bbox_diagonal", 0.0)) + distance_abs = abs(distance) + warnings: list[str] = [] + blockers: list[str] = [] + risk = "low" + status = "ready" + + if distance_abs <= 1e-9: + status = "blocked" + risk = "blocked" + blockers.append("推拉距离为 0,不需要修改。") + if direction_confidence != "high": + risk = _max_risk(risk, "medium") + warnings.append("推拉方向判断置信度较低,可能不是期望的内外方向。") + if bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.2: + risk = _max_risk(risk, "high") + warnings.append("推拉距离超过当前 face 包围盒对角线的 20%,容易导致布尔失败或大范围变形。") + elif bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.08: + risk = _max_risk(risk, "medium") + warnings.append("推拉距离相对当前 face 尺寸偏大,请确认预览范围。") + + if risk in {"medium", "high"} and status != "blocked": + status = "caution" + if blockers: + message = " ".join(blockers + warnings) + elif warnings: + message = " ".join(warnings) + else: + message = "可以尝试推拉该平面。" + + return { + "status": status, + "risk": risk, + "message": message, + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": info["part_id"], + "solid_id": info["solid_id"], + "distance": distance, + "surface": info.get("surface"), + "area": info.get("area"), + "bbox_diagonal": info.get("bbox_diagonal"), + "outward_direction": info.get("push_pull_outward_direction"), + "direction_confidence": direction_confidence, + "direction_note": info.get("push_pull_note"), + "push_pull_scope_face_ids": tuple(scope_face_ids), + "push_pull_scope_face_count": len(scope_face_ids), + "push_pull_scope_note": scope_note, + } + + def cylindrical_resize_preview_polydata( + self, + face_id: int, + new_diameter: float, + deflection: float = 0.8, + ) -> list[dict[str, object]]: + plan = self.cylindrical_resize_plan(face_id, new_diameter) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + face = self.faces[face_id] + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + raise ValueError("Cylinder resize preview currently supports cylindrical faces only.") + + direction = surf.Cylinder().Axis().Direction() + previews: list[dict[str, object]] = [] + + if plan["resize_mode"] == "shrink" and "fill_start_point" in plan: + fill_start = gp_Pnt(*plan["fill_start_point"]) + fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) + filler = BRepPrimAPI_MakeCylinder( + fill_axis, + float(plan["fill_radius"]), + float(plan["fill_height"]), + ).Shape() + BRepMesh_IncrementalMesh(filler, deflection) + previews.append( + { + "role": "fill", + "label": "补料预览", + "polydata": _shape_faces_polydata(filler), + } + ) + + cutter_start = gp_Pnt(*plan["cutter_start_point"]) + cutter_axis = gp_Ax2(cutter_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) + cutter = BRepPrimAPI_MakeCylinder( + cutter_axis, + float(plan["cutter_radius"]), + float(plan["cutter_height"]), + ).Shape() + BRepMesh_IncrementalMesh(cutter, deflection) + previews.append( + { + "role": "cutter", + "label": "切削预览", + "polydata": _shape_faces_polydata(cutter), + } + ) + return previews + + def cylindrical_boss_resize_preview_polydata( + self, + face_id: int, + new_diameter: float, + deflection: float = 0.8, + ) -> list[dict[str, object]]: + plan = self.cylindrical_boss_resize_plan(face_id, new_diameter) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + start = gp_Pnt(*plan["boss_tool_start_point"]) + direction = gp_Dir(*plan["boss_tool_axis_direction"]) + axis = gp_Ax2(start, direction) + height = float(plan["boss_tool_height"]) + if plan["resize_mode"] == "enlarge": + tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape() + BRepMesh_IncrementalMesh(tool, deflection) + return [ + { + "role": "fill", + "label": "凸台扩大补料预览", + "polydata": _shape_faces_polydata(tool), + } + ] + + outer = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape() + inner = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape() + ring_cut = BRepAlgoAPI_Cut(outer, inner) + ring = _finalize_boolean_result(ring_cut, "boss annular cutter preview") + BRepMesh_IncrementalMesh(ring, deflection) + return [ + { + "role": "cutter", + "label": "凸台缩小环形切削预览", + "polydata": _shape_faces_polydata(ring), + } + ] + + def cylindrical_suppress_preview_polydata( + self, + face_id: int, + deflection: float = 0.8, + ) -> list[dict[str, object]]: + plan = self.cylindrical_suppress_plan(face_id) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + face = self.faces[face_id] + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + raise ValueError("Cylinder suppress preview currently supports cylindrical faces only.") + + direction = surf.Cylinder().Axis().Direction() + fill_start = gp_Pnt(*plan["fill_start_point"]) + fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) + filler = BRepPrimAPI_MakeCylinder( + fill_axis, + float(plan["fill_radius"]), + float(plan["fill_height"]), + ).Shape() + BRepMesh_IncrementalMesh(filler, deflection) + return [ + { + "role": "fill", + "label": "封堵补料预览", + "polydata": _shape_faces_polydata(filler), + } + ] + + def cylindrical_depth_preview_polydata( + self, + face_id: int, + target_depth: float, + deflection: float = 0.8, + ) -> list[dict[str, object]]: + plan = self.cylindrical_depth_plan(face_id, target_depth) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + start = gp_Pnt(*plan["depth_tool_start_point"]) + direction = gp_Dir(*plan["depth_axis_direction"]) + axis = gp_Ax2(start, direction) + tool = BRepPrimAPI_MakeCylinder( + axis, + float(plan["depth_tool_radius"]), + float(plan["depth_tool_height"]), + ).Shape() + BRepMesh_IncrementalMesh(tool, deflection) + role = str(plan["depth_tool_role"]) + return [ + { + "role": role, + "label": "切削预览" if role == "cutter" else "补料预览", + "polydata": _shape_faces_polydata(tool), + } + ] + + def existing_fillet_resize_preview_polydata( + self, + face_id: int, + target_radius: float, + deflection: float = 0.8, + ) -> list[dict[str, object]]: + plan = self.existing_fillet_resize_plan(face_id, target_radius) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + return [ + { + "role": "remove", + "label": "将移除并重建的已有圆角面", + "polydata": self.build_face_polydata(face_ids=[face_id], deflection=deflection), + } + ] + + def push_pull_preview_polydata(self, face_id: int, distance: float, deflection: float = 0.8): + plan = self.push_pull_plan(face_id, distance) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + face = self.faces[face_id] + scope_face_ids = _int_values(plan.get("push_pull_scope_face_ids")) or [face_id] + profile_shape = self._push_pull_profile_shape(scope_face_ids) + surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: raise ValueError("Push/pull preview currently supports planar faces only.") - direction_info = self._plane_push_pull_direction(face_id, surf) - outward = direction_info["outward_direction"] + outward = plan["outward_direction"] vec = gp_Vec( float(outward[0]) * distance, float(outward[1]) * distance, float(outward[2]) * distance, ) - preview_shape = BRepPrimAPI_MakePrism(face, vec).Shape() + preview_shape = BRepPrimAPI_MakePrism(profile_shape, vec).Shape() BRepMesh_IncrementalMesh(preview_shape, deflection) return _shape_faces_polydata(preview_shape) + def straight_edge_length_preview_polydata(self, edge_id: int, target_length: float, deflection: float = 0.8): + plan = self.straight_edge_length_plan(edge_id, target_length) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + return self.push_pull_preview_polydata(int(plan["end_face_id"]), float(plan["push_pull_distance"]), deflection) + + def resize_straight_edge_length(self, edge_id: int, target_length: float) -> str: + plan = self.straight_edge_length_plan(edge_id, target_length) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + push_result = self.push_pull_face(int(plan["end_face_id"]), float(plan["push_pull_distance"])) + return ( + "Straight edge length resize completed: " + f"edge {edge_id}, current_length={float(plan['current_length']):g}, " + f"target_length={float(plan['target_length']):g}, " + f"delta={float(plan['delta_length']):g}, " + f"end_face={int(plan['end_face_id'])}, " + f"push_pull_distance={float(plan['push_pull_distance']):g}, " + f"risk={plan['risk']}. {push_result}" + ) + def push_pull_face(self, face_id: int, distance: float) -> str: + plan = self.push_pull_plan(face_id, distance) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: raise ValueError("Push/pull currently supports planar faces only.") - direction_info = self._plane_push_pull_direction(face_id, surf) - outward = direction_info["outward_direction"] - vec = gp_Vec( - float(outward[0]) * distance, - float(outward[1]) * distance, - float(outward[2]) * distance, - ) - tool_shape = BRepPrimAPI_MakePrism(face, vec).Shape() part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") + outward = plan["outward_direction"] + scope_face_ids = _int_values(plan.get("push_pull_scope_face_ids")) or [face_id] + profile_shape = self._push_pull_profile_shape(scope_face_ids) + overlap = _boolean_overlap_distance(part.shape, distance) + start_offset = -overlap if distance >= 0 else overlap + tool_distance = distance + overlap if distance >= 0 else distance - overlap + tool_face = _translated_shape(profile_shape, outward, start_offset) + vec = gp_Vec( + float(outward[0]) * tool_distance, + float(outward[1]) * tool_distance, + float(outward[2]) * tool_distance, + ) + tool_shape = BRepPrimAPI_MakePrism(tool_face, vec).Shape() op = BRepAlgoAPI_Fuse(part.shape, tool_shape) if distance >= 0 else BRepAlgoAPI_Cut(part.shape, tool_shape) - op.Build() - if not op.IsDone(): - raise RuntimeError("Boolean operation failed.") - result = op.Shape() - _ensure_valid_shape(result) + result = _finalize_boolean_result(op, "push/pull") + result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance) part.shape = result self.refresh_topology() action = "fused outward prism" if distance >= 0 else "cut inward prism" return ( "Planar face push/pull completed: " f"{action}, semantic_distance={distance:g}, " + f"tool_overlap={overlap:g}, " + f"scope_faces={len(scope_face_ids)}, " f"outward_direction={_format_tuple(outward)}, " - f"direction_confidence={direction_info['confidence']}." + f"direction_confidence={plan['direction_confidence']}, " + f"risk={plan['risk']}." + ) + + def resize_existing_fillet(self, face_id: int, target_radius: float) -> str: + plan = self.existing_fillet_resize_plan(face_id, target_radius) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + part_id = int(plan["part_id"]) + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + + source_face = topods.Face(self.faces[face_id]) + defeatured = _defeature_faces(part.shape, [source_face]) + axis_point = gp_Pnt(*plan["axis_point"]) + axis_dir = gp_Dir(*plan["axis"]) + root_edges = _axis_aligned_edge_candidates( + defeatured, + axis_point, + axis_dir, + expected_length=float(plan.get("height_estimate") or 0.0), + reference_radius=float(plan["current_radius"]), + ) + if not root_edges: + raise RuntimeError( + "已尝试移除已有圆角面,但没有找到可重新倒圆的轴向锐边;" + "该圆角可能不是简单直线边圆角。" + ) + + result = None + failures: list[str] = [] + for index, root_edge in enumerate(root_edges[:16], start=1): + try: + maker = BRepFilletAPI_MakeFillet(defeatured) + maker.Add(float(target_radius), topods.Edge(root_edge)) + result = _finalize_builder_result(maker, f"existing fillet resize candidate {index}") + break + except Exception as exc: + failures.append(str(exc)) + if result is None: + detail = failures[-1] if failures else "没有可用的候选边。" + raise RuntimeError( + "已移除已有圆角面,但所有候选锐边都无法重新倒圆;" + f"该圆角可能是复杂 blend 或支撑面不适合重建。最后错误:{detail}" + ) + + part.shape = result + self.refresh_topology() + return ( + "Existing fillet radius resize completed: " + f"face {face_id}, current_radius={float(plan['current_radius']):g}, " + f"target_radius={target_radius:g}, " + f"delta_radius={float(plan['delta_radius']):g}, " + f"support_faces={plan.get('feature_existing_fillet_support_face_ids')}, " + f"risk={plan['risk']}." + ) + + def fillet_edge(self, edge_id: int, radius: float) -> str: + plan = self.edge_fillet_plan(edge_id, radius) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + part_id = self.edge_part_ids[edge_id] + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + + maker = BRepFilletAPI_MakeFillet(part.shape) + maker.Add(radius, topods.Edge(self.edges[edge_id])) + result = _finalize_builder_result(maker, "edge fillet") + part.shape = result + self.refresh_topology() + return ( + f"Edge fillet completed: edge {edge_id}, radius={radius:g}, " + f"edge_length={float(plan['edge_length']):g}, " + f"radius_to_length_ratio={float(plan['radius_to_length_ratio']):g}, " + f"risk={plan['risk']}." + ) + + def chamfer_edge(self, edge_id: int, distance: float) -> str: + plan = self.edge_chamfer_plan(edge_id, distance) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + part_id = self.edge_part_ids[edge_id] + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + + maker = BRepFilletAPI_MakeChamfer(part.shape) + maker.Add(distance, topods.Edge(self.edges[edge_id])) + result = _finalize_builder_result(maker, "edge chamfer") + part.shape = result + self.refresh_topology() + return ( + f"Edge chamfer completed: edge {edge_id}, distance={distance:g}, " + f"edge_length={float(plan['edge_length']):g}, " + f"distance_to_length_ratio={float(plan['distance_to_length_ratio']):g}, " + f"risk={plan['risk']}." ) def enlarge_cylindrical_hole(self, face_id: int, new_diameter: float) -> str: @@ -995,22 +3168,14 @@ class StepModel: float(plan["fill_height"]), ).Shape() fuse = BRepAlgoAPI_Fuse(part.shape, filler) - fuse.Build() - if not fuse.IsDone(): - raise RuntimeError("Cylinder fill/fuse failed.") - source_shape = fuse.Shape() - _ensure_valid_shape(source_shape) + source_shape = _finalize_boolean_result(fuse, "cylinder fill/fuse") cutter_start = gp_Pnt(*plan["cutter_start_point"]) cutter_axis = gp_Ax2(cutter_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) cutter = BRepPrimAPI_MakeCylinder(cutter_axis, new_radius, float(plan["cutter_height"])).Shape() op = BRepAlgoAPI_Cut(source_shape, cutter) - op.Build() - if not op.IsDone(): - raise RuntimeError("Cylinder cut failed.") - result = op.Shape() - _ensure_valid_shape(result) + result = _finalize_boolean_result(op, "cylinder cut") part.shape = result self.refresh_topology() action = "enlarged by bounded cut" if plan["resize_mode"] == "enlarge" else "shrunk by fill and recut" @@ -1021,6 +3186,113 @@ class StepModel: f"cutter={plan['cutter_strategy']}, height={float(plan['cutter_height']):g}." ) + 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": + raise ValueError(str(plan["message"])) + + part_id = self.face_part_ids[face_id] + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + + start = gp_Pnt(*plan["boss_tool_start_point"]) + direction = gp_Dir(*plan["boss_tool_axis_direction"]) + axis = gp_Ax2(start, direction) + height = float(plan["boss_tool_height"]) + if plan["resize_mode"] == "enlarge": + tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape() + op = BRepAlgoAPI_Fuse(part.shape, tool) + result = _finalize_boolean_result(op, "cylindrical boss fuse") + action = "enlarged by bounded fuse" + else: + outer = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape() + inner = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape() + ring_cut = BRepAlgoAPI_Cut(outer, inner) + ring = _finalize_boolean_result(ring_cut, "cylindrical boss annular cutter") + op = BRepAlgoAPI_Cut(part.shape, ring) + result = _finalize_boolean_result(op, "cylindrical boss cut") + action = "shrunk by bounded annular cut" + + part.shape = result + self.refresh_topology() + return ( + f"Cylindrical boss resize completed: diameter {float(plan['current_diameter']):g} -> {new_diameter:g}, " + f"mode={plan['resize_mode']}, action={action}, risk={plan['risk']}, " + f"feature={plan['feature_guess']}, tool={plan['boss_tool_strategy']}, " + f"height={float(plan['boss_tool_height']):g}." + ) + + def suppress_cylindrical_hole(self, face_id: int) -> str: + plan = self.cylindrical_suppress_plan(face_id) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + part_id = self.face_part_ids[face_id] + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + + face = self.faces[face_id] + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + raise ValueError("Cylinder suppress currently supports cylindrical faces only.") + + direction = surf.Cylinder().Axis().Direction() + fill_start = gp_Pnt(*plan["fill_start_point"]) + fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) + filler = BRepPrimAPI_MakeCylinder( + fill_axis, + float(plan["fill_radius"]), + float(plan["fill_height"]), + ).Shape() + fuse = BRepAlgoAPI_Fuse(part.shape, filler) + result = _finalize_boolean_result(fuse, "cylinder suppress/fill") + part.shape = result + self.refresh_topology() + return ( + f"Cylindrical hole suppress completed: face {face_id}, " + f"diameter={float(plan['diameter']):g}, " + f"height={float(plan['fill_height']):g}, " + f"risk={plan['risk']}, feature={plan['feature_guess']}." + ) + + def resize_cylindrical_depth(self, face_id: int, target_depth: float) -> str: + plan = self.cylindrical_depth_plan(face_id, target_depth) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + part_id = self.face_part_ids[face_id] + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + + start = gp_Pnt(*plan["depth_tool_start_point"]) + direction = gp_Dir(*plan["depth_axis_direction"]) + axis = gp_Ax2(start, direction) + tool = BRepPrimAPI_MakeCylinder( + axis, + float(plan["depth_tool_radius"]), + float(plan["depth_tool_height"]), + ).Shape() + if plan["depth_mode"] == "deepen": + op = BRepAlgoAPI_Cut(part.shape, tool) + result = _finalize_boolean_result(op, "blind depth cut") + action = "deepened by bounded cut" + else: + op = BRepAlgoAPI_Fuse(part.shape, tool) + result = _finalize_boolean_result(op, "blind depth fill/fuse") + action = "made shallower by bounded fill" + + part.shape = result + self.refresh_topology() + return ( + f"Blind cylindrical depth completed: depth {float(plan['current_depth']):g} -> {target_depth:g}, " + f"mode={plan['depth_mode']}, action={action}, " + f"risk={plan['risk']}, feature={plan['feature_guess']}, " + f"tool={plan['depth_tool_strategy']}, height={float(plan['depth_tool_height']):g}." + ) + def build_face_polydata( self, face_ids: Iterable[int] | None = None, @@ -1086,9 +3358,16 @@ class StepModel: BRepMesh_IncrementalMesh(shape, deflection) return _shape_faces_polydata(shape) - def build_edge_polydata(self, deflection: float = 0.8): + def build_edge_polydata( + self, + edge_ids: Iterable[int] | None = None, + part_ids: Iterable[int] | None = None, + deflection: float = 0.8, + ): import vtk + selected_edges = set(edge_ids) if edge_ids is not None else None + selected_parts = set(part_ids) if part_ids is not None else None points = vtk.vtkPoints() lines = vtk.vtkCellArray() edge_arr = vtk.vtkIntArray() @@ -1097,6 +3376,11 @@ class StepModel: part_arr.SetName("part_id") for edge_id, edge in enumerate(self.edges): + part_id = self.edge_part_ids[edge_id] + if selected_edges is not None and edge_id not in selected_edges: + continue + if selected_parts is not None and part_id not in selected_parts: + continue samples = discretize_edge(edge, deflection) if len(samples) < 2: continue @@ -1107,7 +3391,7 @@ class StepModel: polyline.GetPointIds().SetId(i, point_id) lines.InsertNextCell(polyline) edge_arr.InsertNextValue(edge_id) - part_arr.InsertNextValue(self.edge_part_ids[edge_id]) + part_arr.InsertNextValue(part_id) poly = vtk.vtkPolyData() poly.SetPoints(points) @@ -1225,6 +3509,20 @@ def _write_step(shape: TopoDS_Shape, filename: Path) -> None: raise IOError(f"Could not write STEP file: {filename}") +def _prepare_shape_for_step_export(shape: TopoDS_Shape) -> TopoDS_Shape: + if shape.IsNull(): + return shape + try: + repaired = _repair_shape(shape) + unified = _unify_same_domain_shape(repaired) + repaired_unified = _repair_shape(unified) + if repaired_unified.IsNull(): + return shape + return repaired_unified + except Exception: + return shape + + def _compound_from_shapes(shapes: Iterable[TopoDS_Shape]) -> TopoDS_Shape: from OCC.Core.BRep import BRep_Builder @@ -1257,6 +3555,105 @@ def _explore(shape: TopoDS_Shape, shape_type: int) -> list[TopoDS_Shape]: return items +def _same_shape(left: TopoDS_Shape, right: TopoDS_Shape) -> bool: + try: + return bool(left.IsSame(right)) + except Exception: + return False + + +def _surfaces_are_coplanar(left: BRepAdaptor_Surface, right: BRepAdaptor_Surface, tolerance: float) -> bool: + if left.GetType() != GeomAbs_Plane or right.GetType() != GeomAbs_Plane: + return False + left_plane = left.Plane() + right_plane = right.Plane() + left_dir = left_plane.Axis().Direction() + right_dir = right_plane.Axis().Direction() + dot = abs( + left_dir.X() * right_dir.X() + + left_dir.Y() * right_dir.Y() + + left_dir.Z() * right_dir.Z() + ) + if dot < 1.0 - 1e-7: + return False + left_point = left_plane.Location() + right_point = right_plane.Location() + distance = abs( + (right_point.X() - left_point.X()) * left_dir.X() + + (right_point.Y() - left_point.Y()) * left_dir.Y() + + (right_point.Z() - left_point.Z()) * left_dir.Z() + ) + return distance <= tolerance + + +def _mapped_edge_solid_id( + edge: TopoDS_Shape, + solid_edge_maps: list[tuple[int, TopTools_IndexedDataMapOfShapeListOfShape]], +) -> int: + for solid_id, edge_map in solid_edge_maps: + if edge_map.Contains(edge): + return solid_id + return -1 + + +def _shape_quality_info(label: str, shape: TopoDS_Shape, expect_solid: bool) -> dict[str, object]: + warnings: list[str] = [] + if shape.IsNull(): + return { + "quality_label": label, + "quality_status": "blocked", + "brep_valid": False, + "solids": 0, + "faces": 0, + "edges": 0, + "vertices": 0, + "quality_warnings": "导出对象是空 shape,不能可靠导出。", + } + + try: + brep_valid = BRepCheck_Analyzer(shape).IsValid() + except Exception as exc: + brep_valid = False + warnings.append(f"B-Rep 校验执行失败:{exc}") + + topo = TopologyExplorer(shape, ignore_orientation=True) + solids = len(list(topo.solids())) + faces = len(list(topo.faces())) + edges = len(list(topo.edges())) + vertices = len(list(topo.vertices())) + + if not brep_valid: + warnings.append("B-Rep 校验未通过,导出后其他 CAD 软件可能无法正常识别。") + if faces == 0: + warnings.append("没有检测到 face,导出结果可能不可用。") + if expect_solid and solids == 0: + warnings.append("没有检测到 solid,导出后可能不是实体。") + elif expect_solid and solids > 1: + warnings.append( + f"检测到 {solids} 个 solid。" + "如果这不是有意的多实体零件,导出后可能看起来像多个体叠在一起或彼此分离。" + ) + + geometry_info = _shape_volume_info(shape) + volume = geometry_info.get("volume", "") + if expect_solid and isinstance(volume, (int, float)) and abs(float(volume)) <= 1e-9: + warnings.append("实体体积接近 0,请确认导出对象是否为有效实体。") + + bounds_info = _shape_bounds_info(shape) + return { + "quality_label": label, + "quality_status": "warning" if warnings else "ok", + "brep_valid": brep_valid, + "solids": solids, + "faces": faces, + "edges": edges, + "vertices": vertices, + "volume": volume, + "bbox_diagonal": bounds_info.get("bbox_diagonal", ""), + "quality_warnings": ";".join(warnings), + } + + def _shape_faces_polydata(shape: TopoDS_Shape): import vtk @@ -1327,6 +3724,359 @@ def _shape_diagonal(shape: TopoDS_Shape) -> float: return math.sqrt((xmax - xmin) ** 2 + (ymax - ymin) ** 2 + (zmax - zmin) ** 2) +def _shape_center(shape: TopoDS_Shape) -> tuple[float, float, float]: + xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(shape) + return ((xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0) + + +def _translated_shape(shape: TopoDS_Shape, direction: tuple[float, float, float], distance: float) -> TopoDS_Shape: + if abs(distance) <= 1e-12: + return shape + trsf = gp_Trsf() + trsf.SetTranslation( + gp_Vec( + float(direction[0]) * distance, + float(direction[1]) * distance, + float(direction[2]) * distance, + ) + ) + return BRepBuilderAPI_Transform(shape, trsf, True).Shape() + + +def _translated_shape_by_vector(shape: TopoDS_Shape, vector: tuple[float, float, float]) -> TopoDS_Shape: + if _vector_length(vector) <= 1e-12: + return shape + trsf = gp_Trsf() + trsf.SetTranslation(gp_Vec(float(vector[0]), float(vector[1]), float(vector[2]))) + return BRepBuilderAPI_Transform(shape, trsf, True).Shape() + + +def _rotated_shape( + shape: TopoDS_Shape, + axis_name: str, + angle_degrees: float, + center: tuple[float, float, float], +) -> TopoDS_Shape: + if abs(angle_degrees) <= 1e-12: + return shape + axis_dir = _axis_dir_from_name(axis_name) + trsf = gp_Trsf() + trsf.SetRotation(gp_Ax1(gp_Pnt(*center), axis_dir), math.radians(angle_degrees)) + return BRepBuilderAPI_Transform(shape, trsf, True).Shape() + + +def _axis_dir_from_name(axis_name: str) -> gp_Dir: + axis = axis_name.upper() + if axis == "X": + return gp_Dir(1.0, 0.0, 0.0) + if axis == "Y": + return gp_Dir(0.0, 1.0, 0.0) + if axis == "Z": + return gp_Dir(0.0, 0.0, 1.0) + raise ValueError("Rotation axis must be X, Y or Z.") + + +def _vector_length(vector: tuple[float, float, float]) -> float: + return math.sqrt(float(vector[0]) ** 2 + float(vector[1]) ** 2 + float(vector[2]) ** 2) + + +def _tuple_or_none(value: object) -> tuple[float, float, float] | None: + if not isinstance(value, (list, tuple)) or len(value) != 3: + return None + try: + return (float(value[0]), float(value[1]), float(value[2])) + except (TypeError, ValueError): + return None + + +def _tuple_sub(left: tuple[float, float, float], right: tuple[float, float, float]) -> tuple[float, float, float]: + return (left[0] - right[0], left[1] - right[1], left[2] - right[2]) + + +def _tuple_scale(values: tuple[float, float, float], scale: float) -> tuple[float, float, float]: + return (values[0] * scale, values[1] * scale, values[2] * scale) + + +def _tuple_dot(left: tuple[float, float, float], right: tuple[float, float, float]) -> float: + return left[0] * right[0] + left[1] * right[1] + left[2] * right[2] + + +def _tuple_normalized(value: tuple[float, float, float] | None) -> tuple[float, float, float] | None: + if value is None: + return None + length = _vector_length(value) + if length <= 1e-12: + return None + return (value[0] / length, value[1] / length, value[2] / length) + + +def _rotation_readiness(axis_name: str, angle_degrees: float) -> dict[str, object]: + risk = "low" + status = "ready" + warnings: list[str] = [] + blockers: list[str] = [] + axis = axis_name.upper() + + if axis not in {"X", "Y", "Z"}: + status = "blocked" + risk = "blocked" + blockers.append("旋转轴必须是 X、Y 或 Z。") + if abs(angle_degrees) <= 1e-9: + status = "blocked" + risk = "blocked" + blockers.append("旋转角度为 0,不需要修改。") + if abs(angle_degrees) > 360.0: + risk = _max_risk(risk, "medium") + warnings.append("旋转角度超过 360 度,请确认输入是否符合预期。") + + if blockers: + note = " ".join(blockers + warnings) + elif warnings: + status = "caution" + note = " ".join(warnings) + else: + note = "可以尝试旋转当前对象。" + return { + "rotate_status": status, + "rotate_risk": risk, + "rotate_warnings": ";".join(warnings), + "rotate_blockers": ";".join(blockers), + "rotate_note": note, + } + + +def _translation_readiness(vector: tuple[float, float, float], shape: TopoDS_Shape) -> dict[str, object]: + risk = "low" + status = "ready" + warnings: list[str] = [] + blockers: list[str] = [] + distance = _vector_length(vector) + diagonal = _shape_diagonal(shape) + + if distance <= 1e-9: + status = "blocked" + risk = "blocked" + blockers.append("平移向量为 0,不需要修改。") + elif diagonal > 1e-9: + ratio = distance / diagonal + if ratio > 2.0: + risk = "high" + warnings.append("平移距离超过目标包围盒对角线的 2 倍,请确认单位和方向。") + elif ratio > 0.5: + risk = "medium" + warnings.append("平移距离超过目标包围盒对角线的 50%,请确认单位和方向。") + + if blockers: + note = " ".join(blockers + warnings) + elif warnings: + status = "caution" + note = " ".join(warnings) + else: + note = "可以尝试平移当前对象。" + return { + "translate_status": status, + "translate_risk": risk, + "translate_warnings": ";".join(warnings), + "translate_blockers": ";".join(blockers), + "translate_note": note, + } + + +def _boolean_overlap_distance(shape: TopoDS_Shape, requested_distance: float) -> float: + diagonal = _shape_diagonal(shape) + size_based = diagonal * 1e-5 if diagonal > 0 else 0.01 + distance_based = abs(requested_distance) * 0.02 + return min(max(size_based, distance_based, 0.001), max(abs(requested_distance) * 0.25, 0.01)) + + +def _shape_cleaning_tolerance( + source_shape: TopoDS_Shape, + profile_shape: TopoDS_Shape, + requested_distance: float, +) -> float: + source_diagonal = _shape_diagonal(source_shape) + profile_diagonal = _shape_diagonal(profile_shape) + reference = max(source_diagonal, profile_diagonal, abs(requested_distance), 1.0) + size_based = reference * 1e-7 + distance_based = abs(requested_distance) * 1e-5 + lower = max(size_based, distance_based, 1e-5) + upper = max(reference * 1e-4, 0.02) + return min(lower, upper) + + +def _topology_shape_count(shape: TopoDS_Shape, shape_type) -> int: + explorer = TopExp_Explorer(shape, shape_type) + count = 0 + while explorer.More(): + count += 1 + explorer.Next() + return count + + +def _defeature_faces(shape: TopoDS_Shape, faces: Iterable[TopoDS_Shape]) -> TopoDS_Shape: + builder = BRepAlgoAPI_Defeaturing() + builder.SetShape(shape) + for face in faces: + builder.AddFaceToRemove(topods.Face(face)) + return _finalize_builder_result(builder, "existing fillet defeature") + + +def _find_axis_aligned_edge( + shape: TopoDS_Shape, + axis_point: gp_Pnt, + axis_dir: gp_Dir, + expected_length: float, + reference_radius: float, +) -> TopoDS_Shape | None: + candidates = _axis_aligned_edge_candidates(shape, axis_point, axis_dir, expected_length, reference_radius) + return candidates[0] if candidates else None + + +def _axis_aligned_edge_candidates( + shape: TopoDS_Shape, + axis_point: gp_Pnt, + axis_dir: gp_Dir, + expected_length: float, + reference_radius: float, +) -> list[TopoDS_Shape]: + candidates: list[tuple[float, TopoDS_Shape]] = [] + length_reference = max(expected_length, reference_radius, 1.0) + distance_limit = max(reference_radius * 1.25, length_reference * 0.08, 0.2) + + for edge in TopologyExplorer(shape, ignore_orientation=True).edges(): + try: + curve = BRepAdaptor_Curve(edge) + if curve.GetType() != GeomAbs_Line: + continue + line = curve.Line() + parallel = abs(_direction_dot(line.Direction(), axis_dir)) + if parallel < 0.96: + continue + + props = GProp_GProps() + brepgprop.LinearProperties(edge, props) + edge_length = props.Mass() + if edge_length <= 1e-9: + continue + + line_distance = _point_axis_distance(axis_point, axis_dir, line.Location()) + center_distance = _point_axis_distance(axis_point, axis_dir, props.CentreOfMass()) + length_penalty = 0.0 + if expected_length > 1e-9: + length_penalty = abs(edge_length - expected_length) / expected_length + score = max(line_distance, center_distance) + length_penalty * max(reference_radius * 0.15, 0.05) + if score <= distance_limit: + candidates.append((score, edge)) + except Exception: + continue + + candidates.sort(key=lambda item: item[0]) + return [edge for _score, edge in candidates] + + +def _finalize_boolean_result(op, operation_name: str) -> TopoDS_Shape: + op.SetNonDestructive(True) + op.Build() + if not op.IsDone(): + raise RuntimeError(f"{operation_name} Boolean operation failed.") + raw_result = _ensure_valid_or_repaired_shape(op.Shape(), operation_name) + try: + op.SimplifyResult(True, True) + simplified = _ensure_valid_or_repaired_shape( + op.Shape(), f"{operation_name} simplify" + ) + unified = _unify_same_domain_shape(simplified) + return _ensure_valid_or_repaired_shape(unified, f"{operation_name} unify") + except Exception: + unified = _unify_same_domain_shape(raw_result) + return _ensure_valid_or_repaired_shape(unified, f"{operation_name} unify") + + +def _finalize_builder_result(builder, operation_name: str) -> TopoDS_Shape: + builder.Build() + if hasattr(builder, "IsDone") and not builder.IsDone(): + raise RuntimeError(f"{operation_name} operation failed.") + result = _ensure_valid_or_repaired_shape(builder.Shape(), operation_name) + unified = _unify_same_domain_shape(result) + return _ensure_valid_or_repaired_shape(unified, f"{operation_name} unify") + + +def _cleanup_push_pull_result( + result: TopoDS_Shape, + source_shape: TopoDS_Shape, + profile_shape: TopoDS_Shape, + distance: float, +) -> TopoDS_Shape: + base_tolerance = _shape_cleaning_tolerance(source_shape, profile_shape, distance) + cleaned = result + for multiplier in (1.0, 5.0, 20.0): + tolerance = base_tolerance * multiplier + candidate = _unify_same_domain_shape( + cleaned, + linear_tolerance=tolerance, + angular_tolerance=1e-5, + allow_internal_edges=False, + ) + candidate = _ensure_valid_or_repaired_shape(candidate, f"push/pull cleanup {multiplier:g}x") + if _topology_shape_count(candidate, TopAbs_SOLID) == _topology_shape_count(result, TopAbs_SOLID): + cleaned = candidate + return cleaned + + +def _unify_same_domain_shape( + shape: TopoDS_Shape, + linear_tolerance: float | None = None, + angular_tolerance: float | None = None, + allow_internal_edges: bool = False, +) -> TopoDS_Shape: + try: + unifier = ShapeUpgrade_UnifySameDomain(shape, True, True, False) + unifier.SetSafeInputMode(True) + if hasattr(unifier, "AllowInternalEdges"): + unifier.AllowInternalEdges(allow_internal_edges) + if linear_tolerance is not None and hasattr(unifier, "SetLinearTolerance"): + unifier.SetLinearTolerance(max(float(linear_tolerance), 0.0)) + if angular_tolerance is not None and hasattr(unifier, "SetAngularTolerance"): + unifier.SetAngularTolerance(max(float(angular_tolerance), 0.0)) + unifier.Build() + unified = unifier.Shape() + _ensure_valid_shape(unified) + return unified + except Exception: + return shape + + +def _ensure_valid_or_repaired_shape( + shape: TopoDS_Shape, operation_name: str +) -> TopoDS_Shape: + try: + _ensure_valid_shape(shape) + return shape + except RuntimeError as original_error: + repaired = _repair_shape(shape) + try: + _ensure_valid_shape(repaired) + return repaired + except RuntimeError: + raise RuntimeError( + f"{operation_name} returned an invalid B-Rep shape, and automatic repair did not fix it." + ) from original_error + + +def _repair_shape(shape: TopoDS_Shape) -> TopoDS_Shape: + if shape.IsNull(): + return shape + try: + fixer = ShapeFix_Shape(shape) + fixer.Perform() + repaired = fixer.Shape() + if repaired.IsNull(): + return shape + return repaired + except Exception: + return shape + + def _ensure_valid_shape(shape: TopoDS_Shape) -> None: if shape.IsNull(): raise RuntimeError("Operation returned a null shape.") @@ -1387,6 +4137,7 @@ def _cylinder_resize_readiness( if new_diameter is not None: current_diameter = float(info.get("diameter", 0.0)) + height_estimate = float(info.get("height_estimate", 0.0)) if new_diameter <= 0: status = "blocked" risk = "blocked" @@ -1395,14 +4146,31 @@ def _cylinder_resize_readiness( status = "blocked" risk = "blocked" blockers.append("目标直径与当前直径几乎相同,不需要修改。") - elif new_diameter < current_diameter: - if guess != "hole/groove candidate": - status = "blocked" - risk = "blocked" - blockers.append("缩小孔径第一版只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。") - else: + else: + diameter_delta = abs(new_diameter - current_diameter) + delta_ratio = diameter_delta / max(current_diameter, 1e-9) + if delta_ratio > 1.0: risk = _max_risk(risk, "high") - warnings.append("缩小孔径会先补料再重切,属于高风险实验功能。") + warnings.append("目标直径变化超过当前直径的 100%,很可能导致大范围误切或布尔失败。") + elif delta_ratio > 0.35: + risk = _max_risk(risk, "medium") + warnings.append("目标直径变化超过当前直径的 35%,请确认预览范围。") + + if height_estimate > 0 and new_diameter > height_estimate * 2.0: + risk = _max_risk(risk, "high") + warnings.append("目标直径超过圆柱面估算高度的 2 倍,几何比例异常。") + elif height_estimate > 0 and new_diameter > height_estimate: + risk = _max_risk(risk, "medium") + warnings.append("目标直径超过圆柱面估算高度,可能不是常规孔径修改。") + + if new_diameter < current_diameter: + if guess != "hole/groove candidate": + status = "blocked" + risk = "blocked" + blockers.append("缩小孔径第一版只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。") + else: + risk = _max_risk(risk, "high") + warnings.append("缩小孔径会先补料再重切,属于高风险实验功能。") if risk in {"medium", "high"} and status != "blocked": status = "caution" @@ -1419,6 +4187,295 @@ def _cylinder_resize_readiness( } +def _cylinder_boss_resize_readiness( + info: dict[str, object], + new_diameter: float | None = None, +) -> dict[str, object]: + risk = "low" + status = "ready" + warnings: list[str] = [] + blockers: list[str] = [] + guess = str(info.get("feature_guess", "cylindrical face")) + confidence = str(info.get("confidence", "low")) + angular_span = float(info.get("angular_span", 0.0)) + current_diameter = float(info.get("diameter", 0.0)) + height_estimate = float(info.get("height_estimate", 0.0)) + + if guess != "boss/outer-round candidate": + blockers.append("凸台直径调整第一版只支持明确的凸台/外圆柱候选。") + if angular_span < math.tau * 0.92: + blockers.append("凸台直径调整第一版只支持接近完整圆柱的凸台,不处理局部外圆角或圆角面。") + if current_diameter <= 1e-9: + blockers.append("当前圆柱面的直径估算无效。") + + if guess == "boss/outer-round candidate" and confidence != "high": + risk = _max_risk(risk, "medium") + warnings.append("凸台判断置信度不是 high,修改后请重点检查结果。") + + if new_diameter is not None: + if new_diameter <= 0: + blockers.append("目标凸台直径必须大于 0。") + elif current_diameter > 1e-9 and abs(new_diameter - current_diameter) <= max(current_diameter * 1e-5, 1e-6): + blockers.append("目标凸台直径与当前直径几乎相同,不需要修改。") + elif current_diameter > 1e-9: + delta_ratio = abs(new_diameter - current_diameter) / current_diameter + if delta_ratio > 0.8: + risk = _max_risk(risk, "high") + warnings.append("目标凸台直径变化超过当前直径的 80%,很可能导致大范围布尔失败。") + elif delta_ratio > 0.3: + risk = _max_risk(risk, "medium") + warnings.append("目标凸台直径变化超过当前直径的 30%,请确认预览范围。") + if new_diameter < current_diameter * 0.15: + risk = _max_risk(risk, "high") + warnings.append("目标凸台直径非常小,可能生成很薄或断开的几何。") + if height_estimate > 1e-9 and new_diameter > height_estimate * 3.0: + risk = _max_risk(risk, "high") + warnings.append("目标凸台直径超过圆柱面估算高度的 3 倍,几何比例异常。") + elif height_estimate > 1e-9 and new_diameter > height_estimate * 1.5: + risk = _max_risk(risk, "medium") + warnings.append("目标凸台直径明显大于圆柱面估算高度,请确认单位。") + + if blockers: + status = "blocked" + risk = "blocked" + elif risk in {"medium", "high"}: + status = "caution" + + if not warnings and not blockers: + note = "可以尝试调整圆柱凸台直径。" + else: + note = " ".join(blockers + warnings) + return { + "boss_resize_status": status, + "boss_resize_risk": risk, + "boss_resize_warnings": ";".join(warnings), + "boss_resize_blockers": ";".join(blockers), + "boss_resize_note": note, + } + + +def _cylinder_depth_readiness( + info: dict[str, object], + target_depth: float | None = None, +) -> dict[str, object]: + risk = "low" + status = "ready" + warnings: list[str] = [] + blockers: list[str] = [] + guess = str(info.get("feature_guess", "cylindrical face")) + confidence = str(info.get("confidence", "low")) + angular_span = float(info.get("angular_span", 0.0)) + end_type = str(info.get("cylinder_end_type", "unknown")) + current_depth = float(info.get("hole_depth_estimate", 0.0)) + + if guess != "hole/groove candidate": + blockers.append("孔深调整第一版只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。") + if end_type != "blind": + blockers.append("孔深调整第一版只支持端部类型为 blind 的盲孔/盲槽。") + if current_depth <= 1e-9: + blockers.append("当前圆柱面没有可靠的深度估算。") + + if guess == "hole/groove candidate" and confidence == "low": + risk = _max_risk(risk, "medium") + warnings.append("孔/槽判断置信度较低。") + if guess == "hole/groove candidate" and angular_span < math.tau * 0.92: + risk = _max_risk(risk, "medium") + warnings.append("这是局部圆柱面,更像槽或半孔,孔深调整会按局部槽处理。") + + if target_depth is not None: + if target_depth <= 0: + blockers.append("目标深度必须大于 0。") + elif current_depth > 1e-9 and abs(target_depth - current_depth) <= max(current_depth * 1e-5, 1e-6): + blockers.append("目标深度与当前深度几乎相同,不需要修改。") + elif current_depth > 1e-9: + delta_ratio = abs(target_depth - current_depth) / current_depth + if delta_ratio > 1.0: + risk = _max_risk(risk, "high") + warnings.append("目标深度变化超过当前深度的 100%,很可能导致贯穿、误切或布尔失败。") + elif delta_ratio > 0.35: + risk = _max_risk(risk, "medium") + warnings.append("目标深度变化超过当前深度的 35%,请确认预览范围。") + + if target_depth < current_depth * 0.08: + risk = _max_risk(risk, "high") + warnings.append("目标深度非常浅,补料后可能生成很薄的局部面。") + + if blockers: + status = "blocked" + risk = "blocked" + elif risk in {"medium", "high"}: + status = "caution" + + if not warnings and not blockers: + note = "可以尝试调整盲孔深度。" + else: + note = " ".join(blockers + warnings) + return { + "depth_status": status, + "depth_risk": risk, + "depth_warnings": ";".join(warnings), + "depth_blockers": ";".join(blockers), + "depth_note": note, + } + + +def _cylinder_suppress_readiness(info: dict[str, object]) -> dict[str, object]: + risk = "low" + status = "ready" + warnings: list[str] = [] + blockers: list[str] = [] + guess = str(info.get("feature_guess", "cylindrical face")) + confidence = str(info.get("confidence", "low")) + angular_span = float(info.get("angular_span", 0.0)) + end_type = str(info.get("cylinder_end_type", "unknown")) + height = float(info.get("height_estimate", 0.0)) + diameter = float(info.get("diameter", 0.0)) + + if guess != "hole/groove candidate": + blockers.append("封堵圆柱孔第一版只支持孔候选,不支持圆角、凸柱或未明确圆柱面。") + if angular_span < math.tau * 0.92: + blockers.append("封堵圆柱孔第一版只支持接近完整圆柱的孔,不支持半孔/槽。") + if end_type == "closed/internal": + blockers.append("当前圆柱两端都像在材料内部,不像可封堵的外部孔。") + if height <= 1e-9 or diameter <= 1e-9: + blockers.append("当前圆柱孔的直径或高度估算无效。") + + if guess == "hole/groove candidate" and confidence != "high": + risk = _max_risk(risk, "medium") + warnings.append("孔判断置信度不是 high,封堵后请重点检查结果。") + if end_type not in {"blind", "through/open-ended"}: + risk = _max_risk(risk, "medium") + warnings.append("孔端部类型不明确,补料范围可能不是期望的孔范围。") + + if blockers: + status = "blocked" + risk = "blocked" + elif risk in {"medium", "high"}: + status = "caution" + + if not warnings and not blockers: + note = "可以尝试封堵该圆柱孔。" + else: + note = " ".join(blockers + warnings) + return { + "suppress_status": status, + "suppress_risk": risk, + "suppress_warnings": ";".join(warnings), + "suppress_blockers": ";".join(blockers), + "suppress_note": note, + } + + +def _edge_fillet_readiness( + info: dict[str, object], + radius: float | None = None, +) -> dict[str, object]: + risk = "medium" + status = "caution" + warnings: list[str] = ["STEP 没有建模历史,边倒圆依赖当前 B-Rep 拓扑,部分边可能被 OCCT 拒绝。"] + blockers: list[str] = [] + curve = str(info.get("curve", "")) + length = float(info.get("length", 0.0)) + adjacent_count = int(info.get("adjacent_face_count", 0)) + + if curve != "line": + blockers.append("添加圆角第一版只支持直线 edge。") + if length <= 1e-9: + blockers.append("当前 edge 长度无效。") + if adjacent_count < 2: + blockers.append("当前 edge 没有检测到至少两个相邻 face,不能可靠添加圆角。") + elif adjacent_count > 2: + risk = _max_risk(risk, "medium") + warnings.append(f"当前 edge 相邻 face 数为 {adjacent_count},可能是复杂交汇边。") + + if radius is not None: + if radius <= 0: + blockers.append("圆角半径必须大于 0。") + elif length > 1e-9: + ratio = radius / length + if ratio >= 0.45: + blockers.append("圆角半径接近或超过 edge 长度的一半,第一版直接阻止。") + elif ratio > 0.25: + risk = _max_risk(risk, "high") + warnings.append("圆角半径超过 edge 长度的 25%,很容易导致倒圆失败。") + elif ratio > 0.12: + risk = _max_risk(risk, "medium") + warnings.append("圆角半径相对 edge 长度偏大,请确认预览范围。") + + if blockers: + status = "blocked" + risk = "blocked" + elif risk in {"medium", "high"}: + status = "caution" + + if not warnings and not blockers: + note = "可以尝试给该直线边添加圆角。" + else: + note = " ".join(blockers + warnings) + return { + "fillet_status": status, + "fillet_risk": risk, + "fillet_warnings": ";".join(warnings), + "fillet_blockers": ";".join(blockers), + "fillet_note": note, + } + + +def _edge_chamfer_readiness( + info: dict[str, object], + distance: float | None = None, +) -> dict[str, object]: + risk = "medium" + status = "caution" + warnings: list[str] = ["STEP 没有建模历史,边倒角依赖当前 B-Rep 拓扑,部分边可能被 OCCT 拒绝。"] + blockers: list[str] = [] + curve = str(info.get("curve", "")) + length = float(info.get("length", 0.0)) + adjacent_count = int(info.get("adjacent_face_count", 0)) + + if curve != "line": + blockers.append("添加倒角第一版只支持直线 edge。") + if length <= 1e-9: + blockers.append("当前 edge 长度无效。") + if adjacent_count < 2: + blockers.append("当前 edge 没有检测到至少两个相邻 face,不能可靠添加倒角。") + elif adjacent_count > 2: + risk = _max_risk(risk, "medium") + warnings.append(f"当前 edge 相邻 face 数为 {adjacent_count},可能是复杂交汇边。") + + if distance is not None: + if distance <= 0: + blockers.append("倒角距离必须大于 0。") + elif length > 1e-9: + ratio = distance / length + if ratio >= 0.45: + blockers.append("倒角距离接近或超过 edge 长度的一半,第一版直接阻止。") + elif ratio > 0.25: + risk = _max_risk(risk, "high") + warnings.append("倒角距离超过 edge 长度的 25%,很容易导致倒角失败。") + elif ratio > 0.12: + risk = _max_risk(risk, "medium") + warnings.append("倒角距离相对 edge 长度偏大,请确认预览范围。") + + if blockers: + status = "blocked" + risk = "blocked" + elif risk in {"medium", "high"}: + status = "caution" + + if not warnings and not blockers: + note = "可以尝试给该直线边添加倒角。" + else: + note = " ".join(blockers + warnings) + return { + "chamfer_status": status, + "chamfer_risk": risk, + "chamfer_warnings": ";".join(warnings), + "chamfer_blockers": ";".join(blockers), + "chamfer_note": note, + } + + def _max_risk(current: str, candidate: str) -> str: levels = {"low": 0, "medium": 1, "high": 2, "blocked": 3} return candidate if levels[candidate] > levels[current] else current @@ -1428,6 +4485,26 @@ def _resize_mode(current_diameter: float, target_diameter: float) -> str: return "enlarge" if target_diameter > current_diameter else "shrink" +def _join_nonempty(*values: object) -> str: + return ";".join(str(value) for value in values if value not in {"", None}) + + +def _int_values(value: object) -> list[int]: + if value is None or value == "": + return [] + if isinstance(value, int): + return [value] + if isinstance(value, (list, tuple, set)): + result: list[int] = [] + for item in value: + try: + result.append(int(item)) + except (TypeError, ValueError): + continue + return result + return [] + + def _dir_tuple(direction) -> tuple[float, float, float]: return (direction.X(), direction.Y(), direction.Z()) @@ -1455,6 +4532,44 @@ def _point_on_axis(axis_point: gp_Pnt, direction, parameter: float) -> gp_Pnt: ) +def _direction_dot(left, right) -> float: + return left.X() * right.X() + left.Y() * right.Y() + left.Z() * right.Z() + + +def _axis_parameter(axis_point: gp_Pnt, direction, point: gp_Pnt) -> float: + return ( + (point.X() - axis_point.X()) * direction.X() + + (point.Y() - axis_point.Y()) * direction.Y() + + (point.Z() - axis_point.Z()) * direction.Z() + ) + + +def _point_axis_distance(axis_point: gp_Pnt, direction, point: gp_Pnt) -> float: + projected = _point_on_axis(axis_point, direction, _axis_parameter(axis_point, direction, point)) + return _vec_from_points(projected, point).Magnitude() + + +def _shape_axis_parameters(shape: TopoDS_Shape, axis_point: gp_Pnt, direction) -> list[float]: + parameters: list[float] = [] + try: + for vertex in TopologyExplorer(shape, ignore_orientation=True).vertices(): + point = BRep_Tool.Pnt(topods.Vertex(vertex)) + parameters.append(_axis_parameter(axis_point, direction, point)) + except Exception: + parameters.clear() + try: + parameters.append(_axis_parameter(axis_point, direction, _surface_center(shape))) + except Exception: + pass + return parameters + + +def _surface_center(shape: TopoDS_Shape) -> gp_Pnt: + props = GProp_GProps() + brepgprop.SurfaceProperties(shape, props) + return props.CentreOfMass() + + def _orientation_name(orientation) -> str: return ORIENTATION_TYPES.get(orientation, f"type {orientation}") @@ -1467,5 +4582,35 @@ def _vec_from_points(a: gp_Pnt, b: gp_Pnt) -> gp_Vec: return gp_Vec(b.X() - a.X(), b.Y() - a.Y(), b.Z() - a.Z()) +def _point_distance_sq( + point: tuple[float, float, float], + target: tuple[float, float, float], +) -> float: + dx = point[0] - target[0] + dy = point[1] - target[1] + dz = point[2] - target[2] + return dx * dx + dy * dy + dz * dz + + +def _point_segment_distance_sq( + point: tuple[float, float, float], + start: tuple[float, float, float], + end: tuple[float, float, float], +) -> float: + vx = end[0] - start[0] + vy = end[1] - start[1] + vz = end[2] - start[2] + wx = point[0] - start[0] + wy = point[1] - start[1] + wz = point[2] - start[2] + length_sq = vx * vx + vy * vy + vz * vz + if length_sq <= 1e-18: + return _point_distance_sq(point, start) + t = (wx * vx + wy * vy + wz * vz) / length_sq + t = max(0.0, min(1.0, t)) + projection = (start[0] + t * vx, start[1] + t * vy, start[2] + t * vz) + return _point_distance_sq(point, projection) + + def _dot(vec: gp_Vec, direction) -> float: return vec.X() * direction.X() + vec.Y() * direction.Y() + vec.Z() * direction.Z()