diff --git a/.gitignore b/.gitignore index 1ae154e..66bcc2f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,10 @@ dist/ *_edited.step *_export.step *_diff_*.txt +*_operation_history.json *.log +docs/screenshots/ + .DS_Store Thumbs.db diff --git a/README.md b/README.md index efc6aa5..eaa7fa4 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ - 展示三维模型,支持旋转、缩放、平移。 - 读取 STEP 中的零件、装配、solid 等结构。 - 用户可以选择零件、solid、面、边和局部几何特征。 +- 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。 - 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。 -- 用户可以修改可控的局部特征,比如平面推拉、孔径调整、盲孔深度调整、凸台直径调整、边倒圆、边倒角和受限直线边长度调整。 +- 用户可以修改可控的局部特征,比如平面推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、凸台直径调整、边倒圆、边倒角和第一版直接边长调整。 - 用户可以撤销/重做修改,避免实验性编辑一步做坏。 - 用户可以导出修改后的完整模型。 - 如果 STEP 文件里存在多个零件,用户可以只导出选中的某个零件。 @@ -55,14 +56,19 @@ - Edge - Feature - 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。 +- `Face` 模式会把属于同一几何面的连续区域一起高亮;平面会按共面且投影范围相接/重叠查找,圆柱面会按同一实体内同轴、同半径且轴向连续/重叠查找,不再要求旧段和新段必须共享同一条拓扑 edge。例如推拉后侧壁被 OCCT 拆成上下多段 face,点击任意一段时都会把整片同域区域一起高亮。 +- 推拉后程序会尽量保留侧壁面区域的 `逻辑 Face ID`:即使 OCCT 把原来的一个侧壁拓扑 face 重建成上下两段,新旧两段也会绑定回推拉前那片侧壁的逻辑 ID。属性表会同时显示 `逻辑 Face ID` 和 `拓扑 Face ID`;前者面向用户选择和按 ID 定位,后者用于调试当前 B-Rep 拓扑。 +- 操作历史会记录 `target_logical_id` 和当时的拓扑 face id。点击历史记录定位 face / feature 时,会优先用逻辑 ID 找回当前模型中的整片面区域;撤销/重做快照也会保留这层逻辑 ID 映射。 - 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界 edge。 - 支持按当前选择模式输入 ID 直接选择 Part / Solid / Face / Edge / Feature。 +- Face / Feature 按 ID 选择会优先解析 `逻辑 Face ID`。例如推拉前侧壁是 558,推拉后当前拓扑里变成 1556 和 1802,只要它们被绑定到逻辑 558,输入 558 就会选中这片面区域。 - `鼠标选择模式` 下拉框不会响应鼠标滚轮,避免鼠标经过时误切换模式;需要点击后手动选择。 - `按 ID 选择` 会在输入框和 `选择` 按钮之间同步显示当前鼠标选择模式。 - `Feature` 模式现在会把点到的 face 解释为局部几何特征候选: - 平面会识别为可推拉平面候选。 + - 平面会尝试查找同一 solid 内投影重叠的相对平面,用于估算薄壁/壳体局部区域厚度。 - 圆柱面会识别为圆柱孔候选、槽/半孔候选、圆角/倒圆候选、凸台/外圆候选或未明确圆柱特征。 - - 圆柱特征会尝试找出边界 edge、相邻 face、端面 face、开口端相邻 face 和疑似底面 face,并一起高亮相关局部区域。 + - 圆柱特征会先把同一实体内同轴、同半径且轴向连续/重叠的完整圆柱 face 当作同一侧壁区域,再尝试找出边界 edge、相邻 face、端面 face、开口端相邻 face 和疑似底面 face,并一起高亮相关局部区域。 - 槽/半孔候选会额外显示槽宽、圆弧长度、槽深估算和槽边界相邻 face。 - 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑 face。 - 支持显示选中对象信息,并提供 `属性表` / `原始文本` 两种查看方式: @@ -87,16 +93,22 @@ - 直线 edge 的方向,圆弧 edge 的圆心、轴线、半径和直径。 - 属性面板支持复制当前对象 ID、拾取坐标和完整信息。 - 面/边属性查询带缓存;重复选择同一个对象会复用上次计算结果,模型编辑、撤销、重做或重新加载后缓存会自动清空。 -- 支持高亮选中的零件、solid、面、边或局部特征候选,并在鼠标悬停时用红色预览当前可选对象。 +- 支持高亮选中的零件、solid、面、边或局部特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face 选择会对共面平面和同轴同半径圆柱面做同域区域高亮。 +- 默认模型边线渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔推拉后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部 edge,仍然可以单独高亮查看。 - 支持只显示当前选中对象、对准当前选中对象,并可一键恢复显示完整模型。 -- Feature 模式会在属性表里显示特征来源 face、侧壁 face、端面 face、疑似底面 face、开口端相邻 face、边界 edge 和当前可用操作。 +- 支持两点测量: + - 可以把当前选中对象的拾取点设为 A 或 B;如果没有拾取点,会退回使用对象中心、面积中心、长度中心、重心或包围盒中心。 + - 设置 A/B 后会显示两点距离和 X/Y/Z 方向差值。 + - 3D 视图中会显示一条青色测量线;测量结果可以复制,加载新模型时会自动清除旧测量。 +- Feature 模式会在属性表里显示特征来源 face、侧壁 face、同域圆柱范围、端面 face、疑似底面 face、开口端相邻 face、边界 edge 和当前可用操作。 - 支持导出当前完整模型为 STEP。 - 支持导出选中的 part 为 STEP。 - 支持导出选中的 solid 为 STEP。 -- 支持导出选中的 face 为 STEP。 +- 支持导出选中的 face / 同域面区域为 STEP;如果当前 face 与共面或同轴同半径的相邻面一起高亮,导出也会包含整片黄色区域。 - 支持导出选中特征区域为 STEP。 - 支持导出选中的 edge 为 STEP。 - 支持导出质量检查:导出前会检查 B-Rep 有效性、solid / face / edge 数量、体积和包围盒;如果发现风险,会先弹窗确认。 +- 支持一键修复当前模型或选中零件/solid:执行 ShapeFix 和同域面/边合并,修复会进入后台编辑流程,并支持撤销/重做。 - 导出完整模型、part 或 solid 前,会先尝试执行 ShapeFix 和同域面/边合并,减少外部 CAD 打开时出现碎面、坏体或明显拼接痕迹的概率。 - 实验性编辑按钮会根据当前选中对象自动启用或禁用,并通过悬停提示说明为什么当前操作不可用,减少 face、edge、孔、凸台、盲孔等对象混用导致的误操作。 - 支持扫描第一版可编辑对象: @@ -104,13 +116,14 @@ - `深度扫描` 会尝试列出更多候选,适合默认列表里没有找到目标对象时使用。 - 可推拉的平面 face。 - 可调整孔径的圆柱 face 候选。 + - 可调整槽/半孔宽度的局部圆柱孔/槽候选。 - 可调整盲孔深度的圆柱 face 候选。 - 可添加新圆角的直线 edge 候选。 - 可添加倒角的直线 edge 候选。 - 可尝试通过端面推拉调整长度的直线 edge 候选。 - 表格会显示当前值、状态、风险、置信度和说明。 - 盲孔深度候选会在列表阶段先确认疑似底面,列表里的深度值优先使用底面 face 轴向位置计算。 - - 点击表格行会直接选中对应 face 或 edge,方便继续执行推拉、孔径调整、盲孔深度调整、边倒圆、边倒角或受限直线边长度调整。 + - 点击表格行会直接选中对应 face 或 edge,方便继续执行推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、边倒圆、边倒角或直接边长调整。 - 支持列出圆柱面候选特征。 - 圆柱候选会显示在表格中,点击候选行可以直接选中并高亮对应 face。 - 圆柱候选可以按类型筛选:全部、孔/槽候选、圆角候选、凸柱/外圆候选、未明确分类。 @@ -126,13 +139,15 @@ - 实验性支持平面 face 推拉。 - 平面推拉会自动判断面两侧的 inside / outside,输入正数表示向外加料,输入负数表示向内切削。 - 推拉平面会先显示半透明预览体,表示推拉方向和大致范围,然后在后台执行真实 OCCT 布尔计算。 -- 推拉平面现在会从选中 face 出发,自动查找共享边且共面的相邻 face,并把它们作为同一片平面区域一起推拉。 +- 推拉平面现在会从选中 face 出发,自动查找共面且相接/重叠的相邻 face,并把它们作为同一片平面区域一起推拉。 - 这可以减少 STEP 碎面导致的“只推了一小块,导出后像贴上去一块,中间有伤疤”的情况。 +- 如果选中的平面被识别为完整圆柱端盖,并且是向外拉长,程序会优先使用“完整旧段 + 新段圆柱”的专用 Fuse 路线,而不是只把一个短圆柱 prism 拼到端面上。简单圆柱场景下,这会让拉长结果保持为一个连续圆柱侧面,减少新旧圆柱段拼接痕迹。 - 推拉加料时,工具体会向原实体内部保留一个很小的重叠量,再执行 Fuse,避免只靠两个面贴合导致导出后像“原模型 + 新增块”叠在一起。 - Fuse/Cut 完成后会执行更强的推拉结果清理:按模型尺寸设置小容差,显式禁止保留 internal edges,并多轮执行 OCCT 同域面/边合并,减少第三方 CAD 中出现不必要共面分割线的概率。 +- 布尔计算前会启用 OCCT glue/fuzzy 选项,布尔后会调用 `SimplifyResult` 和同域合并;但某些 CAD/OCCT 场景仍可能保留拓扑分割边。当前程序会先在选择层把同域平面/圆柱区域作为整体高亮,并在默认边线渲染中隐藏同域内部拓扑边和几何重复拼接边;真正从 STEP/B-Rep 拓扑里消除这类分割边,还需要后续做更专门的局部圆柱/侧壁重建。 - 如果推拉本身产生了真实台阶边界,第三方 CAD 仍会显示这条几何边;清理只能减少不必要的共面残留边,不能消除真实形状边界。 -- 推拉平面会先生成操作计划:检查距离是否为 0、方向置信度和相对 face 尺寸的风险;高风险会先弹窗确认。 -- 调整圆柱孔径也会先显示半透明预览体:红色表示切削 cutter,绿色表示缩小孔径时的补料范围。 +- 推拉平面会先生成操作计划:检查距离是否为 0、方向置信度和相对 face 尺寸的风险;能推拉时直接执行,不能推拉时会提示阻止原因。 +- 调整圆柱孔径也会先显示半透明预览体:红色表示切削 cutter,绿色表示缩小孔径时的补料范围;如果圆柱侧壁被拆成多段同域 face,工具范围会优先覆盖整段同域圆柱。 - 支持第一版盲孔/盲槽深度调整: - 只对 `hole/groove candidate` 且端部类型为 `blind`、并识别到疑似底面 face 的圆柱候选开放。 - 目标深度大于当前估算深度时,会沿开口到疑似底面方向用有限长度 cutter 加深切削。 @@ -145,6 +160,10 @@ - 会估算槽宽、圆弧长度和槽深。 - 会识别槽圆柱 face 和槽边界相邻 face,并在 Feature 模式下一起高亮。 - 这些仍是 B-Rep 几何估算,不是 CAD 历史里的槽特征参数。 +- 支持第一版槽/半孔宽度调整: + - 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。 + - 输入目标槽宽后,会按当前圆弧角度把槽宽换算为目标圆柱直径,再复用圆柱孔/槽重建路线执行。 + - 这不是完整 CAD 槽参数编辑;复杂槽、非圆柱槽或圆弧角度不稳定时仍可能失败并回滚。 - 支持第一版已有圆角/倒圆候选结构化识别: - 对局部小半径圆柱面,会标记为 `round/fillet candidate`。 - 会估算已有圆角半径、圆弧角度和圆弧长度。 @@ -161,9 +180,9 @@ - 支持第一版圆柱凸台直径调整: - 只对 `boss/outer-round candidate` 且角度跨度接近完整圆柱的凸台开放。 - 不处理局部外圆角、圆角面或未明确圆柱面,避免把圆角当凸台修改。 - - 目标直径变大时,会生成目标直径圆柱并 Fuse。 - - 目标直径变小时,会生成环形 cutter 并 Cut。 - - 会先显示半透明预览:绿色表示扩大补料范围,红色表示缩小切削范围。 + - 目标直径变大时,会在同域圆柱侧壁范围内生成目标直径圆柱并 Fuse。 + - 目标直径变小时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建,避免环形 cutter 在部分场景下把实体切空。 + - 会先显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。 - 当前默认模型可能没有明确凸台候选;包含完整圆柱凸台的 STEP 文件可以使用这个能力。 - 支持第一版直线边添加圆角: - 只对直线 edge 开放。 @@ -178,13 +197,15 @@ - 橙色半透明预览表示目标 edge 和输入倒角距离的大致范围。 - 真实编辑使用 OCCT `BRepFilletAPI_MakeChamfer` 在后台执行。 - STEP 没有建模历史,某些边仍可能被 OCCT 拒绝;失败时会自动尝试恢复编辑前状态。 -- 支持第一版受限直线边长度调整: - - 只对直线 edge 开放。 - - 选中直线 edge 后,`边目标长度` 会自动填入当前边长。 - - 输入目标长度后,程序会尝试寻找这条边端点附近、方向匹配的平面端面。 - - 找到端面后,会把“改边长”转换成该端面的平面推拉操作,并显示蓝绿色半透明预览。 - - 如果找不到可靠端面,或者目标长度无效,会直接阻止,不会强行修改。 - - 这不是通用参数化边长编辑;复杂拓扑、斜面过渡、圆角链或没有明确端面的边仍可能不能改。 +- 支持第一版直接边长调整: + - 选中 edge 后,`边目标长度` 会自动填入当前边长。 + - 可以选择边长基准:自动、中心、固定起点、固定终点。 + - 直线 edge 会优先尝试寻找端点附近、方向匹配的平面端面,并把“改边长”转换成该端面的平面推拉操作。 + - `自动` 会优先选择可局部推拉的端面;`固定起点` / `固定终点` 会优先尝试移动另一端端面。 + - 圆形/圆弧 edge 会优先检查相邻圆柱面;如果能复用孔/槽或凸台直径编辑,就把目标边长按比例换算成目标圆柱直径再执行局部编辑。 + - `中心`、没有明确端面、或圆边没有可复用圆柱面时,会尝试整体/轴向几何缩放 fallback,以覆盖更多任意 edge 的最小可用场景。 + - 该 fallback 会影响所属 part/solid 的其它尺寸,因此会按高风险确认,并依赖失败回滚/撤销兜底。 + - 这仍不是通用参数化边长编辑;复杂拓扑、斜面过渡、圆角链或布尔失败场景仍可能不能稳定修改。 - 支持 part / solid 级平移: - 输入 X/Y/Z 平移量后,可以平移当前选中零件。 - 选中 solid、或选中属于某个 solid 的 face/edge 后,可以平移当前 solid。 @@ -195,7 +216,8 @@ - 第一版旋转中心取目标对象的包围盒中心。 - 单 solid 零件中旋转 solid 实际等同于旋转整个 part shape,程序会按中风险提示。 - 旋转操作会写入历史记录,并支持撤销/重做。 -- 后台编辑计算时会显示进度提示,主界面会暂时置灰,并阻止选择、扫描、导出、撤销/重做、历史详情查看或关闭窗口。 +- 后台编辑计算时不再弹出模态进度框,也不再把主界面整体置灰;UI 线程只负责渲染和响应基础视图交互,真实 B-Rep 编辑在后台线程执行。 +- 编辑计算期间,半透明预览会继续在 3D 视图里渲染,用户可以旋转或缩放查看;会改变模型状态的操作仍会暂时禁用,避免同一模型被多个后台编辑同时修改。 - 真实 B-Rep 结果会在布尔计算完成后一次性刷新;半透明预览不等于最终几何结果。 - 如果后台编辑失败,程序会自动尝试恢复到编辑前快照,避免出现“提示失败但模型已经被部分改动”的状态。 - 实验性支持圆柱孔/圆柱面扩大切削。 @@ -233,6 +255,7 @@ - 支持导出选中操作历史的差异报告,报告会包含操作参数、拓扑变化、几何变化和热力图统计。 - 鼠标拾取模型时会显示三维拾取点坐标。 - 实验性编辑历史会记录目标对象和当时的拾取点。 +- 支持把本软件加载后的编辑历史导出为 JSON;这不是 STEP 原始 CAD 建模历史,而是后续操作留档。 - 点击操作历史记录时,程序会尝试重新高亮目标对象,并显示当时的拾取点标记。 - 对非 `hole/groove candidate` 的圆柱切削会先弹出确认,避免误切外圆角或凸柱。 - 提供 `--smoke-test` 启动检查,用于验证 Qt + VTK 窗口组件能正常初始化。 @@ -258,28 +281,27 @@ vertices: 3262 - 复杂特征识别:已经有平面、圆柱、边、孔/槽候选、槽/半孔候选、已有圆角候选、圆柱凸台候选等基础候选列表,但还不是完整 CAD 语义级识别。 - 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面 face 的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。 -- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、完整圆柱凸台直径修改,复杂槽、复杂凸台和壳体局部区域仍需要继续做。 +- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度调整、完整圆柱凸台直径修改和平面薄壁/壳体相对面厚度估算;复杂槽、复杂凸台和完整壳体区域仍需要继续做。 - 已有圆角/倒圆面半径:已经有第一版受限修改实现,使用 defeature + refillet 路线,但复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。 - 局部偏差分析:已经有编辑前后体积、面积、包围盒、距离热力图和差异报告,但还不是工程公差级的局部区域偏差报告。 -- 错误恢复和模型修复:已经有编辑前快照、失败回滚、基础 B-Rep 校验、同域面合并和布尔结果自动修复,但还不是完整的模型修复流水线。 +- 错误恢复和模型修复:已经有编辑前快照、失败回滚、基础 B-Rep 校验、同域面合并、布尔结果自动修复,以及一键修复当前模型或选中零件/solid,但还不是完整的模型修复流水线。 - 多零件装配:已经有装配/零件树和单独导出入口,但还没有做大规模、多来源 STEP 装配测试。 仍基本没有实现,或者 STEP 本身不一定能可靠支持: -- 通用任意边长直接修改。当前只有第一版受限直线边长度调整,会尝试通过端面推拉来改变长度;没有明确可推拉端面的边仍不能改。 -- 参数化建模历史恢复。STEP 通常只保存结果几何/拓扑,不能稳定还原原 CAD 软件里的建模历史,只能在本程序里记录后续编辑历史,或做近似的特征语义识别。 +- 参数化建模历史恢复。STEP 通常只保存结果几何/拓扑,不能稳定还原原 CAD 软件里的建模历史;当前只能在本程序里记录并导出后续编辑历史,或做近似的特征语义识别。 - 所有场景都稳定的复杂特征识别。 ## 下一步要实现什么 建议下一步按这个顺序推进: -1. 继续加固最小可用编辑闭环:选择、预览、执行、校验、修复、回滚、导出。 -2. 继续验证平面推拉、孔径调整、盲孔/盲槽深度调整和圆柱凸台直径调整,记录哪些对象稳定、哪些对象会失败。 +1. 继续补齐更多最小可用特征入口,优先覆盖复杂槽、复杂凸台、局部薄壁/壳体区域和装配用例。 +2. 继续验证平面推拉、孔径调整、槽/半孔宽度调整、盲孔/盲槽深度调整、圆柱凸台直径调整和直接边长调整,记录哪些对象稳定、哪些对象会失败。 3. 改进盲孔/槽底面识别,减少“看起来像 blind 但找不到可靠底面 face”的候选。 4. 继续加固已有圆角半径修改:扩大可成功场景,记录哪些圆角能 defeature + refillet,哪些需要更复杂的重建策略。 -5. 继续增加复杂槽、复杂凸台、局部区域识别。 -6. 优化导出策略,例如导出时保留更多名称、颜色和层级信息。 +5. 完善本软件编辑历史的导入/复放能力,让后续编辑不只是导出 JSON 留档。 +6. 最后集中做性能和稳定性优化,包括首次加载后台精细化、扫描节流和高风险 OCC 操作隔离。 ## 怎么运行 @@ -372,11 +394,24 @@ python main.py --smoke-test 左侧 `显示` 用来临时缩小三维视图范围: - `只显示选中`:只显示当前选中的 part、solid、face、edge 或 feature 区域。 +- Face 模式下 `只显示选中` 会隔离当前黄色同域面区域,并显示这片区域的外边界 edge。 - `对准选中`:不改变显示范围,只把相机对准当前选中的 part、solid、face、edge 或 feature,适合快速查看小孔、小边和局部特征。 - `显示全部`:恢复显示完整模型。 +- `显示同域内部边`:默认关闭,用来隐藏同一平面/同一圆柱面内部的拓扑分割边和几何重复拼接边;打开后可以查看完整 B-Rep 拓扑边线,适合调试布尔推拉后的分面情况。 +- `导出选中面区域` 会导出当前 Face 模式黄色高亮的同域面区域,而不是只导出鼠标点到的单个拓扑 face。 - solid / edge 的显示查询已经使用拓扑索引,避免每次隔离或高亮时反复全模型查找。 - 这是视图操作,不会修改 STEP 几何,也不会写入操作历史。 +左侧 `测量` 用来做最基础的点到点距离检查: + +- 先在模型中点击一个对象或具体位置,然后点击 `设为 A`。 +- 再点击另一个对象或位置,然后点击 `设为 B`。 +- 如果选择来自鼠标点击,测量会优先使用实际拾取点;如果是从结构树或 ID 选择,程序会使用对象中心、面积中心、长度中心、重心或包围盒中心。 +- 设置 A/B 后会显示两点距离和 `ΔX/ΔY/ΔZ`。 +- 3D 视图里会显示一条青色测量线,方便确认测量方向。 +- `复制测量` 会复制当前测量结果;`清除测量` 会清空 A/B 和测量线。 +- 测量不会修改模型,也不会写入操作历史。 + 左侧 `第一版可编辑对象` 是当前 MVP 的主要入口: - 点击 `扫描可编辑对象` 会列出当前第一版能尝试编辑的对象。 @@ -385,6 +420,7 @@ python main.py --smoke-test - 当前默认显示一批代表性的可编辑候选,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。 - `推拉平面` 行表示这个 face 是平面,可以配合 `面偏移` 和 `推拉平面` 使用。 - `调整圆柱孔径` 行表示这个 face 是圆柱候选,可以配合 `孔直径` 和 `调整圆柱孔径` 使用。 +- `调整槽/半孔宽度` 行表示这个 face 是槽/半孔候选,可以配合 `槽/半孔宽度` 和 `调整槽/半孔宽度` 使用。 - `调整圆柱凸台直径` 行表示这个 face 是较明确的完整圆柱凸台候选,可以配合 `凸台直径` 和 `调整圆柱凸台直径` 使用。 - `封堵圆柱孔` 行表示这个 face 是接近完整圆柱的孔候选,可以点击后直接使用 `封堵圆柱孔`。 - `调整盲孔深度` 行表示这个 face 是较明确的盲孔/盲槽候选,可以配合 `孔深度` 和 `调整盲孔深度` 使用。 @@ -398,6 +434,7 @@ python main.py --smoke-test 选择对象后: - 鼠标经过对象时会出现红色预高亮;被选对象会显示为黄色高亮。 +- 在 `Face` 模式下,如果相邻 face 属于同一几何面,并且投影范围或轴向范围相接/重叠,程序会把它们作为同域区域一起红色预高亮和黄色选中高亮;这主要用于缓解平面/圆柱推拉后视觉上被拓扑边分成多段的问题。 - 如果处于 `Feature` 模式,程序会尽量高亮这个特征相关的局部区域,例如圆柱侧壁、相邻端面和槽边界相邻面。 - 对槽/半孔候选,属性表会显示 `槽宽估算`、`槽圆弧长度估算`、`槽深估算` 和 `槽边界相邻 Face`。 - 对圆角/倒圆候选,属性表会显示 `已有圆角半径估算`、`已有圆角圆弧角度`、`已有圆角圆弧长度估算` 和 `已有圆角支撑 Face`。 @@ -438,6 +475,8 @@ python main.py --smoke-test - `导出选中特征区域`:先切换到 `Feature` 选择模式并选中局部特征,再导出当前特征高亮的 face 集合。 - `导出选中 edge`:先切换到 `Edge` 选择模式并选中一个 edge,再导出该 edge。 - `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。 +- `修复当前模型`:对当前完整模型执行 ShapeFix 和同域面/边合并,完成后会刷新显示并写入操作历史;如果修复失败,会尝试回滚到修复前状态。 +- `修复选中零件/solid`:优先修复当前选中对象所属 solid;如果当前对象没有 solid,则修复所属零件。face 或 edge 被选中时,也会按其所属 solid/零件执行局部修复。 - 导出按钮会根据当前选中对象自动启用或禁用,避免选中 face 时误点 `导出选中 edge` 这类不匹配的操作。 - 所有导出按钮都会先做质量检查。发现 B-Rep 无效、没有 solid、零件包含多个 solid、体积接近 0 等风险时,会先弹窗确认是否继续导出。 @@ -451,7 +490,7 @@ python main.py --smoke-test - 负数表示沿内侧方向切削。 - 点击 `推拉平面` 后,会先出现半透明预览体;绿色表示向外加料方向,红色表示向内切削方向。 - 如果距离为 0 会直接阻止;如果方向置信度低或距离相对 face 尺寸偏大,会先弹窗确认。 - - 如果选中 face 周围有共享边且共面的碎面,程序会自动把这些 face 合成同一片推拉区域,属性表和历史记录会显示 `推拉共面区域 Face 数`。 + - 如果选中 face 周围有共面且相接/重叠的碎面,程序会自动把这些 face 合成同一片推拉区域,属性表和历史记录会显示 `推拉共面区域 Face 数`。 - 加料时程序会让拉伸体和原实体产生极小重叠,并在布尔后尝试合并同域面/边,使导出的 STEP 更像一个整体实体。 - 程序随后在后台执行真实布尔运算,完成后再刷新为真正修改后的模型。 - 选中平面 face 后,可以在 `属性表` 的方向/轴线分组里查看 `推拉向外方向`、`推拉向内方向` 和 `推拉方向置信度`。 @@ -476,12 +515,18 @@ python main.py --smoke-test - 缩小孔径时,操作历史会额外记录补料策略、补料半径和补料高度。 - 端部类型是通过轴线端部 inside / outside 采样得到的估算,不等于 CAD 原始建模历史里的“孔深”参数。 - 如果选到的圆柱面不是孔,而是柱或圆角,结果可能不是你想要的,所以它目前仍是实验功能。 +- `槽/半孔宽度` + `调整槽/半孔宽度`: + - 先切换到 `Feature` 选择模式并选择一个槽/半孔候选 face,或从圆柱候选表中选择局部圆柱形 `hole/groove candidate`。 + - 选中符合条件的槽/半孔后,`槽/半孔宽度` 会自动填入一个比当前槽宽估算略大的建议值。 + - 输入目标槽宽后点击 `调整槽/半孔宽度`。 + - 程序会用当前圆弧角度把目标槽宽换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 + - 这一步仍是 B-Rep 几何近似;复杂槽、非圆柱槽或圆弧角度不稳定时可能失败并回滚。 - `凸台直径` + `调整圆柱凸台直径`: - 先选择一个完整圆柱凸台 face,建议从 `第一版可编辑对象` 中点击 `调整圆柱凸台直径` 行进入。 - 只有接近完整圆柱的 `boss/outer-round candidate` 会放行;局部外圆角、圆角面、孔/槽和未明确圆柱面会被阻止。 - - 目标直径大于当前直径时,会在选中圆柱面的轴向范围内生成目标直径圆柱并 Fuse。 - - 目标直径小于当前直径时,会生成环形 cutter,把目标直径外侧到原直径附近的材料 Cut 掉。 - - 点击后会显示半透明预览:绿色表示扩大补料范围,红色表示缩小切削范围。 + - 目标直径大于当前直径时,会在同域圆柱侧壁范围内生成目标直径圆柱并 Fuse。 + - 目标直径小于当前直径时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建。 + - 点击后会显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。 - 这不是 CAD 历史里的“修改凸台特征参数”,而是基于当前 B-Rep 的受限几何布尔编辑。 - `封堵圆柱孔`: - 先选择一个完整圆柱孔 face,建议从 `第一版可编辑对象` 中点击 `封堵圆柱孔` 行进入。 @@ -527,14 +572,18 @@ python main.py --smoke-test - 真实编辑会在后台调用 OCCT 倒角 API,完成后刷新模型。 - 当前只支持对称距离倒角,不支持两侧不同距离或距离+角度倒角。 - 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒角;失败时程序会提示并尝试恢复编辑前状态。 -- `边目标长度` + `调整直线边长度`: - - 先切换到 `Edge` 选择模式并选择一条直线 edge,或从 `第一版可编辑对象` 中点击 `调整直线边长度` 行进入。 - - 选中直线 edge 后,`边目标长度` 会自动填入当前边长。 - - 把它改成目标长度后点击 `调整直线边长度`。 - - 程序会先查找该边端点附近是否有可推拉的平面端面,并计算需要移动的端面 face 和推拉距离。 - - 通过检查后,会显示蓝绿色半透明预览,表示将要移动的端面推拉范围。 - - 真实编辑复用平面推拉的布尔流程,执行失败时会回滚到编辑前状态。 - - 如果这条边没有明确端面,或端面方向与边长变化不匹配,会被阻止。这是受限功能,不是任意边参数化改长。 +- `边目标长度` + `直接修改边长`: + - 先切换到 `Edge` 选择模式并选择一条 edge,或从 `第一版可编辑对象` 中点击边长相关行进入。 + - 选中 edge 后,`边目标长度` 会自动填入当前边长。 + - 右侧边长基准下拉框可以选择 `自动`、`中心`、`固定起点` 或 `固定终点`。 + - 把它改成目标长度后点击 `直接修改边长`。 + - 直线 edge 会优先查找端点附近是否有可推拉的平面端面,并计算需要移动的端面 face 和推拉距离。 + - 圆形/圆弧 edge 会优先尝试复用相邻圆柱面的孔/槽或凸台直径编辑,并在确认窗口显示换算出来的目标直径。 + - `固定起点` 会尽量让起点不动、移动终点侧;`固定终点` 相反;`中心` 会让缩放 fallback 以 edge 中心为基准。 + - 如果没有明确端面或可复用圆柱面,程序会按所选基准尝试整体/轴向几何缩放 fallback;这会影响所属 part/solid 的其它尺寸,因此会弹出高风险确认。 + - 通过检查后,会显示蓝绿色半透明预览,表示将要移动或缩放的范围。 + - 真实编辑在后台执行,失败时会回滚到编辑前状态。 + - 这是第一版 B-Rep 结果几何修改,不是任意边参数化改长。 - `平移 X/Y/Z` + `平移选中零件` / `平移选中 solid`: - 输入 X、Y、Z 三个方向的平移量。 - 选择 `Part` 后点击 `平移选中零件`,会移动整个 part。 @@ -564,20 +613,59 @@ python main.py --smoke-test - 历史详情会显示热力图统计,包括最大距离、平均距离和发生明显变化的采样点比例。 - `清除差异预览` 可以关闭红/绿叠加显示。 - `导出差异报告` 会把当前选中的历史记录导出为 `.txt`,里面包含操作详情、拓扑变化、几何变化和热力图统计。 + - `导出编辑历史` 会把当前会话中的本软件编辑记录导出为 `.json`,用于留档和后续复盘。 - 当前热力图是“编辑后模型顶点到编辑前模型表面”的距离估算,不是完整 CAD 公差报告。 - 点击某条历史记录时,程序也会尝试重新高亮当时编辑的目标 face,并用青色小点标出当时的拾取位置。 - 由于布尔编辑后拓扑 ID 可能重新分配,历史里的 face ID 只能作为定位参考,不能视为稳定的 CAD 建模历史 ID。 - 撤销后对应记录会从列表里退回。 - 重做后对应记录会重新显示。 -## 文件说明 +## 目录结构 + +当前整理后的项目结构是: + +```text +pythonocc-step-editor/ + main.py # 程序入口,保持 python main.py 启动 + docs/ + screenshots/ # 调试截图和问题截图 + step_editor/ + __init__.py # 包导出 + app.py # 主窗口初始化、UI 搭建和程序启动 + constants.py # OCCT 曲面、曲线和方向类型常量映射 + export.py # 导出、导出质量检查和面区域导出逻辑 + features.py # 平面、圆柱、孔、槽、凸台、圆角等候选识别和编辑计划 + geometry_utils.py # OCC 几何、拓扑、布尔修复、向量和形状工具函数 + info_panel.py # 属性面板、信息树和复制信息逻辑 + model.py # StepModel 核心状态、拓扑索引、基础属性和 mixin 组合 + model_types.py # PartNode、TopologyStats 等模型数据结构 + operations.py # 推拉、孔径、孔深、圆角、倒角、边长等实际编辑操作 + polydata.py # OCC shape / edge / face 到 VTK polydata 的显示数据生成 + records.py # 操作历史记录数据结构 + step_io.py # STEP/XCAF 读取、产品名解析和 STEP 写出 + transforms.py # part / solid 平移和旋转 + ui_helpers.py # UI 常量、格式化函数和 VTK 显示小工具 + widgets.py # 自定义 Qt 小组件 + window_actions.py # 导出、编辑按钮、扫描、后台编辑调度等窗口动作 + window_core.py # 文件加载、结构树、VTK 视图、选择、拾取、高亮和预览 + window_state.py # 选择状态、按钮状态、撤销/重做和历史定位 + workers.py # 后台编辑和后台扫描 worker + environment.yml # Conda 环境说明,用于安装 pythonocc-core、VTK、PySide6 等依赖 + geom_extract.step # 当前默认测试模型 + README.md # 项目说明和使用说明 +``` + +这次整理把根目录收窄到入口、环境配置、默认模型和文档,主要代码放到 `step_editor/` 包里;问题截图和调试截图放到 `docs/screenshots/`。后端和窗口层都按 mixin / 工具模块拆开,尽量不改变功能行为。 + +`step_editor/model.py` 现在主要保留 StepModel 的核心状态、拓扑缓存、基础属性查询和 mixin 组合;具体能力分散到 `features.py`、`operations.py`、`export.py`、`transforms.py`、`polydata.py`、`step_io.py` 和 `geometry_utils.py`。 + +`step_editor/app.py` 现在主要保留主窗口初始化、左侧操作面板搭建和程序入口;VTK 视图、选择高亮、编辑动作、扫描、撤销/重做和属性面板逻辑已经拆到 `window_core.py`、`window_actions.py`、`window_state.py`、`info_panel.py` 和 `ui_helpers.py`。 + +其他文件说明: -- `main.py`:程序入口和桌面界面。 -- `step_model.py`:STEP/OCCT 后端逻辑。 -- `environment.yml`:Conda 环境说明,用于安装 pythonocc-core、VTK、PySide6 等依赖。 - `.gitignore`:Git 忽略规则,避免提交 Python 缓存、临时文件和导出的 STEP 文件。 -- `geom_extract.step`:当前默认测试模型。 -- `README.md`:项目说明和使用说明。 +- `*_edited.step`、`*_export.step`、`*_diff_*.txt`、`*_operation_history.json`:运行或导出时产生的结果文件,默认不提交。 +- `docs/screenshots/`:调试截图和问题截图,例如第三方软件显示效果、模型结构树截图等。 ## 当前版本边界 diff --git a/main.py b/main.py index aac473c..d7527b4 100644 --- a/main.py +++ b/main.py @@ -1,5031 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass -from datetime import datetime -import math -import sys -from pathlib import Path - -import vtk -from PySide6.QtCore import QObject, Qt, QThread, QTimer, Signal, Slot -from PySide6.QtWidgets import ( - QAbstractItemView, - QApplication, - QComboBox, - QFileDialog, - QGridLayout, - QGroupBox, - QHBoxLayout, - QLabel, - QLineEdit, - QListWidget, - QMainWindow, - QMessageBox, - QPushButton, - QPlainTextEdit, - QProgressDialog, - QScrollArea, - QTableWidget, - QTableWidgetItem, - QTabWidget, - QTreeWidget, - QTreeWidgetItem, - QVBoxLayout, - QWidget, -) -from vtkmodules.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor - -from step_model import StepModel - - -@dataclass -class OperationRecord: - summary: str - detail: str - target_kind: str | None = None - target_id: int | None = None - pick_position: tuple[float, float, float] | None = None - before_snapshot: dict[int, object] | None = None - after_snapshot: dict[int, object] | None = None - diff_stats: dict[str, object] | None = None - - -class EditWorker(QObject): - finished = Signal(object) - failed = Signal(str) - - def __init__(self, action): - super().__init__() - self.action = action - - @Slot() - def run(self) -> None: - try: - self.finished.emit(self.action()) - except Exception as exc: - self.failed.emit(str(exc)) - - -class NoWheelComboBox(QComboBox): - def wheelEvent(self, event) -> None: - event.accept() - - -INFO_GROUPS: list[tuple[str, list[str]]] = [ - ( - "身份", - [ - "kind", - "name", - "path", - "file", - "part_id", - "solid_id", - "face_id", - "edge_id", - "parent_id", - "depth", - "feature_mode", - "feature_type", - "feature_source_face_id", - ], - ), - ( - "拓扑", - [ - "parts", - "solids", - "faces", - "edges", - "vertices", - "boundary_edges", - "orientation", - "surface", - "curve", - "adjacent_face_ids", - "adjacent_face_count", - ], - ), - ( - "测量", - [ - "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", - "semi_angle", - "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", - ], - ), - ( - "位置", - [ - "pick_position", - "center", - "center_of_mass", - "surface_center", - "area_center", - "length_center", - "bbox_min", - "bbox_max", - "bbox_size", - "start_point", - "end_point", - "translation_vector", - "rotation_center", - "end_face_id", - "end_face_label", - "end_face_plane_distance", - "push_pull_distance", - ], - ), - ( - "方向 / 轴线", - [ - "normal", - "oriented_normal", - "plane_origin", - "axis_point", - "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", - ], - ), - ( - "参数", - [ - "u_range", - "v_range", - "first_parameter", - "last_parameter", - "param_height", - ], - ), - ( - "特征判断", - [ - "feature_guess", - "confidence", - "material_vote_summary", - "material_sample_count", - "material_toward_axis", - "material_away_axis", - "cylinder_end_type", - "start_end_state", - "end_end_state", - "start_end_open", - "end_end_open", - "open_end_count", - "closed_end_count", - "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", - "cutter_start_margin", - "cutter_end_margin", - "cutter_radius", - "cutter_start_parameter", - "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", - "fill_radius", - "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", - ], - ), -] - - -INFO_LABELS = { - "kind": "类型", - "name": "名称", - "path": "层级路径", - "file": "文件", - "part_id": "Part ID", - "solid_id": "Solid ID", - "face_id": "Face ID", - "edge_id": "Edge ID", - "parent_id": "父级 ID", - "depth": "层级深度", - "feature_mode": "特征模式说明", - "feature_type": "特征类型", - "feature_source_face_id": "特征来源 Face", - "parts": "零件数", - "solids": "Solid 数", - "faces": "Face 数", - "edges": "Edge 数", - "vertices": "Vertex 数", - "boundary_edges": "边界边数", - "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": "参考半径", - "semi_angle": "半角", - "angular_span": "角度跨度", - "height_estimate": "估算高度", - "hole_depth_estimate": "孔/槽深度估算", - "current_depth": "当前深度", - "target_depth": "目标深度", - "delta_depth": "深度变化量", - "depth_delta_ratio": "深度变化比例", - "is_full_cylinder": "接近完整圆柱", - "bbox_diagonal": "包围盒对角线", - "pick_position": "拾取点", - "center": "中心", - "center_of_mass": "重心", - "surface_center": "表面积中心", - "area_center": "面积中心", - "length_center": "长度中心", - "bbox_min": "包围盒最小点", - "bbox_max": "包围盒最大点", - "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": "平面原点", - "axis_point": "轴线点", - "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": "起始参数", - "last_parameter": "结束参数", - "param_height": "参数高度", - "feature_guess": "候选判断", - "confidence": "置信度", - "material_vote_summary": "材料投票", - "material_sample_count": "采样数量", - "material_toward_axis": "轴侧材料", - "material_away_axis": "外侧材料", - "cylinder_end_type": "端部类型", - "start_end_state": "起点端状态", - "end_end_state": "终点端状态", - "start_end_open": "起点端开口", - "end_end_open": "终点端开口", - "open_end_count": "开口端数量", - "closed_end_count": "封闭端数量", - "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 余量", - "cutter_start_margin": "Cutter 起点余量", - "cutter_end_margin": "Cutter 终点余量", - "cutter_radius": "Cutter 半径", - "cutter_start_parameter": "Cutter 起始参数", - "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": "补料高度", - "fill_radius": "补料半径", - "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_TARGET_ID_ROLE = Qt.UserRole -EDITABLE_ACTION_ROLE = Qt.UserRole + 1 -EDITABLE_TARGET_KIND_ROLE = Qt.UserRole + 2 - - -class StepEditorWindow(QMainWindow): - def __init__(self, step_path: str | Path): - super().__init__() - self.setWindowTitle("STEP 零件查看与编辑原型") - self.resize(1280, 820) - - self.model: StepModel | None = None - self.step_path = Path(step_path) - self.selected_kind: str | None = None - self.selected_part_id: int | None = None - self.selected_solid_id: int | None = None - self.selected_face_id: int | None = None - self.selected_edge_id: int | None = None - self.selected_pick_position: tuple[float, float, float] | None = None - - self.model_actor = None - 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] = [] - self.redo_history: list[OperationRecord] = [] - self.current_info_text = "" - self.current_info_values: dict[str, object] = {} - self.cylinder_candidate_cache: list[dict[str, object]] = [] - self.cylinder_candidates_loaded = False - self.operation_in_progress = False - self.edit_thread: QThread | None = None - 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() - self.load_step(self.step_path) - - def _build_ui(self) -> None: - central = QWidget() - root_layout = QHBoxLayout(central) - root_layout.setContentsMargins(10, 10, 10, 10) - root_layout.setSpacing(10) - self.setCentralWidget(central) - - panel_scroll = QScrollArea() - panel_scroll.setWidgetResizable(True) - panel_scroll.setFixedWidth(440) - panel_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - root_layout.addWidget(panel_scroll) - - panel = QWidget() - panel.setFixedWidth(420) - panel_layout = QVBoxLayout(panel) - panel_layout.setContentsMargins(0, 0, 0, 0) - 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; - } - """ - ) - - 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) - file_layout.addWidget(self.path_label) - - file_buttons = QHBoxLayout() - open_button = QPushButton("打开") - open_button.clicked.connect(self.open_step) - reload_button = QPushButton("重新加载") - reload_button.clicked.connect(self.reload_step) - file_buttons.addWidget(open_button) - file_buttons.addWidget(reload_button) - file_layout.addLayout(file_buttons) - panel_layout.addWidget(file_box) - - tree_box = QGroupBox("模型结构树") - tree_box.setObjectName("treeSection") - tree_layout = QVBoxLayout(tree_box) - self.part_tree = QTreeWidget() - self.part_tree.setHeaderLabels(["对象", "内容"]) - self.part_tree.setMinimumHeight(180) - self.part_tree.currentItemChanged.connect(self.on_part_tree_select) - tree_layout.addWidget(self.part_tree) - panel_layout.addWidget(tree_box) - - mode_box = QGroupBox("鼠标选择模式") - mode_box.setObjectName("modeSection") - mode_layout = QVBoxLayout(mode_box) - self.mode_combo = NoWheelComboBox() - self.mode_combo.addItems(["Part", "Solid", "Face", "Edge", "Feature"]) - self.mode_combo.setCurrentText("Face") - 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_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(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(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) - 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) - 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) - self.resize_button = QPushButton("调整圆柱孔径") - self.resize_button.clicked.connect(self.resize_hole) - edit_layout.addWidget(self.resize_button, 3, 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) - - 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( - ["操作", "ID", "对象", "当前值", "状态", "风险", "置信度", "说明"] - ) - self.editable_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.editable_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - self.editable_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) - self.editable_table.setMinimumHeight(130) - self.editable_table.cellClicked.connect(self.on_editable_row_clicked) - editable_layout.addWidget(self.editable_table) - 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 = NoWheelComboBox() - self.candidate_filter_combo.addItems( - [ - "All", - "Hole/Groove", - "Round/Fillet", - "Boss/Outer", - "Unclear", - ] - ) - self.candidate_filter_combo.currentTextChanged.connect( - lambda _text: self._filter_cached_cylinder_candidates(show_info=True) - ) - filter_layout.addWidget(self.candidate_filter_combo) - candidate_layout.addLayout(filter_layout) - self.cylinder_table = QTableWidget(0, 8) - self.cylinder_table.setHorizontalHeaderLabels( - ["face", "guess", "diameter", "span", "height", "confidence", "risk", "part"] - ) - self.cylinder_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.cylinder_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - self.cylinder_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) - self.cylinder_table.setMinimumHeight(140) - self.cylinder_table.cellClicked.connect(self.on_cylinder_row_clicked) - candidate_layout.addWidget(self.cylinder_table) - panel_layout.addWidget(candidate_box) - - history_box = QGroupBox("操作历史") - history_box.setObjectName("historySection") - history_layout = QVBoxLayout(history_box) - self.history_list = QListWidget() - self.history_list.setMinimumHeight(110) - self.history_list.currentRowChanged.connect(self.on_history_row_changed) - history_buttons = QHBoxLayout() - clear_diff_button = QPushButton("清除差异预览") - clear_diff_button.clicked.connect(lambda _checked=False: self.clear_diff_preview()) - export_diff_button = QPushButton("导出差异报告") - export_diff_button.clicked.connect(self.export_diff_report) - history_layout.addWidget(self.history_list) - history_buttons.addWidget(clear_diff_button) - history_buttons.addWidget(export_diff_button) - history_layout.addLayout(history_buttons) - panel_layout.addWidget(history_box) - - 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) - copy_pick_button = QPushButton("复制坐标") - copy_pick_button.clicked.connect(self.copy_pick_position) - copy_info_button = QPushButton("复制信息") - copy_info_button.clicked.connect(self.copy_current_info) - info_buttons.addWidget(copy_id_button) - info_buttons.addWidget(copy_pick_button) - info_buttons.addWidget(copy_info_button) - info_layout.addLayout(info_buttons) - - self.info_tabs = QTabWidget() - self.info_tree = QTreeWidget() - self.info_tree.setHeaderLabels(["属性", "值"]) - self.info_tree.setAlternatingRowColors(True) - self.info_tree.setTextElideMode(Qt.TextElideMode.ElideMiddle) - self.info_tree.setUniformRowHeights(True) - self.info_text = QPlainTextEdit() - self.info_text.setReadOnly(True) - self.info_tabs.addTab(self.info_tree, "属性表") - self.info_tabs.addTab(self.info_text, "原始文本") - 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) - - self.statusBar().showMessage("Ready") - - def _build_vtk(self) -> None: - self.renderer = vtk.vtkRenderer() - self.renderer.SetBackground(0.11, 0.13, 0.15) - self.render_window = self.vtk_widget.GetRenderWindow() - self.render_window.AddRenderer(self.renderer) - - self.interactor = self.render_window.GetInteractor() - self.interactor.SetInteractorStyle(vtk.vtkInteractorStyleTrackballCamera()) - 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() - light.SetPosition(1, 1, 1) - light.SetIntensity(0.9) - self.renderer.AddLight(light) - - self.interactor.Initialize() - - def closeEvent(self, event) -> None: - if self.operation_in_progress: - QMessageBox.information(self, "编辑进行中", "请等待当前编辑计算完成后再关闭窗口。") - event.ignore() - return - 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: - 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: - load_error = exc - finally: - QApplication.restoreOverrideCursor() - if load_error is not None: - QMessageBox.critical(self, "加载失败", str(load_error)) - self.statusBar().showMessage("STEP 加载失败,当前模型保持不变") - return - - 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_from_polydata(model_polydata, edge_polydata, reset_camera=True) - self._clear_editable_candidates() - self._clear_cylinder_candidates() - self.set_info( - { - "file": str(self.step_path), - "parts": stats.parts, - "solids": stats.solids, - "faces": stats.faces, - "edges": stats.edges, - "vertices": stats.vertices, - } - ) - self._update_action_states() - self.statusBar().showMessage(f"已加载 {self.step_path.name}") - - def open_step(self) -> None: - if self._edit_busy("请等待当前编辑完成后再打开文件。"): - return - path, _ = QFileDialog.getOpenFileName( - self, - "打开 STEP 文件", - str(self.step_path.parent if self.step_path else Path.cwd()), - "STEP 文件 (*.step *.stp);;所有文件 (*.*)", - ) - if path: - self.load_step(path) - - def reload_step(self) -> None: - if self._edit_busy("请等待当前编辑完成后再重新加载。"): - return - self.load_step(self.step_path) - - def _clear_history(self) -> None: - self.undo_stack.clear() - self.redo_stack.clear() - self.operation_history.clear() - self.redo_history.clear() - self.clear_diff_preview(render=False) - if hasattr(self, "history_list"): - self.history_list.clear() - - def _refresh_history_list(self) -> None: - was_blocked = self.history_list.blockSignals(True) - try: - self.history_list.clear() - for index, entry in enumerate(self.operation_history, start=1): - self.history_list.addItem(f"{index}. {entry.summary}") - finally: - 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) - diff_message = self._show_operation_diff(record) - detail = record.detail - if locate_message: - detail = f"{detail}\n\n{locate_message}" - if diff_message: - detail = f"{detail}\n\n{diff_message}" - self.set_plain_info(detail) - - 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, "未选择历史记录", "请先选择一条操作历史。") - return - record = self.operation_history[row] - self._ensure_record_diff_stats(record) - target, _ = QFileDialog.getSaveFileName( - self, - "导出差异报告", - str(self.step_path.parent / f"{self.step_path.stem}_diff_{row + 1}.txt"), - "文本文件 (*.txt);;所有文件 (*.*)", - ) - if not target: - return - report = self._diff_report_text(record, row + 1) - try: - Path(target).write_text(report, encoding="utf-8") - except Exception as exc: - QMessageBox.critical(self, "导出失败", str(exc)) - self.statusBar().showMessage("差异报告导出失败") - return - self.statusBar().showMessage(f"已导出差异报告 {Path(target).name}") - self.set_plain_info(f"已导出差异报告:{target}\n\n{report}") - - def _populate_part_tree(self) -> None: - was_blocked = self.part_tree.blockSignals(True) - try: - self.part_tree.clear() - if self.model is None: - return - inserted: dict[int, QTreeWidgetItem] = {} - for part in self.model.parts: - 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) - else: - parent.addChild(item) - inserted[part.id] = item - - for solid_id, (part_id, _solid) in enumerate(self.model.solids): - parent = inserted.get(part_id) - if parent is None: - continue - 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() - self.part_tree.resizeColumnToContents(0) - self.part_tree.resizeColumnToContents(1) - 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 _part_tree_kind_label(kind) - try: - info = self.model.part_info(part_id) - except Exception: - 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 "实体" - try: - info = self.model.solid_info(solid_id) - except Exception: - 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: - return - model_polydata = self.model.build_face_polydata() - 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() - mapper.SetInputData(self.model_polydata) - self.model_actor = vtk.vtkActor() - self.model_actor.SetMapper(mapper) - self.model_actor.GetProperty().SetColor(0.68, 0.72, 0.73) - self.model_actor.GetProperty().SetDiffuse(0.82) - self.model_actor.GetProperty().SetSpecular(0.25) - self.model_actor.GetProperty().SetSpecularPower(18) - self.renderer.AddActor(self.model_actor) - - self.edge_polydata = edge_polydata - edge_mapper = vtk.vtkPolyDataMapper() - edge_mapper.SetInputData(self.edge_polydata) - self.edge_actor = vtk.vtkActor() - self.edge_actor.SetMapper(edge_mapper) - self.edge_actor.GetProperty().SetColor(0.08, 0.09, 0.1) - self.edge_actor.GetProperty().SetLineWidth(1.0) - self.renderer.AddActor(self.edge_actor) - - 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("编辑计算中,请等待当前操作完成") - return - if self.model is None: - return - mode = self.mode_combo.currentText() - x, y = self.interactor.GetEventPosition() - target = self._pick_selection_target(mode, x, y) - if target is None: - self.statusBar().showMessage("未选中对象") - return - - 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": - 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(target_id, pick_position=pick_position) - - def _select_face_cell( - self, - cell_id: int, - mode: str, - pick_position: tuple[float, float, float] | None = None, - ) -> None: - if self.model is None or self.model_polydata is None: - return - 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") - face_id = int(face_arr.GetValue(cell_id)) - part_id = int(part_arr.GetValue(cell_id)) - solid_id = int(solid_arr.GetValue(cell_id)) - - if mode == "Part": - self.select_part(part_id, pick_position=pick_position) - elif mode == "Solid": - self.select_solid(solid_id, part_id, pick_position=pick_position) - elif mode == "Feature": - self.select_feature(face_id, pick_position=pick_position) - else: - self.select_face(face_id, pick_position=pick_position) - - def _select_edge_from_cell( - self, - cell_id: int, - pick_position: tuple[float, float, float] | None = None, - ) -> None: - if self.model is None or self.edge_polydata is None: - return - edge_arr = self.edge_polydata.GetCellData().GetArray("edge_id") - edge_id = int(edge_arr.GetValue(cell_id)) - 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) - target_id = current.data(0, PART_TREE_ID_ROLE) - if node_kind == "solid" and target_id is not None: - part_id = current.data(0, PART_TREE_PART_ID_ROLE) - if part_id is not None: - self.mode_combo.setCurrentText("Solid") - self.select_solid(int(target_id), int(part_id)) - return - if node_kind in {"part", "assembly"} and target_id is not None: - self.mode_combo.setCurrentText("Part") - self.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 - face_id = item.data(Qt.UserRole) - if face_id is None: - return - self.mode_combo.setCurrentText("Feature") - 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 - target_id = item.data(EDITABLE_TARGET_ID_ROLE) - target_kind = item.data(EDITABLE_TARGET_KIND_ROLE) - action = item.data(EDITABLE_ACTION_ROLE) - if target_id is None: - return - if action == "resize_cylinder": - self.mode_combo.setCurrentText("Feature") - 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(target_id)) - self.statusBar().showMessage(f"已选择可推拉平面 face {target_id}") - - def select_by_id(self, kind: str | None = None) -> None: - if self.model is None: - return - if self._edit_busy("编辑计算中,暂时不能切换选择对象。"): - return - try: - target_id = int(self.id_input.text()) - except ValueError: - QMessageBox.information(self, "ID 无效", "请输入整数 ID。Part ID 从 1 开始,Solid/Face/Edge ID 从 0 开始。") - return - - kind = kind or self.mode_combo.currentText() - try: - if kind == "Part": - if self.model.part_by_id(target_id) is None: - raise ValueError(f"不存在 Part ID {target_id}") - self.mode_combo.setCurrentText("Part") - self.select_part(target_id) - elif kind == "Solid": - if target_id < 0 or target_id >= len(self.model.solids): - raise ValueError(f"不存在 Solid ID {target_id}") - part_id = self.model.solids[target_id][0] - self.mode_combo.setCurrentText("Solid") - self.select_solid(target_id, part_id) - elif kind == "Face": - if target_id < 0 or target_id >= len(self.model.faces): - 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" - self.selected_part_id = part_id - self.selected_pick_position = pick_position - self._highlight_faces(part_ids=[part_id]) - self._show_pick_marker(pick_position) - self._sync_id_picker("Part", part_id) - self.set_info(self._with_pick_info(info, 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, - solid_id: int, - part_id: int, - pick_position: tuple[float, float, float] | None = None, - ) -> 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" - self.selected_part_id = part_id - self.selected_solid_id = solid_id - self.selected_pick_position = pick_position - face_ids = [i for i, sid in enumerate(self.model.face_solid_ids) if sid == solid_id] - self._highlight_faces(face_ids=face_ids) - 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( - self, - face_id: int, - feature_mode: bool = False, - pick_position: tuple[float, float, float] | None = None, - ) -> 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 - self.selected_pick_position = pick_position - info = self.model.face_info(face_id) - if feature_mode: - info["feature_mode"] = "当前是几何候选判断,不等同于 CAD 历史特征" - 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("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: - if self.model is None: - return - self._clear_highlight() - 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.82) - actor.GetProperty().SetAmbient(0.45) - actor.GetProperty().SetDiffuse(0.65) - actor.GetProperty().SetSpecular(0.35) - actor.GetProperty().SetLineWidth(2) - self.highlight_actor = actor - self.renderer.AddActor(actor) - self.render_window.Render() - - def _highlight_edge(self, edge_id: int) -> None: - if self.model is None: - return - self._clear_highlight() - 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.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) - self.highlight_actor = None - if self.edge_highlight_actor is not None: - self.renderer.RemoveActor(self.edge_highlight_actor) - self.edge_highlight_actor = None - if self.pick_marker_actor is not None: - self.renderer.RemoveActor(self.pick_marker_actor) - self.pick_marker_actor = None - - def clear_edit_preview(self, render: bool = True) -> None: - if self.edit_preview_timer is not None: - self.edit_preview_timer.stop() - 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 - self.clear_edit_preview(render=False) - try: - polydata = self.model.push_pull_preview_polydata(face_id, distance) - except Exception as exc: - self.statusBar().showMessage(f"推拉预览不可用:{exc}") - return - if distance >= 0: - color = (0.0, 0.86, 0.34) - else: - 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() - - def _start_edit_preview_pulse(self) -> None: - if self.edit_preview_timer is None: - self.edit_preview_timer = QTimer(self) - self.edit_preview_timer.timeout.connect(self._pulse_edit_preview) - self.edit_preview_phase = 0.0 - self.edit_preview_timer.start(120) - - def _pulse_edit_preview(self) -> 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) - for actor in self.edit_preview_actors: - actor.GetProperty().SetOpacity(opacity) - self.render_window.Render() - - def clear_diff_preview(self, render: bool = True) -> None: - if not hasattr(self, "renderer"): - self.diff_actors.clear() - return - for actor in self.diff_actors: - self.renderer.RemoveViewProp(actor) - self.diff_actors.clear() - if render and hasattr(self, "render_window"): - self.render_window.Render() - self.statusBar().showMessage("已清除差异预览") - - def _show_operation_diff(self, record: OperationRecord) -> str: - if self.model is None: - return "" - self.clear_diff_preview(render=False) - if record.before_snapshot is None or record.after_snapshot is None: - return "差异预览: 这条历史记录没有可显示的前后模型快照。" - - before_polydata = self.model.build_snapshot_polydata(record.before_snapshot) - after_polydata = self.model.build_snapshot_polydata(record.after_snapshot) - before_actor = self._make_diff_actor(before_polydata, color=(1.0, 0.16, 0.08), opacity=0.24) - after_actor = self._make_diff_actor(after_polydata, color=(0.0, 0.9, 0.28), opacity=0.16) - heatmap_actor, scalar_bar, heatmap_stats = self._make_distance_heatmap_props(before_polydata, after_polydata) - self.diff_actors = [before_actor, after_actor] - if heatmap_actor is not None: - self.diff_actors.append(heatmap_actor) - if scalar_bar is not None: - self.diff_actors.append(scalar_bar) - for actor in self.diff_actors: - self.renderer.AddViewProp(actor) - self.render_window.Render() - if heatmap_stats: - record.diff_stats = heatmap_stats - return ( - "差异预览: 红色半透明为编辑前,绿色半透明为编辑后;" - "热力图覆盖在编辑后模型上,蓝色接近无变化,黄色/红色表示变化更大。\n" - f"热力图统计: max_distance={_format_value(heatmap_stats['max_distance'])}, " - f"mean_distance={_format_value(heatmap_stats['mean_distance'])}, " - f"changed_points={heatmap_stats['changed_points']}/{heatmap_stats['points']} " - f"({_format_value(heatmap_stats['changed_ratio'] * 100.0)}%)." - ) - return "差异预览: 红色半透明为编辑前,绿色半透明为编辑后;热力图无法生成。" - - def _ensure_record_diff_stats(self, record: OperationRecord) -> dict[str, object]: - if record.diff_stats is not None: - return record.diff_stats - if self.model is None or record.before_snapshot is None or record.after_snapshot is None: - record.diff_stats = {} - return record.diff_stats - before_polydata = self.model.build_snapshot_polydata(record.before_snapshot) - after_polydata = self.model.build_snapshot_polydata(record.after_snapshot) - _heat_polydata, stats = self._build_distance_heatmap_polydata(before_polydata, after_polydata) - record.diff_stats = stats - return stats - - def _diff_report_text(self, record: OperationRecord, history_index: int) -> str: - lines = [ - "STEP 编辑差异报告", - "", - f"generated_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", - f"source_file: {self.step_path}", - f"history_index: {history_index}", - f"summary: {record.summary}", - "", - "操作详情:", - record.detail, - "", - "距离热力图统计:", - ] - stats = record.diff_stats or {} - if stats: - lines.extend( - [ - f" points: {stats.get('points', '')}", - f" max_distance: {_format_value(stats.get('max_distance', ''))}", - f" mean_distance: {_format_value(stats.get('mean_distance', ''))}", - f" changed_points: {stats.get('changed_points', '')}", - f" changed_ratio: {_format_value(float(stats.get('changed_ratio', 0.0)) * 100.0)}%", - f" changed_threshold: {_format_value(stats.get('changed_threshold', ''))}", - ] - ) - else: - lines.append(" unavailable") - lines.extend( - [ - "", - "说明:", - " 热力图使用“编辑后模型顶点到编辑前模型表面”的距离估算。", - " 它适合做可视化诊断和修改留档,不等同于完整 CAD 公差报告。", - ] - ) - return "\n".join(lines) - - def _make_diff_actor(self, polydata, color: tuple[float, float, float], opacity: float): - mapper = vtk.vtkPolyDataMapper() - mapper.SetInputData(polydata) - actor = vtk.vtkActor() - actor.SetMapper(mapper) - actor.GetProperty().SetColor(*color) - actor.GetProperty().SetOpacity(opacity) - actor.GetProperty().SetSpecular(0.15) - actor.GetProperty().SetLineWidth(1) - return actor - - def _make_distance_heatmap_props(self, before_polydata, after_polydata): - heat_polydata, stats = self._build_distance_heatmap_polydata(before_polydata, after_polydata) - if heat_polydata is None: - return None, None, {} - max_distance = float(stats["max_distance"]) - - lut = self._make_heatmap_lookup_table(max_distance) - mapper = vtk.vtkPolyDataMapper() - mapper.SetInputData(heat_polydata) - mapper.SetLookupTable(lut) - mapper.SetScalarRange(0.0, max(max_distance, 1e-9)) - mapper.SetScalarModeToUsePointData() - mapper.ScalarVisibilityOn() - - actor = vtk.vtkActor() - actor.SetMapper(mapper) - actor.GetProperty().SetOpacity(0.82) - actor.GetProperty().SetSpecular(0.22) - actor.GetProperty().SetSpecularPower(16) - - scalar_bar = vtk.vtkScalarBarActor() - scalar_bar.SetLookupTable(lut) - scalar_bar.SetTitle("distance") - scalar_bar.SetNumberOfLabels(4) - scalar_bar.SetWidth(0.08) - scalar_bar.SetHeight(0.32) - scalar_bar.SetPosition(0.89, 0.05) - scalar_bar.GetTitleTextProperty().SetColor(1.0, 1.0, 1.0) - scalar_bar.GetLabelTextProperty().SetColor(1.0, 1.0, 1.0) - - return actor, scalar_bar, stats - - def _build_distance_heatmap_polydata(self, before_polydata, after_polydata): - point_count = after_polydata.GetNumberOfPoints() - if before_polydata.GetNumberOfPoints() == 0 or point_count == 0: - return None, {} - heat_polydata = vtk.vtkPolyData() - heat_polydata.DeepCopy(after_polydata) - distance = vtk.vtkImplicitPolyDataDistance() - distance.SetInput(before_polydata) - - values = vtk.vtkFloatArray() - values.SetName("edit_distance") - values.SetNumberOfValues(point_count) - max_distance = 0.0 - total_distance = 0.0 - raw_values: list[float] = [] - for point_id in range(point_count): - point = heat_polydata.GetPoint(point_id) - value = abs(float(distance.EvaluateFunction(point))) - raw_values.append(value) - values.SetValue(point_id, value) - total_distance += value - max_distance = max(max_distance, value) - - heat_polydata.GetPointData().SetScalars(values) - changed_threshold = max(max_distance * 0.01, 1e-6) - changed_points = sum(1 for value in raw_values if value > changed_threshold) - stats = { - "points": point_count, - "max_distance": max_distance, - "mean_distance": total_distance / point_count, - "changed_points": changed_points, - "changed_ratio": changed_points / point_count, - "changed_threshold": changed_threshold, - } - return heat_polydata, stats - - def _make_heatmap_lookup_table(self, max_distance: float): - lut = vtk.vtkLookupTable() - lut.SetNumberOfTableValues(256) - lut.SetRange(0.0, max(max_distance, 1e-9)) - lut.Build() - stops = [ - (0.0, (0.08, 0.16, 0.85)), - (0.35, (0.0, 0.72, 1.0)), - (0.7, (1.0, 0.9, 0.08)), - (1.0, (1.0, 0.08, 0.02)), - ] - for index in range(256): - t = index / 255.0 - left = stops[0] - right = stops[-1] - for stop_index in range(len(stops) - 1): - if stops[stop_index][0] <= t <= stops[stop_index + 1][0]: - left = stops[stop_index] - right = stops[stop_index + 1] - break - span = max(right[0] - left[0], 1e-9) - local_t = (t - left[0]) / span - color = tuple(left[1][axis] + (right[1][axis] - left[1][axis]) * local_t for axis in range(3)) - lut.SetTableValue(index, color[0], color[1], color[2], 1.0) - return lut - - def _reset_selection(self, clear_highlight: bool = True) -> None: - self.selected_kind = None - self.selected_part_id = None - self.selected_solid_id = None - self.selected_face_id = None - self.selected_edge_id = None - 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: - return - radius = self._pick_marker_radius() - sphere = vtk.vtkSphereSource() - sphere.SetCenter(*pick_position) - sphere.SetRadius(radius) - sphere.SetThetaResolution(20) - sphere.SetPhiResolution(12) - - mapper = vtk.vtkPolyDataMapper() - mapper.SetInputConnection(sphere.GetOutputPort()) - actor = vtk.vtkActor() - actor.SetMapper(mapper) - actor.GetProperty().SetColor(0.1, 0.95, 0.95) - actor.GetProperty().SetSpecular(0.4) - actor.GetProperty().SetSpecularPower(18) - self.pick_marker_actor = actor - self.renderer.AddActor(actor) - self.render_window.Render() - - def _pick_marker_radius(self) -> float: - bounds = self.model_actor.GetBounds() if self.model_actor is not None else None - if bounds is None: - return 1.0 - dx = bounds[1] - bounds[0] - dy = bounds[3] - bounds[2] - dz = bounds[5] - bounds[4] - diagonal = math.sqrt(dx * dx + dy * dy + dz * dz) - return max(diagonal * 0.004, 0.1) - - def _with_pick_info( - self, - info: dict[str, object], - pick_position: tuple[float, float, float] | None, - ) -> dict[str, object]: - if pick_position is None: - return info - enriched = dict(info) - enriched["pick_position"] = pick_position - return enriched - - def _selection_status(self, message: str, pick_position: tuple[float, float, float] | None) -> str: - if pick_position is None: - return message - return f"{message},拾取点 {_format_value(pick_position)}" - - def _edit_busy(self, message: str = "编辑计算中,请等待当前操作完成。") -> bool: - if not self.operation_in_progress: - return False - self.statusBar().showMessage(message) - return True - - def _sync_id_picker(self, kind: str, target_id: int) -> None: - 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 "" - self._reset_selection(clear_highlight=True) - located = False - locator_note = "" - - if record.target_kind == "face" and record.target_id is not None: - if 0 <= record.target_id < len(self.model.faces): - info = self.model.face_info(record.target_id) - self.selected_kind = "face" - self.selected_face_id = record.target_id - self.selected_part_id = int(info["part_id"]) - self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None - self.selected_pick_position = record.pick_position - self.mode_combo.setCurrentText("Face") - self._sync_id_picker("Face", record.target_id) - self._highlight_faces(face_ids=[record.target_id]) - located = True - locator_note = ( - f"定位: 已尝试高亮当前模型中的 face {record.target_id}。" - "布尔编辑后 face ID 可能发生语义变化,请结合拾取点确认。" - ) - 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: - locator_note = "定位: 已显示当时记录的拾取点。" - elif located: - locator_note += f"\n拾取点: {_format_value(record.pick_position)}" - else: - locator_note += f"\n已显示当时记录的拾取点: {_format_value(record.pick_position)}" - - 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[-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._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[-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._refresh_history_list() - self._update_action_states() - self.statusBar().showMessage(f"已重做:{redone.summary}") - - def _restore_snapshot(self, snapshot: dict[int, object]) -> None: - if self.model is None: - return - 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() - stats = self.model.stats() - self.set_info( - { - "parts": stats.parts, - "solids": stats.solids, - "faces": stats.faces, - "edges": stats.edges, - "vertices": stats.vertices, - } - ) - - 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", - str(self.step_path.parent / f"{self.step_path.stem}_edited.step"), - "STEP 文件 (*.step *.stp);;所有文件 (*.*)", - ) - if not target: - return - self._run_action(lambda: self.model.export_all(target), f"已导出 {Path(target).name}") - - def export_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, "未选择零件", "请先选择一个零件。") - 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( - self, - "导出选中零件", - str(self.step_path.parent / default_name), - "STEP 文件 (*.step *.stp);;所有文件 (*.*)", - ) - if not target: - return - self._run_action( - lambda: self.model.export_part(self.selected_part_id, target), - f"已导出选中零件到 {Path(target).name}", - ) - - def export_selected_solid(self) -> None: - if self.model is None: - return - if self._edit_busy("请等待当前编辑完成后再导出。"): - return - 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", - str(self.step_path.parent / f"solid_{self.selected_solid_id}_export.step"), - "STEP 文件 (*.step *.stp);;所有文件 (*.*)", - ) - if not target: - return - self._run_action( - lambda: self.model.export_solid(self.selected_solid_id, target), - f"已导出选中 solid 到 {Path(target).name}", - ) - - def export_selected_face(self) -> None: - if self.model is None: - return - if self._edit_busy("请等待当前编辑完成后再导出。"): - return - 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", - str(self.step_path.parent / f"face_{self.selected_face_id}_export.step"), - "STEP 文件 (*.step *.stp);;所有文件 (*.*)", - ) - if not target: - return - self._run_action( - lambda: self.model.export_face(self.selected_face_id, target), - 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 - if self._edit_busy("请等待当前编辑完成后再执行新的推拉。"): - return - if self.selected_face_id is None: - QMessageBox.information(self, "未选择面", "请先选择一个平面 face。") - return - try: - distance = float(self.offset_input.text()) - except ValueError: - QMessageBox.critical(self, "距离无效", "请输入数字形式的面偏移距离。") - return - face_id = self.selected_face_id - 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(): - return self.model.push_pull_face(face_id, distance) - - 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"), - "semantic_distance": distance, - "distance_rule": "positive=outward fuse, negative=inward cut", - "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, - ) - - def resize_hole(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: - diameter = float(self.hole_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_resize_plan(self.selected_face_id, diameter) - if plan["status"] == "blocked": - QMessageBox.information(self, "不能调整孔径", str(plan["message"])) - self.statusBar().showMessage("圆柱孔径调整已阻止") - return - guess = str(info.get("feature_guess", "cylindrical face")) - if plan["risk"] != "low": - target_to_height_ratio = plan.get("target_to_height_ratio", "") - target_to_height_line = ( - "" - if target_to_height_ratio is None or target_to_height_ratio == "" - else f"目标直径/估算高度: {_format_value(target_to_height_ratio)}\n" - ) - warnings = str(plan.get("warnings", "")) - warnings_line = f"警告: {warnings}\n\n" if warnings else "" - result = QMessageBox.question( - self, - "确认圆柱孔径调整", - ( - f"face: {plan['face_id']}\n" - f"当前直径: {_format_value(plan['current_diameter'])}\n" - f"目标直径: {_format_value(plan['target_diameter'])}\n" - f"直径变化量: {_format_value(plan['delta_diameter'])}\n" - f"直径变化比例: {_format_percent(plan['diameter_delta_ratio'])}\n" - f"{target_to_height_line}" - f"调整模式: {plan['resize_mode']}\n" - f"候选判断: {plan['feature_guess']}\n" - f"置信度: {plan['confidence']}\n" - f"材料投票: {plan['material_vote_summary']}\n" - f"端部类型: {plan['cylinder_end_type']}\n" - f"深度估算: {_format_value(plan['hole_depth_estimate'])}\n" - f"疑似底面 Face: {_format_value(plan.get('feature_bottom_face_ids', ''))}\n" - f"开口端相邻 Face: {_format_value(plan.get('feature_opening_face_ids', ''))}\n" - f"风险: {plan['risk']}\n\n" - f"Cutter: {plan['cutter_strategy']}\n" - f"Cutter 高度: {_format_value(plan['cutter_height'])}\n" - f"Cutter 起点/终点余量: {_format_value(plan['cutter_start_margin'])} / " - f"{_format_value(plan['cutter_end_margin'])}\n\n" - f"底面保护: {_format_value(plan.get('cutter_bottom_protection', ''))}\n" - f"{_format_value(plan.get('cutter_bottom_note', ''))}\n\n" - f"补料策略: {plan.get('fill_strategy', '')}\n\n" - f"{warnings_line}" - f"{plan['message']}\n\n" - "继续操作会对当前零件执行实验性布尔修改。\n\n" - "确定继续吗?" - ), - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if result != QMessageBox.StandardButton.Yes: - self.statusBar().showMessage("已取消圆柱孔径调整") - return - face_id = self.selected_face_id - self._show_cylinder_resize_preview(face_id, diameter) - - def action(): - return self.model.resize_cylindrical_hole(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_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"), - "start_end_state": plan.get("start_end_state"), - "end_end_state": plan.get("end_end_state"), - "cutter_strategy": plan.get("cutter_strategy"), - "cutter_height": plan.get("cutter_height"), - "cutter_margin": plan.get("cutter_margin"), - "cutter_start_margin": plan.get("cutter_start_margin"), - "cutter_end_margin": plan.get("cutter_end_margin"), - "cutter_bottom_protection": plan.get("cutter_bottom_protection"), - "cutter_protected_bottom_face_ids": plan.get("cutter_protected_bottom_face_ids"), - "cutter_opening_face_ids": plan.get("cutter_opening_face_ids"), - "cutter_bottom_note": plan.get("cutter_bottom_note"), - "cutter_note": plan.get("cutter_note"), - "fill_strategy": plan.get("fill_strategy"), - "fill_height": plan.get("fill_height"), - "fill_radius": plan.get("fill_radius"), - "fill_radius_overlap": plan.get("fill_radius_overlap"), - "fill_note": plan.get("fill_note"), - }, - target_kind="face", - 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) - - def _clear_editable_candidates(self) -> None: - if hasattr(self, "editable_table"): - self.editable_table.setRowCount(0) - - 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 - 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=limit, - progress_callback=QApplication.processEvents, - ) - except Exception as exc: - QMessageBox.critical(self, "扫描失败", str(exc)) - self.statusBar().showMessage("第一版可编辑对象扫描失败") - return - finally: - QApplication.restoreOverrideCursor() - self.editable_table.setRowCount(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_TARGET_ID_ROLE, target_id) - operation_item.setData(EDITABLE_TARGET_KIND_ROLE, target_kind) - operation_item.setData(EDITABLE_ACTION_ROLE, str(item["operation_key"])) - 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'])}" - ) - status_item = QTableWidgetItem(str(item["status"])) - risk_item = QTableWidgetItem(str(item["risk"])) - confidence_item = QTableWidgetItem(str(item["confidence"])) - note_item = QTableWidgetItem(str(item["note"])) - row_items = [ - operation_item, - id_item, - object_item, - current_item, - status_item, - risk_item, - confidence_item, - note_item, - ] - for column, table_item in enumerate(row_items): - table_item.setToolTip(table_item.text()) - self.editable_table.setItem(row, column, table_item) - lines.append( - f"{target_kind} {target_id}: {item['operation']}, " - f"part={item['part_id']}, solid={item['solid_id']}, " - f"object={item['feature_guess']}, " - f"{item['current_value_label']}={_format_value(item['current_value'])}, " - f"status={item['status']}, risk={item['risk']}, " - f"confidence={item['confidence']}, note={item['note']}" - ) - self.editable_table.resizeColumnsToContents() - self.statusBar().showMessage(f"已{scan_label}第一版可编辑对象:{len(candidates)} 个") - if show_info: - self.set_plain_info("\n".join(lines)) - - def _clear_cylinder_candidates(self) -> None: - self.cylinder_candidate_cache = [] - self.cylinder_candidates_loaded = False - if hasattr(self, "cylinder_table"): - self.cylinder_table.setRowCount(0) - - def refresh_cylinder_candidates(self, show_info: bool = True) -> None: - if self.model is None: - return - if self._edit_busy("编辑计算中,暂时不能扫描圆柱候选。"): - return - self.statusBar().showMessage("正在扫描圆柱候选...") - QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) - QApplication.processEvents() - try: - self.cylinder_candidate_cache = self.model.cylindrical_feature_candidates( - limit=60, - progress_callback=QApplication.processEvents, - ) - self.cylinder_candidates_loaded = True - except Exception as exc: - self._clear_cylinder_candidates() - QMessageBox.critical(self, "扫描失败", str(exc)) - self.statusBar().showMessage("圆柱候选扫描失败") - return - finally: - QApplication.restoreOverrideCursor() - self._filter_cached_cylinder_candidates(show_info=show_info) - - def _filter_cached_cylinder_candidates(self, show_info: bool = False) -> None: - if not self.cylinder_candidates_loaded: - self.cylinder_table.setRowCount(0) - if show_info: - self.set_plain_info("圆柱候选尚未扫描。点击 `列出圆柱候选` 后再切换筛选类型。") - return - candidates = [ - item - for item in self.cylinder_candidate_cache - if self._candidate_matches_filter(str(item["feature_guess"])) - ] - self._populate_cylinder_table(candidates, show_info=show_info) - - def _populate_cylinder_table(self, candidates: list[dict[str, object]], show_info: bool = True) -> None: - self.cylinder_table.setRowCount(len(candidates)) - lines = [f"圆柱候选:显示 {len(candidates)} 个"] - for row, item in enumerate(candidates): - face_item = QTableWidgetItem(str(item["face_id"])) - face_item.setData(Qt.UserRole, int(item["face_id"])) - guess_item = QTableWidgetItem(str(item["feature_guess"])) - diameter_item = QTableWidgetItem(_format_float(float(item["diameter"]))) - span_item = QTableWidgetItem(_format_float(float(item["angular_span"]))) - height_item = QTableWidgetItem(_format_float(float(item["height_estimate"]))) - confidence_item = QTableWidgetItem(str(item["confidence"])) - risk_item = QTableWidgetItem(str(item["resize_risk"])) - part_item = QTableWidgetItem(str(item["part_id"])) - self.cylinder_table.setItem(row, 0, face_item) - self.cylinder_table.setItem(row, 1, guess_item) - self.cylinder_table.setItem(row, 2, diameter_item) - self.cylinder_table.setItem(row, 3, span_item) - self.cylinder_table.setItem(row, 4, height_item) - self.cylinder_table.setItem(row, 5, confidence_item) - self.cylinder_table.setItem(row, 6, risk_item) - self.cylinder_table.setItem(row, 7, part_item) - lines.append( - f"face {item['face_id']}: part {item['part_id']}, " - f"guess={item['feature_guess']}, " - f"diameter={_format_float(float(item['diameter']))}, " - f"height={_format_float(float(item['height_estimate']))}, " - f"confidence={item['confidence']}, " - f"risk={item['resize_risk']}" - ) - self.cylinder_table.resizeColumnsToContents() - self.statusBar().showMessage(f"已显示圆柱候选:{len(candidates)} 个") - if show_info: - self.set_plain_info("\n".join(lines)) - - def _candidate_matches_filter(self, feature_guess: str) -> bool: - current = self.candidate_filter_combo.currentText() - if current == "All": - return True - if current == "Hole/Groove": - return feature_guess == "hole/groove candidate" - if current == "Round/Fillet": - return feature_guess == "round/fillet candidate" - if current == "Boss/Outer": - return feature_guess == "boss/outer-round candidate" - if current == "Unclear": - return feature_guess == "cylindrical face" - return True - - def _run_action(self, action, success_message: str | None) -> None: - try: - result = action() - except Exception as exc: - QMessageBox.critical(self, "操作失败", str(exc)) - self.statusBar().showMessage("操作失败") - return - message = success_message or str(result) - self.statusBar().showMessage(message) - self.set_plain_info(message) - - def _run_edit_action( - self, - action, - operation_name: str, - target: str, - parameters: dict[str, object], - target_kind: str | None = None, - target_id: int | None = None, - ) -> None: - if self.model is None: - return - if self.operation_in_progress: - QMessageBox.information(self, "编辑进行中", "请等待当前编辑计算完成。") - return - context = { - "operation_name": operation_name, - "target": target, - "parameters": parameters, - "target_kind": target_kind, - "target_id": target_id, - "pick_position": self.selected_pick_position, - } - - self._begin_edit_task(operation_name) - self.pending_edit_context = context - thread = QThread(self) - worker = EditWorker(self._make_edit_job(action, context)) - worker.moveToThread(thread) - thread.started.connect(worker.run) - worker.finished.connect(self._finish_edit_action) - worker.failed.connect(self._fail_edit_action) - worker.finished.connect(thread.quit) - worker.failed.connect(thread.quit) - thread.finished.connect(worker.deleteLater) - thread.finished.connect(thread.deleteLater) - thread.finished.connect(self._forget_edit_thread) - self.edit_thread = thread - self.edit_worker = worker - thread.start() - - 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() - 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, - } - - 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) - self.edit_progress.setWindowTitle("编辑计算中") - self.edit_progress.setCancelButton(None) - self.edit_progress.setAutoClose(False) - self.edit_progress.setAutoReset(False) - self.edit_progress.setMinimumDuration(0) - self.edit_progress.setWindowModality(Qt.WindowModality.WindowModal) - self.edit_progress.show() - QApplication.processEvents() - - def _set_edit_progress_text(self, text: str) -> None: - if self.edit_progress is not None: - self.edit_progress.setLabelText(text) - QApplication.processEvents() - - @Slot(object) - def _finish_edit_action(self, result: object) -> None: - context = self.pending_edit_context - if context is None: - self._end_edit_task(clear_preview=True) - self.statusBar().showMessage("编辑已完成,但上下文丢失") - return - if self.model is None: - self._end_edit_task(clear_preview=True) - return - self._set_edit_progress_text("布尔计算已完成,正在刷新模型显示和历史记录...") - if not isinstance(result, dict): - self._end_edit_task(clear_preview=True) - QMessageBox.critical(self, "操作失败", "后台编辑返回了无法识别的结果。") - self.statusBar().showMessage("编辑结果无法识别") - return - message = str(result["message"]) - try: - record = self._make_operation_record( - operation_name=str(context["operation_name"]), - target=str(context["target"]), - parameters=dict(context["parameters"]), - 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"], - target_id=context["target_id"], - pick_position=context["pick_position"], - 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, "操作失败", f"编辑结果刷新失败:{exc}\n\n{rollback_message}") - self.statusBar().showMessage("编辑已完成,但刷新结果时失败") - return - - self.undo_stack.append(result["snapshot"]) - self.redo_stack.clear() - self.operation_history.append(record) - self.redo_history.clear() - self._clear_editable_candidates() - self._clear_cylinder_candidates() - self._refresh_history_list() - self._end_edit_task(clear_preview=False) - 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._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 - - def _make_operation_record( - self, - operation_name: str, - target: str, - parameters: dict[str, object], - result_message: str, - before_stats, - 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, - before_snapshot: dict[int, object] | None = None, - after_snapshot: dict[int, object] | None = None, - ) -> OperationRecord: - summary_parts = [operation_name, target] - if "distance" in parameters: - summary_parts.append(f"distance={_format_value(parameters['distance'])}") - if "semantic_distance" in parameters: - summary_parts.append(f"distance={_format_value(parameters['semantic_distance'])}") - if "new_diameter" in parameters: - summary_parts.append(f"diameter={_format_value(parameters['new_diameter'])}") - summary = " | ".join(summary_parts) - - lines = [ - f"operation: {operation_name}", - f"target: {target}", - f"target_kind: {target_kind or ''}", - f"target_id: {target_id if target_id is not None else ''}", - "parameters:", - ] - if pick_position is not None: - 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:", - f" solids: {before_stats.solids}", - f" faces: {before_stats.faces}", - f" edges: {before_stats.edges}", - "topology after:", - f" solids: {after_stats.solids} ({_signed_delta(after_stats.solids - before_stats.solids)})", - f" faces: {after_stats.faces} ({_signed_delta(after_stats.faces - before_stats.faces)})", - f" edges: {after_stats.edges} ({_signed_delta(after_stats.edges - before_stats.edges)})", - "geometry before:", - f" volume: {_format_value(before_geometry.get('volume', ''))}", - f" surface_area: {_format_value(before_geometry.get('surface_area', ''))}", - f" bbox_size: {_format_value(before_geometry.get('bbox_size', ''))}", - f" bbox_diagonal: {_format_value(before_geometry.get('bbox_diagonal', ''))}", - "geometry after:", - f" volume: {_format_after_delta(before_geometry, after_geometry, 'volume')}", - f" surface_area: {_format_after_delta(before_geometry, after_geometry, 'surface_area')}", - f" bbox_size: {_format_after_delta(before_geometry, after_geometry, 'bbox_size')}", - f" bbox_diagonal: {_format_after_delta(before_geometry, after_geometry, 'bbox_diagonal')}", - "result:", - f" {result_message}", - ] - ) - return OperationRecord( - summary=summary, - detail="\n".join(lines), - target_kind=target_kind, - target_id=target_id, - pick_position=pick_position, - before_snapshot=before_snapshot, - after_snapshot=after_snapshot, - ) - - def set_info(self, info: dict[str, object]) -> None: - self.current_info_values = dict(info) - self.current_info_text = _info_to_text(info) - self.info_text.setPlainText(self.current_info_text) - self._populate_info_tree(info) - self.info_tabs.setCurrentWidget(self.info_tree) - - def set_plain_info(self, text: str) -> None: - self.current_info_values = {} - self.current_info_text = text - self.info_tree.clear() - self.info_text.setPlainText(text) - self.info_tabs.setCurrentWidget(self.info_text) - - def _populate_info_tree(self, info: dict[str, object]) -> None: - self.info_tree.clear() - emitted: set[str] = set() - for group_name, keys in INFO_GROUPS: - items = [(key, info[key]) for key in keys if key in info] - if not items: - continue - self._add_info_group(group_name, items) - emitted.update(key for key, _value in items) - - remaining = [(key, value) for key, value in info.items() if key not in emitted] - if remaining: - self._add_info_group("其他", remaining) - - self.info_tree.expandAll() - self.info_tree.resizeColumnToContents(0) - - def _add_info_group(self, group_name: str, items: list[tuple[str, object]]) -> None: - group = QTreeWidgetItem([group_name, ""]) - group.setFirstColumnSpanned(True) - self.info_tree.addTopLevelItem(group) - for key, value in items: - child = QTreeWidgetItem([INFO_LABELS.get(key, key), _format_value(value)]) - child.setData(0, Qt.UserRole, key) - child.setToolTip(0, key) - child.setToolTip(1, _format_value(value)) - group.addChild(child) - - def copy_selected_id(self) -> None: - text = self._selected_id_text() - if not text: - self.statusBar().showMessage("没有可复制的对象 ID") - return - QApplication.clipboard().setText(text) - self.statusBar().showMessage(f"已复制 {text}") - - def copy_pick_position(self) -> None: - pick_position = self.selected_pick_position - if pick_position is None and "pick_position" in self.current_info_values: - value = self.current_info_values["pick_position"] - if isinstance(value, tuple) and len(value) == 3: - pick_position = (float(value[0]), float(value[1]), float(value[2])) - if pick_position is None: - self.statusBar().showMessage("没有可复制的拾取坐标") - return - text = _format_value(pick_position) - QApplication.clipboard().setText(text) - self.statusBar().showMessage(f"已复制拾取坐标 {text}") - - def copy_current_info(self) -> None: - if not self.current_info_text: - self.statusBar().showMessage("没有可复制的信息") - return - QApplication.clipboard().setText(self.current_info_text) - self.statusBar().showMessage("已复制当前信息") - - def _selected_id_text(self) -> str: - if self.selected_kind == "part" and self.selected_part_id is not None: - return f"part {self.selected_part_id}" - if self.selected_kind == "solid" and self.selected_solid_id is not None: - return f"solid {self.selected_solid_id}" - if self.selected_kind in {"face", "feature"} and self.selected_face_id is not None: - return f"face {self.selected_face_id}" - if self.selected_kind == "edge" and self.selected_edge_id is not None: - return f"edge {self.selected_edge_id}" - for kind, key in (("face", "face_id"), ("edge", "edge_id"), ("solid", "solid_id"), ("part", "part_id")): - if key in self.current_info_values: - return f"{kind} {self.current_info_values[key]}" - return "" - - -def _format_float(value: float) -> str: - return f"{value:.6g}" - - -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) - if isinstance(value, tuple): - return "(" + ", ".join(_format_float(float(v)) for v in value) + ")" - 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) - if isinstance(before_value, (int, float)) and isinstance(after_value, (int, float)): - return f"{_format_value(float(after_value))} ({_signed_float_delta(float(after_value) - float(before_value))})" - if isinstance(before_value, tuple) and isinstance(after_value, tuple) and len(before_value) == len(after_value): - try: - deltas = tuple(float(after_item) - float(before_item) for before_item, after_item in zip(before_value, after_value)) - except (TypeError, ValueError): - return _format_value(after_value) - return f"{_format_value(after_value)} (delta={_format_value(deltas)})" - return _format_value(after_value if after_value is not None else "") - - -def _vector_tuple(values) -> tuple[float, float, float]: - return (float(values[0]), float(values[1]), float(values[2])) - - -def _signed_delta(value: int) -> str: - if value > 0: - return f"+{value}" - return str(value) - - -def _signed_float_delta(value: float) -> str: - if value > 0: - return f"+{_format_float(value)}" - return _format_float(value) - - -def _parse_args(argv: list[str]) -> tuple[Path, bool]: - smoke_test = "--smoke-test" in argv - paths = [arg for arg in argv[1:] if not arg.startswith("--")] - path = Path(paths[0]) if paths else Path("geom_extract.step") - return path, smoke_test - - -def main() -> int: - path, smoke_test = _parse_args(sys.argv) - app = QApplication(sys.argv) - window = StepEditorWindow(path) - if smoke_test: - print("smoke test ok") - window.close() - app.quit() - return 0 - window.show() - window.vtk_widget.Start() - return app.exec() +from step_editor.app import main if __name__ == "__main__": diff --git a/step_editor/__init__.py b/step_editor/__init__.py new file mode 100644 index 0000000..351d2bc --- /dev/null +++ b/step_editor/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from .app import StepEditorWindow, main +from .model import StepModel + +__all__ = ["StepEditorWindow", "StepModel", "main"] diff --git a/step_editor/app.py b/step_editor/app.py new file mode 100644 index 0000000..cb59971 --- /dev/null +++ b/step_editor/app.py @@ -0,0 +1,826 @@ +from __future__ import annotations + +from datetime import datetime +import faulthandler +import math +import sys +from pathlib import Path + +import vtk +from PySide6.QtCore import Qt, QThread, QTimer, Slot +from PySide6.QtWidgets import ( + QAbstractItemView, + QApplication, + QCheckBox, + QComboBox, + QFileDialog, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QMainWindow, + QMessageBox, + QPushButton, + QPlainTextEdit, + QScrollArea, + QTableWidget, + QTableWidgetItem, + QTabWidget, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) +from vtkmodules.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor + +from .records import OperationRecord +from .model import StepModel +from .widgets import NoWheelComboBox +from .workers import EditWorker, LoadWorker, ScanWorker + + +from .info_panel import InfoPanelMixin +from .ui_helpers import * # noqa: F403 +from .window_actions import WindowActionMixin +from .window_core import WindowCoreMixin +from .window_state import WindowStateMixin + + +_CRASH_LOG_HANDLE = None + + +def _enable_crash_log() -> None: + global _CRASH_LOG_HANDLE + if _CRASH_LOG_HANDLE is not None: + return + try: + log_path = Path("step_editor_crash.log") + _CRASH_LOG_HANDLE = log_path.open("a", encoding="utf-8") + faulthandler.enable(file=_CRASH_LOG_HANDLE, all_threads=True) + except Exception: + faulthandler.enable(all_threads=True) + + +class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, InfoPanelMixin, QMainWindow): + def __init__(self, step_path: str | Path, *, background_load: bool = True): + super().__init__() + self.setWindowTitle("STEP 零件查看与编辑原型") + self.resize(1280, 820) + + self.model: StepModel | None = None + self.step_path = Path(step_path) + self.selected_kind: str | None = None + self.selected_part_id: int | None = None + self.selected_solid_id: int | None = None + self.selected_face_id: int | None = None + self.selected_edge_id: int | None = None + self.selected_pick_position: tuple[float, float, float] | None = None + + self.model_actor = None + 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.hover_interval_ms = 45 + self.hover_move_threshold_px = 0 + self.pending_hover_position: tuple[int, int] | None = None + self.last_hover_pick_position: tuple[int, int] | None = None + self.pointer_button_down = False + 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.model_face_id_array = None + self.model_part_id_array = None + self.model_solid_id_array = None + self.edge_id_array = None + self.face_overlay_polydata_cache: dict[tuple[tuple[int, ...] | None, tuple[int, ...] | None, bool], object] = {} + self.edge_overlay_polydata_cache: dict[int, object] = {} + self.overlay_cache_limit = 160 + self.show_internal_edges_checkbox: QCheckBox | None = None + self.scene_isolated = False + self.undo_stack: list[dict[int, object]] = [] + self.redo_stack: list[dict[int, object]] = [] + self.operation_history: list[OperationRecord] = [] + self.redo_history: list[OperationRecord] = [] + self.measure_point_a: tuple[float, float, float] | None = None + self.measure_point_b: tuple[float, float, float] | None = None + self.measure_label_a = "" + self.measure_label_b = "" + self.measure_actor = None + self.current_info_text = "" + self.current_info_values: dict[str, object] = {} + self.cylinder_candidate_cache: list[dict[str, object]] = [] + self.cylinder_candidates_loaded = False + self.operation_in_progress = False + self.edit_thread: QThread | None = None + self.edit_worker: EditWorker | None = None + self.pending_edit_context: dict[str, object] | None = None + self.scan_in_progress = False + self.scan_thread: QThread | None = None + self.scan_worker: ScanWorker | None = None + self.pending_scan_kind: str | None = None + self.load_in_progress = False + self.load_thread: QThread | None = None + self.load_worker: LoadWorker | None = None + self.load_refine_thread: QThread | None = None + self.load_refine_worker: LoadWorker | None = None + self.pending_load_path: Path | None = None + self.initial_load_deflection = 0.8 + self.last_id_kind = "Face" + + self._build_ui() + self._build_vtk() + self.load_step(self.step_path, background=background_load) + + def _build_ui(self) -> None: + help_tip = self._set_help_tip + + central = QWidget() + root_layout = QHBoxLayout(central) + root_layout.setContentsMargins(10, 10, 10, 10) + root_layout.setSpacing(10) + self.setCentralWidget(central) + + panel_scroll = QScrollArea() + panel_scroll.setWidgetResizable(True) + panel_scroll.setFixedWidth(440) + panel_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + root_layout.addWidget(panel_scroll) + + panel = QWidget() + panel.setFixedWidth(420) + panel_layout = QVBoxLayout(panel) + panel_layout.setContentsMargins(0, 0, 0, 0) + 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; + } + """ + ) + + file_box = QGroupBox("STEP 文件") + file_box.setObjectName("fileSection") + help_tip(file_box, "打开、重新加载和查看当前 STEP 文件路径。") + file_layout = QVBoxLayout(file_box) + self.path_label = QLabel("") + self.path_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + self.path_label.setWordWrap(True) + help_tip(self.path_label, "当前打开的 STEP 文件路径。可以选中文字复制路径。") + file_layout.addWidget(self.path_label) + + file_buttons = QHBoxLayout() + open_button = QPushButton("打开") + help_tip(open_button, "选择并打开一个 .step 或 .stp 文件。打开失败时会保留当前模型。") + open_button.clicked.connect(self.open_step) + reload_button = QPushButton("重新加载") + help_tip(reload_button, "从磁盘重新读取当前 STEP 文件,用于放弃本次会话里的临时查看状态。") + reload_button.clicked.connect(self.reload_step) + file_buttons.addWidget(open_button) + file_buttons.addWidget(reload_button) + file_layout.addLayout(file_buttons) + panel_layout.addWidget(file_box) + + tree_box = QGroupBox("模型结构树") + tree_box.setObjectName("treeSection") + help_tip(tree_box, "查看 STEP 里的装配、零件和实体层级,并从树上直接选中对象。") + tree_layout = QVBoxLayout(tree_box) + self.part_tree = QTreeWidget() + self.part_tree.setHeaderLabels(["对象", "内容"]) + self.part_tree.setMinimumHeight(180) + help_tip(self.part_tree, "模型里的装配、零件和 solid 列表。点击一行可以选中并高亮对应对象。") + self.part_tree.currentItemChanged.connect(self.on_part_tree_select) + tree_layout.addWidget(self.part_tree) + panel_layout.addWidget(tree_box) + + mode_box = QGroupBox("鼠标选择模式") + mode_box.setObjectName("modeSection") + help_tip(mode_box, "决定鼠标点模型时选中零件、solid、面、边,还是识别局部特征。") + mode_layout = QVBoxLayout(mode_box) + self.mode_combo = NoWheelComboBox() + self.mode_combo.addItems(["Part", "Solid", "Face", "Edge", "Feature"]) + self.mode_combo.setCurrentText("Face") + help_tip( + self.mode_combo, + "选择鼠标点击模型时要选什么:零件、solid、面、边,或把面解释成孔/槽/圆角等特征候选。", + ) + 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") + help_tip(select_box, "知道对象 ID 时,可以直接输入 ID 跳转选择。") + select_layout = QGridLayout(select_box) + self.id_input = QLineEdit("") + self.id_input.setPlaceholderText("输入 ID") + help_tip(self.id_input, "输入要选中的对象 ID。ID 的类型由右侧显示的选择模式决定。") + self.id_mode_display = QLineEdit(self.mode_combo.currentText()) + self.id_mode_display.setObjectName("idModeDisplay") + self.id_mode_display.setReadOnly(True) + self.id_mode_display.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.id_mode_display.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.id_mode_display.setMinimumWidth(70) + help_tip(self.id_mode_display, "当前按这个鼠标选择模式解释输入的 ID。它会跟随鼠标选择模式自动变化。") + select_button = QPushButton("选择") + help_tip(select_button, "按当前选择模式跳转到输入的 ID,并在 3D 视图中高亮它。") + 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(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") + help_tip(view_box, "只改变视图显示方式,不会修改 STEP 几何。") + view_layout = QVBoxLayout(view_box) + view_buttons = QHBoxLayout() + isolate_button = QPushButton("只显示选中") + help_tip(isolate_button, "把视图临时隔离到当前选中的零件、solid、面、边或特征区域。不会修改模型。") + isolate_button.clicked.connect(self.isolate_selected) + fit_button = QPushButton("对准选中") + help_tip(fit_button, "把相机移动到当前选中对象附近,方便看清细节。不会修改模型。") + fit_button.clicked.connect(self.fit_selected) + show_all_button = QPushButton("显示全部") + help_tip(show_all_button, "取消隔离显示,恢复查看完整模型。不会修改模型。") + show_all_button.clicked.connect(self.show_all_geometry) + view_buttons.addWidget(isolate_button) + view_buttons.addWidget(fit_button) + view_buttons.addWidget(show_all_button) + view_layout.addLayout(view_buttons) + self.show_internal_edges_checkbox = QCheckBox("显示同域内部边") + help_tip( + self.show_internal_edges_checkbox, + "显示同一平面或同一圆柱面内部的拓扑分割边。关闭时会隐藏布尔推拉后常见的视觉接缝线。", + ) + self.show_internal_edges_checkbox.toggled.connect(self._on_internal_edges_toggled) + view_layout.addWidget(self.show_internal_edges_checkbox) + panel_layout.addWidget(view_box) + + measure_box = QGroupBox("测量") + measure_box.setObjectName("viewSection") + help_tip(measure_box, "把当前选中对象的拾取点或中心设为 A/B,计算两点距离和 X/Y/Z 差值。不会修改模型。") + measure_layout = QVBoxLayout(measure_box) + self.measure_text = QPlainTextEdit() + self.measure_text.setReadOnly(True) + self.measure_text.setMaximumHeight(90) + help_tip(self.measure_text, "显示 A 点、B 点、两点距离和各方向差值。") + measure_buttons = QGridLayout() + set_measure_a_button = QPushButton("设为 A") + help_tip(set_measure_a_button, "把当前选中对象的拾取点设为测量点 A;没有拾取点时使用对象中心。") + set_measure_a_button.clicked.connect(lambda _checked=False: self.set_measure_point("A")) + set_measure_b_button = QPushButton("设为 B") + help_tip(set_measure_b_button, "把当前选中对象的拾取点设为测量点 B;没有拾取点时使用对象中心。") + set_measure_b_button.clicked.connect(lambda _checked=False: self.set_measure_point("B")) + copy_measure_button = QPushButton("复制测量") + help_tip(copy_measure_button, "复制当前测量结果文本,方便记录尺寸。") + copy_measure_button.clicked.connect(self.copy_measurement) + clear_measure_button = QPushButton("清除测量") + help_tip(clear_measure_button, "清除 A/B 测量点和 3D 测量线。不会影响模型。") + clear_measure_button.clicked.connect(self.clear_measurement) + measure_buttons.addWidget(set_measure_a_button, 0, 0) + measure_buttons.addWidget(set_measure_b_button, 0, 1) + measure_buttons.addWidget(copy_measure_button, 1, 0) + measure_buttons.addWidget(clear_measure_button, 1, 1) + measure_layout.addWidget(self.measure_text) + measure_layout.addLayout(measure_buttons) + panel_layout.addWidget(measure_box) + self._refresh_measurement_panel() + + export_box = QGroupBox("导出") + export_box.setObjectName("exportSection") + help_tip(export_box, "把当前模型或选中对象导出为 STEP,也可以做基础质量检查和修复。") + export_layout = QVBoxLayout(export_box) + self.export_all_button = QPushButton("导出当前完整 STEP") + help_tip(self.export_all_button, "把当前编辑后的整个模型导出为 STEP 文件。导出前会做基础质量检查。") + self.export_all_button.clicked.connect(self.export_all) + self.export_part_button = QPushButton("导出选中零件") + help_tip(self.export_part_button, "只导出当前选中的零件。适合从装配里拆出一个 part。") + self.export_part_button.clicked.connect(self.export_selected_part) + self.export_solid_button = QPushButton("导出选中 solid") + help_tip(self.export_solid_button, "只导出当前选中的实体 solid。适合检查或单独保存某个实体。") + self.export_solid_button.clicked.connect(self.export_selected_solid) + self.export_face_button = QPushButton("导出选中面区域") + help_tip(self.export_face_button, "导出当前选中的面区域;同域高亮的共面/同圆柱区域也会一起导出。") + self.export_face_button.clicked.connect(self.export_selected_face) + self.export_feature_button = QPushButton("导出选中特征区域") + help_tip(self.export_feature_button, "导出 Feature 模式识别到的局部特征区域,例如孔、槽、圆角或凸台候选。") + self.export_feature_button.clicked.connect(self.export_selected_feature) + self.export_edge_button = QPushButton("导出选中 edge") + help_tip(self.export_edge_button, "导出当前选中的边。主要用于调试、定位或把边界单独拿出去检查。") + self.export_edge_button.clicked.connect(self.export_selected_edge) + self.export_check_button = QPushButton("检查导出质量") + help_tip(self.export_check_button, "检查当前导出对象是否像有效实体:B-Rep、face/edge 数量、体积和包围盒等。") + self.export_check_button.clicked.connect(self.check_export_quality) + self.repair_model_button = QPushButton("修复当前模型") + help_tip(self.repair_model_button, "对完整模型尝试 ShapeFix 和同域面/边合并。会写入历史,可撤销。") + self.repair_model_button.clicked.connect(self.repair_model) + self.repair_selected_button = QPushButton("修复选中零件/solid") + help_tip(self.repair_selected_button, "只修复当前选中的零件或 solid,范围比修复完整模型更小。会写入历史,可撤销。") + self.repair_selected_button.clicked.connect(self.repair_selected_shape) + export_layout.addWidget(self.export_all_button) + export_layout.addWidget(self.export_part_button) + export_layout.addWidget(self.export_solid_button) + export_layout.addWidget(self.export_face_button) + export_layout.addWidget(self.export_feature_button) + export_layout.addWidget(self.export_edge_button) + export_layout.addWidget(self.export_check_button) + export_layout.addWidget(self.repair_model_button) + export_layout.addWidget(self.repair_selected_button) + panel_layout.addWidget(export_box) + + edit_box = QGroupBox("实验性编辑") + edit_box.setObjectName("editSection") + help_tip(edit_box, "这里的按钮会真实修改当前 B-Rep 模型;执行前通常会预览,成功后可撤销。") + edit_layout = QGridLayout(edit_box) + edit_layout.addWidget(QLabel("面偏移"), 0, 0) + self.offset_input = QLineEdit("5.0") + help_tip(self.offset_input, "平面推拉距离。正数通常向外加料,负数通常向内切削;单位沿用 STEP 模型单位。") + edit_layout.addWidget(self.offset_input, 0, 1) + self.push_button = QPushButton("推拉平面") + help_tip(self.push_button, "移动当前选中的平面区域:正数加料,负数切削。会先显示半透明预览,再后台执行。") + 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("") + help_tip(self.hole_diameter_input, "圆柱孔/槽的目标直径。选中候选后会自动填一个参考值,可以手动改。") + edit_layout.addWidget(self.hole_diameter_input, 2, 1) + self.resize_button = QPushButton("调整圆柱孔径") + help_tip(self.resize_button, "修改孔或圆柱槽的直径。扩大时切削,缩小时会先补料再重切。") + self.resize_button.clicked.connect(self.resize_hole) + edit_layout.addWidget(self.resize_button, 3, 0, 1, 2) + + edit_layout.addWidget(QLabel("槽/半孔宽度"), 4, 0) + self.slot_width_input = QLineEdit("") + help_tip(self.slot_width_input, "槽或半孔的目标开口宽度。程序会把它换算成对应圆柱直径来执行。") + edit_layout.addWidget(self.slot_width_input, 4, 1) + self.resize_slot_button = QPushButton("调整槽/半孔宽度") + help_tip(self.resize_slot_button, "修改已识别槽/半孔候选的宽度。第一版是几何近似,失败会回滚。") + self.resize_slot_button.clicked.connect(self.resize_slot_width) + edit_layout.addWidget(self.resize_slot_button, 5, 0, 1, 2) + + edit_layout.addWidget(QLabel("凸台直径"), 6, 0) + self.boss_diameter_input = QLineEdit("") + help_tip(self.boss_diameter_input, "圆柱凸台的目标直径。选中凸台候选后会自动填一个参考值。") + edit_layout.addWidget(self.boss_diameter_input, 6, 1) + self.resize_boss_button = QPushButton("调整圆柱凸台直径") + help_tip(self.resize_boss_button, "修改完整圆柱凸台直径。变大会加料,变小会重建凸台区域。") + self.resize_boss_button.clicked.connect(self.resize_boss) + edit_layout.addWidget(self.resize_boss_button, 7, 0, 1, 2) + + self.suppress_button = QPushButton("封堵圆柱孔") + help_tip(self.suppress_button, "用补料体填住完整圆柱孔。适合通孔/盲孔,不适合半孔或槽。") + self.suppress_button.clicked.connect(self.suppress_hole) + edit_layout.addWidget(self.suppress_button, 8, 0, 1, 2) + + edit_layout.addWidget(QLabel("孔深度"), 9, 0) + depth_inputs = QWidget() + depth_layout = QHBoxLayout(depth_inputs) + depth_layout.setContentsMargins(0, 0, 0, 0) + self.hole_depth_input = QLineEdit("") + help_tip(self.hole_depth_input, "盲孔或盲槽的目标深度。选中候选后会填入参考值,深度来自几何估算。") + self.hole_bottom_face_input = QLineEdit("") + self.hole_bottom_face_input.setPlaceholderText("底面 Face ID") + help_tip(self.hole_bottom_face_input, "自动识别孔底不稳定时,可手动输入底面 Face ID,让孔深计算有明确底面。") + self.hole_bottom_face_input.textChanged.connect(lambda _text: self._update_action_states()) + depth_layout.addWidget(self.hole_depth_input, stretch=2) + depth_layout.addWidget(self.hole_bottom_face_input, stretch=1) + edit_layout.addWidget(depth_inputs, 9, 1) + self.resize_depth_button = QPushButton("调整盲孔深度") + help_tip(self.resize_depth_button, "加深或变浅盲孔/盲槽。需要识别到底面,或手动填写底面 Face ID。") + self.resize_depth_button.clicked.connect(self.resize_hole_depth) + edit_layout.addWidget(self.resize_depth_button, 10, 0, 1, 2) + + edit_layout.addWidget(QLabel("圆角半径"), 11, 0) + self.edge_fillet_radius_input = QLineEdit("") + help_tip(self.edge_fillet_radius_input, "新圆角或已有圆角的目标半径。选中边/圆角候选后会自动填参考值。") + edit_layout.addWidget(self.edge_fillet_radius_input, 11, 1) + self.fillet_edge_button = QPushButton("给边添加圆角") + help_tip(self.fillet_edge_button, "给当前直线边新增圆角。不是修改已有圆角;已有圆角请用下面那个按钮。") + self.fillet_edge_button.clicked.connect(self.fillet_edge) + edit_layout.addWidget(self.fillet_edge_button, 12, 0, 1, 2) + + self.resize_existing_fillet_button = QPushButton("修改已有圆角半径") + help_tip(self.resize_existing_fillet_button, "尝试修改已识别圆角面的半径。会先移除原圆角再重建,复杂圆角可能失败并回滚。") + self.resize_existing_fillet_button.clicked.connect(self.resize_existing_fillet) + edit_layout.addWidget(self.resize_existing_fillet_button, 13, 0, 1, 2) + + edit_layout.addWidget(QLabel("倒角距离"), 14, 0) + self.edge_chamfer_distance_input = QLineEdit("") + help_tip(self.edge_chamfer_distance_input, "给边添加倒角时使用的距离。选中直线边后会填一个较小参考值。") + edit_layout.addWidget(self.edge_chamfer_distance_input, 14, 1) + self.chamfer_edge_button = QPushButton("给边添加倒角") + help_tip(self.chamfer_edge_button, "给当前直线边新增对称倒角。会先预览,再后台执行,失败会回滚。") + self.chamfer_edge_button.clicked.connect(self.chamfer_edge) + edit_layout.addWidget(self.chamfer_edge_button, 15, 0, 1, 2) + + edit_layout.addWidget(QLabel("边目标长度"), 16, 0) + edge_length_inputs = QWidget() + edge_length_layout = QHBoxLayout(edge_length_inputs) + edge_length_layout.setContentsMargins(0, 0, 0, 0) + self.edge_target_length_input = QLineEdit("") + help_tip(self.edge_target_length_input, "当前 edge 的目标长度。选中边后会自动填当前长度,改成新长度再执行。") + self.edge_length_anchor_combo = QComboBox() + self.edge_length_anchor_combo.addItem("自动", "auto") + self.edge_length_anchor_combo.addItem("中心", "center") + self.edge_length_anchor_combo.addItem("固定起点", "keep-start") + self.edge_length_anchor_combo.addItem("固定终点", "keep-end") + self.edge_length_anchor_combo.setCurrentIndex(0) + self.edge_length_anchor_combo.setMinimumWidth(90) + help_tip( + self.edge_length_anchor_combo, + "选择边长修改时尽量固定哪里:自动会优先找局部端面;中心/起点/终点会影响缩放 fallback 的基准点。", + ) + edge_length_layout.addWidget(self.edge_target_length_input, stretch=2) + edge_length_layout.addWidget(self.edge_length_anchor_combo, stretch=1) + edit_layout.addWidget(edge_length_inputs, 16, 1) + self.resize_edge_length_button = QPushButton("直接修改边长") + help_tip( + self.resize_edge_length_button, + "直接修改当前 edge 长度。直线边优先端面推拉,圆边优先换算相邻圆柱直径,必要时再用几何缩放 fallback。", + ) + self.resize_edge_length_button.clicked.connect(self.resize_any_edge_length) + edit_layout.addWidget(self.resize_edge_length_button, 17, 0, 1, 2) + + edit_layout.addWidget(QLabel("平移 X/Y/Z"), 18, 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") + help_tip(self.translate_x_input, "沿 X 方向平移的距离。输入 0 表示 X 方向不移动。") + help_tip(self.translate_y_input, "沿 Y 方向平移的距离。输入 0 表示 Y 方向不移动。") + help_tip(self.translate_z_input, "沿 Z 方向平移的距离。输入 0 表示 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, 18, 1) + self.translate_part_button = QPushButton("平移选中零件") + help_tip(self.translate_part_button, "按上面的 X/Y/Z 距离移动当前零件。会写入历史,可撤销。") + self.translate_part_button.clicked.connect(self.translate_selected_part) + self.translate_solid_button = QPushButton("平移选中 solid") + help_tip(self.translate_solid_button, "按上面的 X/Y/Z 距离移动当前 solid。单 solid 零件中相当于移动整个零件。") + self.translate_solid_button.clicked.connect(self.translate_selected_solid) + edit_layout.addWidget(self.translate_part_button, 19, 0, 1, 2) + edit_layout.addWidget(self.translate_solid_button, 20, 0, 1, 2) + + edit_layout.addWidget(QLabel("旋转轴/角度"), 21, 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") + help_tip(self.rotate_axis_combo, "选择旋转轴。对象会绕自身包围盒中心旋转。") + self.rotate_angle_input = QLineEdit("90") + self.rotate_angle_input.setPlaceholderText("度") + help_tip(self.rotate_angle_input, "旋转角度,单位是度。正负号决定旋转方向。") + rotate_layout.addWidget(self.rotate_axis_combo) + rotate_layout.addWidget(self.rotate_angle_input) + edit_layout.addWidget(rotate_inputs, 21, 1) + self.rotate_part_button = QPushButton("旋转选中零件") + help_tip(self.rotate_part_button, "绕所选轴旋转当前零件。会写入历史,可撤销。") + self.rotate_part_button.clicked.connect(self.rotate_selected_part) + self.rotate_solid_button = QPushButton("旋转选中 solid") + help_tip(self.rotate_solid_button, "绕所选轴旋转当前 solid。单 solid 零件中相当于旋转整个零件。") + self.rotate_solid_button.clicked.connect(self.rotate_selected_solid) + edit_layout.addWidget(self.rotate_part_button, 22, 0, 1, 2) + edit_layout.addWidget(self.rotate_solid_button, 23, 0, 1, 2) + + self.cylinders_button = QPushButton("列出圆柱候选") + help_tip(self.cylinders_button, "扫描模型里的圆柱面,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。") + self.cylinders_button.clicked.connect(self.list_cylinders) + edit_layout.addWidget(self.cylinders_button, 24, 0, 1, 2) + + self.undo_button = QPushButton("撤销") + help_tip(self.undo_button, "撤销上一次成功编辑,把模型恢复到编辑前快照。") + self.undo_button.clicked.connect(self.undo_edit) + self.redo_button = QPushButton("重做") + help_tip(self.redo_button, "重做刚刚撤销的编辑。") + self.redo_button.clicked.connect(self.redo_edit) + edit_layout.addWidget(self.undo_button, 25, 0) + edit_layout.addWidget(self.redo_button, 25, 1) + panel_layout.addWidget(edit_box) + + editable_box = QGroupBox("第一版可编辑对象") + editable_box.setObjectName("editableSection") + help_tip(editable_box, "自动找出一批可以尝试编辑的 face 或 edge,方便不用手动到处点。") + editable_layout = QVBoxLayout(editable_box) + editable_button_row = QHBoxLayout() + self.editable_refresh_button = QPushButton("扫描可编辑对象") + help_tip(self.editable_refresh_button, "快速扫描一批可以尝试编辑的对象,并列在下面表格里。") + self.editable_refresh_button.clicked.connect(lambda _checked=False: self.refresh_editable_candidates(show_info=True)) + self.editable_deep_scan_button = QPushButton("深度扫描") + help_tip(self.editable_deep_scan_button, "扫描更多候选对象,结果更全但更慢。默认列表找不到目标时再用。") + self.editable_deep_scan_button.clicked.connect( + lambda _checked=False: self.refresh_editable_candidates(show_info=True, deep_scan=True) + ) + editable_button_row.addWidget(self.editable_refresh_button) + editable_button_row.addWidget(self.editable_deep_scan_button) + editable_layout.addLayout(editable_button_row) + self.editable_table = QTableWidget(0, 8) + self.editable_table.setHorizontalHeaderLabels( + ["操作", "ID", "对象", "当前值", "状态", "风险", "置信度", "说明"] + ) + self.editable_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.editable_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.editable_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.editable_table.setMinimumHeight(130) + help_tip(self.editable_table, "这里列出可尝试编辑的对象。点击一行会选中模型里的对应 face 或 edge,并自动填入相关输入框。") + self.editable_table.cellClicked.connect(self.on_editable_row_clicked) + editable_layout.addWidget(self.editable_table) + panel_layout.addWidget(editable_box) + + candidate_box = QGroupBox("圆柱候选") + candidate_box.setObjectName("candidateSection") + help_tip(candidate_box, "列出圆柱面并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。") + candidate_layout = QVBoxLayout(candidate_box) + filter_layout = QHBoxLayout() + filter_layout.addWidget(QLabel("类型")) + self.candidate_filter_combo = NoWheelComboBox() + self.candidate_filter_combo.addItems( + [ + "All", + "Hole/Groove", + "Round/Fillet", + "Boss/Outer", + "Unclear", + ] + ) + help_tip( + self.candidate_filter_combo, + "筛选下面的圆柱候选:孔/槽、圆角、凸台/外圆,或暂时无法明确分类的圆柱面。", + ) + self.candidate_filter_combo.currentTextChanged.connect( + lambda _text: self._filter_cached_cylinder_candidates(show_info=True) + ) + filter_layout.addWidget(self.candidate_filter_combo) + candidate_layout.addLayout(filter_layout) + self.cylinder_table = QTableWidget(0, 8) + self.cylinder_table.setHorizontalHeaderLabels( + ["face", "guess", "diameter", "span", "height", "confidence", "risk", "part"] + ) + self.cylinder_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.cylinder_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.cylinder_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.cylinder_table.setMinimumHeight(140) + help_tip(self.cylinder_table, "这里显示圆柱面候选。点击一行会选中对应 face,并在属性区显示可用操作和风险。") + self.cylinder_table.cellClicked.connect(self.on_cylinder_row_clicked) + candidate_layout.addWidget(self.cylinder_table) + panel_layout.addWidget(candidate_box) + + history_box = QGroupBox("操作历史") + history_box.setObjectName("historySection") + help_tip(history_box, "查看已经成功执行的编辑、差异预览,并导出报告或 JSON 历史。") + history_layout = QVBoxLayout(history_box) + self.history_list = QListWidget() + self.history_list.setMinimumHeight(110) + help_tip(self.history_list, "成功执行过的编辑会出现在这里。点击一条记录可查看参数、差异预览和目标位置。") + self.history_list.currentRowChanged.connect(self.on_history_row_changed) + history_buttons = QHBoxLayout() + clear_diff_button = QPushButton("清除差异预览") + help_tip(clear_diff_button, "关闭历史记录产生的红/绿差异叠加和热力图显示。不会修改模型。") + clear_diff_button.clicked.connect(lambda _checked=False: self.clear_diff_preview()) + export_diff_button = QPushButton("导出差异报告") + help_tip(export_diff_button, "把当前选中的历史记录导出成文本报告,包含参数、拓扑变化和几何差异统计。") + export_diff_button.clicked.connect(self.export_diff_report) + export_history_button = QPushButton("导出编辑历史") + help_tip(export_history_button, "把本次会话里的所有编辑记录导出为 JSON,方便留档或后续复盘。") + export_history_button.clicked.connect(self.export_operation_history) + history_layout.addWidget(self.history_list) + history_buttons.addWidget(clear_diff_button) + history_buttons.addWidget(export_diff_button) + history_buttons.addWidget(export_history_button) + history_layout.addLayout(history_buttons) + panel_layout.addWidget(history_box) + + info_box = QGroupBox("选中对象信息") + info_box.setObjectName("infoSection") + help_tip(info_box, "显示当前选中对象的几何、拓扑、特征判断和可用操作信息。") + info_layout = QVBoxLayout(info_box) + info_buttons = QHBoxLayout() + copy_id_button = QPushButton("复制 ID") + help_tip(copy_id_button, "复制当前选中对象的 ID。Face/Feature 会优先复制逻辑 Face ID。") + copy_id_button.clicked.connect(self.copy_selected_id) + copy_pick_button = QPushButton("复制坐标") + help_tip(copy_pick_button, "复制最近一次鼠标点到模型上的三维坐标。") + copy_pick_button.clicked.connect(self.copy_pick_position) + copy_info_button = QPushButton("复制信息") + help_tip(copy_info_button, "复制当前属性区里的完整文本,方便发给别人或做问题记录。") + copy_info_button.clicked.connect(self.copy_current_info) + info_buttons.addWidget(copy_id_button) + info_buttons.addWidget(copy_pick_button) + info_buttons.addWidget(copy_info_button) + info_layout.addLayout(info_buttons) + + self.info_tabs = QTabWidget() + self.info_tree = QTreeWidget() + self.info_tree.setHeaderLabels(["属性", "值"]) + self.info_tree.setAlternatingRowColors(True) + self.info_tree.setTextElideMode(Qt.TextElideMode.ElideMiddle) + self.info_tree.setUniformRowHeights(True) + help_tip(self.info_tree, "当前选中对象的结构化属性。悬停单元格可以看到完整字段和值。") + self.info_text = QPlainTextEdit() + self.info_text.setReadOnly(True) + help_tip(self.info_text, "当前选中对象信息的原始文本版本,适合复制或排查问题。") + self.info_tabs.addTab(self.info_tree, "属性表") + self.info_tabs.addTab(self.info_text, "原始文本") + help_tip(self.info_tabs, "在表格视图和原始文本视图之间切换当前选中对象信息。") + info_layout.addWidget(self.info_tabs) + panel_layout.addWidget(info_box, stretch=1) + + self._update_action_states() + + self.vtk_widget = QVTKRenderWindowInteractor(central) + self.vtk_widget.setMouseTracking(True) + self.vtk_widget.installEventFilter(self) + root_layout.addWidget(self.vtk_widget, stretch=1) + + self.statusBar().showMessage("Ready") + + def _set_help_tip(self, widget, text: str) -> None: + widget.setToolTip(text) + widget.setStatusTip(text) + try: + widget.setToolTipDuration(14000) + except AttributeError: + pass + + + +def _parse_args(argv: list[str]) -> tuple[Path, bool]: + smoke_test = "--smoke-test" in argv + paths = [arg for arg in argv[1:] if not arg.startswith("--")] + path = Path(paths[0]) if paths else Path("geom_extract.step") + return path, smoke_test + + +def main() -> int: + _enable_crash_log() + path, smoke_test = _parse_args(sys.argv) + app = QApplication(sys.argv) + window = StepEditorWindow(path, background_load=not smoke_test) + if smoke_test: + print("smoke test ok") + window.close() + app.quit() + return 0 + window.show() + window.vtk_widget.Start() + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/step_editor/constants.py b/step_editor/constants.py new file mode 100644 index 0000000..d31bb4f --- /dev/null +++ b/step_editor/constants.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from OCC.Core.GeomAbs import ( + GeomAbs_BSplineCurve, + GeomAbs_BSplineSurface, + GeomAbs_BezierCurve, + GeomAbs_BezierSurface, + GeomAbs_Circle, + GeomAbs_Cone, + GeomAbs_Cylinder, + GeomAbs_Ellipse, + GeomAbs_Hyperbola, + GeomAbs_Line, + GeomAbs_OffsetSurface, + GeomAbs_OtherCurve, + GeomAbs_OtherSurface, + GeomAbs_Parabola, + GeomAbs_Plane, + GeomAbs_Sphere, + GeomAbs_SurfaceOfExtrusion, + GeomAbs_SurfaceOfRevolution, + GeomAbs_Torus, +) +from OCC.Core.TopAbs import TopAbs_EXTERNAL, TopAbs_FORWARD, TopAbs_INTERNAL, TopAbs_REVERSED + + +SURFACE_TYPES = { + GeomAbs_Plane: "plane", + GeomAbs_Cylinder: "cylinder", + GeomAbs_Cone: "cone", + GeomAbs_Sphere: "sphere", + GeomAbs_Torus: "torus", + GeomAbs_BezierSurface: "bezier surface", + GeomAbs_BSplineSurface: "b-spline surface", + GeomAbs_SurfaceOfRevolution: "surface of revolution", + GeomAbs_SurfaceOfExtrusion: "surface of extrusion", + GeomAbs_OffsetSurface: "offset surface", + GeomAbs_OtherSurface: "other surface", +} + +SNAPSHOT_FACE_LOGICAL_IDS_KEY = "__face_logical_ids__" + +CURVE_TYPES = { + GeomAbs_Line: "line", + GeomAbs_Circle: "circle", + GeomAbs_Ellipse: "ellipse", + GeomAbs_Hyperbola: "hyperbola", + GeomAbs_Parabola: "parabola", + GeomAbs_BezierCurve: "bezier curve", + GeomAbs_BSplineCurve: "b-spline curve", + GeomAbs_OtherCurve: "other curve", +} + +ORIENTATION_TYPES = { + TopAbs_FORWARD: "forward", + TopAbs_REVERSED: "reversed", + TopAbs_INTERNAL: "internal", + TopAbs_EXTERNAL: "external", +} diff --git a/step_editor/export.py b/step_editor/export.py new file mode 100644 index 0000000..95c7be7 --- /dev/null +++ b/step_editor/export.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import math +from pathlib import Path +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_Defeaturing, BRepAlgoAPI_Fuse +from OCC.Core.BRepBndLib import brepbndlib +from OCC.Core.BOPAlgo import BOPAlgo_GlueFull +from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform +from OCC.Core.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 +from OCC.Core.Bnd import Bnd_Box +from OCC.Core.GeomAbs import ( + GeomAbs_BSplineCurve, + GeomAbs_BSplineSurface, + GeomAbs_BezierCurve, + GeomAbs_BezierSurface, + GeomAbs_Circle, + GeomAbs_Cone, + GeomAbs_Cylinder, + GeomAbs_Ellipse, + GeomAbs_Hyperbola, + GeomAbs_Line, + GeomAbs_OffsetSurface, + GeomAbs_OtherCurve, + GeomAbs_OtherSurface, + GeomAbs_Parabola, + GeomAbs_Plane, + GeomAbs_Sphere, + GeomAbs_SurfaceOfExtrusion, + GeomAbs_SurfaceOfRevolution, + GeomAbs_Torus, +) +from OCC.Core.GProp import GProp_GProps +from OCC.Core.ShapeFix import ShapeFix_Shape +from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain +from OCC.Core.TopAbs import ( + TopAbs_EDGE, + TopAbs_EXTERNAL, + TopAbs_FACE, + TopAbs_FORWARD, + TopAbs_IN, + TopAbs_INTERNAL, + TopAbs_OUT, + TopAbs_REVERSED, + TopAbs_SOLID, +) +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.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec +from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge + +from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES +from .geometry_utils import * # noqa: F403 +from .step_io import _prepare_shape_for_step_export, _write_step + + +class ExportMixin: + def export_all(self, filename: str | Path) -> None: + 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}") + face_ids = self.face_region_ids(target_id) + shape = self._face_region_shape(target_id) + label = f"Face {target_id}" if len(face_ids) == 1 else f"Face region from face {target_id}" + info = _shape_quality_info(label, 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["face_region_ids"] = tuple(face_ids) + info["face_region_count"] = len(face_ids) + 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(_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(_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._face_region_shape(face_id), Path(filename)) + + def face_region_ids(self, face_id: int) -> list[int]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + return self.connected_same_domain_face_ids(face_id) or [face_id] + + def face_region_boundary_edge_ids(self, face_id: int) -> list[int]: + return self._region_boundary_edge_ids(self.face_region_ids(face_id)) + + def _face_region_shape(self, face_id: int) -> TopoDS_Shape: + face_ids = self.face_region_ids(face_id) + shapes = [self.faces[item] for item in face_ids if 0 <= item < len(self.faces)] + if not shapes: + raise ValueError(f"Face region export did not find any valid faces for face {face_id}.") + if len(shapes) == 1: + return shapes[0] + return _unify_same_domain_shape(_compound_from_shapes(shapes)) + + 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)) + diff --git a/step_editor/features.py b/step_editor/features.py new file mode 100644 index 0000000..cba82fc --- /dev/null +++ b/step_editor/features.py @@ -0,0 +1,1361 @@ +from __future__ import annotations + +import math +from pathlib import Path +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_Defeaturing, BRepAlgoAPI_Fuse +from OCC.Core.BRepBndLib import brepbndlib +from OCC.Core.BOPAlgo import BOPAlgo_GlueFull +from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform +from OCC.Core.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 +from OCC.Core.Bnd import Bnd_Box +from OCC.Core.GeomAbs import ( + GeomAbs_BSplineCurve, + GeomAbs_BSplineSurface, + GeomAbs_BezierCurve, + GeomAbs_BezierSurface, + GeomAbs_Circle, + GeomAbs_Cone, + GeomAbs_Cylinder, + GeomAbs_Ellipse, + GeomAbs_Hyperbola, + GeomAbs_Line, + GeomAbs_OffsetSurface, + GeomAbs_OtherCurve, + GeomAbs_OtherSurface, + GeomAbs_Parabola, + GeomAbs_Plane, + GeomAbs_Sphere, + GeomAbs_SurfaceOfExtrusion, + GeomAbs_SurfaceOfRevolution, + GeomAbs_Torus, +) +from OCC.Core.GProp import GProp_GProps +from OCC.Core.ShapeFix import ShapeFix_Shape +from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain +from OCC.Core.TopAbs import ( + TopAbs_EDGE, + TopAbs_EXTERNAL, + TopAbs_FACE, + TopAbs_FORWARD, + TopAbs_IN, + TopAbs_INTERNAL, + TopAbs_OUT, + TopAbs_REVERSED, + TopAbs_SOLID, +) +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.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec +from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge + +from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES +from .geometry_utils import * # noqa: F403 + + +class FeatureMixin: + def editable_feature_candidates( + self, + limit: int = 160, + detailed: bool = False, + max_scan_faces: int | None = None, + max_scan_edges: int | None = None, + progress_callback: Callable[[], None] | None = None, + ) -> list[dict[str, object]]: + per_type_limit = max(1, limit // 5) + candidates: list[dict[str, object]] = [] + + diameter_count = 0 + slot_width_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)) + slot_width_limit = max(2, min(per_type_limit, limit // 10)) + cylinder_scan_limit = max(per_type_limit * 4, 24) + for item in self.cylindrical_feature_candidates( + limit=cylinder_scan_limit, + include_end_info=True, + max_scan_faces=max_scan_faces, + progress_callback=progress_callback, + ): + 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 + if ( + slot_width_count < slot_width_limit + and feature_guess == "hole/groove candidate" + and float(item.get("angular_span", 0.0)) < math.tau * 0.92 + ): + feature = self.feature_info(int(item["face_id"])) + slot_width = feature.get("slot_chord_width_estimate") + if isinstance(slot_width, (int, float)) and float(slot_width) > 0: + candidates.append( + { + "operation_key": "resize_slot_width", + "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": float(slot_width), + "current_value_label": "slot_width", + "status": item["resize_status"], + "risk": item["resize_risk"], + "confidence": item["confidence"], + "note": ( + "这是槽/半孔候选;点击后会选中该 face,并把槽/半孔宽度输入框预填为参考目标值。" + "执行时会把槽宽换算为圆柱直径后重建。" + ), + } + ) + slot_width_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 slot_width_count >= slot_width_limit + and existing_fillet_count >= existing_fillet_limit + ): + break + + plane_count = 0 + face_scan_limit = len(self.faces) if max_scan_faces is None else min(len(self.faces), max(0, int(max_scan_faces))) + for face_id, face in enumerate(self.faces[:face_scan_limit]): + if progress_callback is not None and face_id % 30 == 0: + progress_callback() + if plane_count >= per_type_limit: + break + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Plane: + continue + props = GProp_GProps() + brepgprop.SurfaceProperties(face, props) + if detailed: + direction_info = self._plane_push_pull_direction(face_id, surf) + confidence = str(direction_info["confidence"]) + risk = "low" if confidence == "high" else "medium" + status = "ready" if confidence == "high" else "caution" + note = str(direction_info["note"]) + else: + confidence = "pending" + risk = "medium" + status = "caution" + note = "快速扫描:推拉方向会在选中 face 或执行编辑前再详细判断。" + candidates.append( + { + "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], + "surface": "plane", + "feature_guess": "planar push/pull candidate", + "current_value": props.Mass(), + "current_value_label": "area", + "status": status, + "risk": risk, + "confidence": confidence, + "note": note, + } + ) + plane_count += 1 + + fillet_edge_count = 0 + chamfer_edge_count = 0 + edge_length_count = 0 + edge_type_limit = max(1, per_type_limit // 3) + edge_scan_limit = len(self.edges) if max_scan_edges is None else min(len(self.edges), max(0, int(max_scan_edges))) + for edge_id, edge in enumerate(self.edges[:edge_scan_limit]): + 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) + is_line_edge = curve.GetType() == GeomAbs_Line + is_circle_edge = curve.GetType() == GeomAbs_Circle + props = GProp_GProps() + brepgprop.LinearProperties(edge, props) + length = props.Mass() + if length <= 1e-9: + continue + solid_id = self._edge_solid_id(edge_id) + curve_label = CURVE_TYPES.get(curve.GetType(), f"type {curve.GetType()}") + if is_line_edge and 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 is_line_edge and 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": f"{curve_label} edge length candidate", + "current_value": length, + "current_value_label": "length", + "status": "caution", + "risk": "medium" if is_line_edge or is_circle_edge else "high", + "confidence": "pending", + "note": "快速扫描:直线边会优先尝试端面推拉;圆边会尝试换算相邻圆柱直径;其他边会使用几何缩放 fallback。", + } + ) + 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, + "resize_slot_width": 1, + "resize_boss": 2, + "suppress_cylinder": 3, + "resize_depth": 4, + "inspect_existing_fillet": 5, + "push_pull_plane": 6, + "fillet_edge": 7, + "chamfer_edge": 8, + "resize_edge_length": 9, + } + 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.get("target_id", item.get("face_id", item.get("edge_id", -1)))), + ) + ) + return candidates[:limit] + + def cylindrical_feature_candidates( + self, + limit: int = 100, + include_end_info: bool = False, + max_scan_faces: int | None = None, + progress_callback: Callable[[], None] | None = None, + ) -> list[dict[str, object]]: + candidates: list[dict[str, object]] = [] + face_scan_limit = len(self.faces) if max_scan_faces is None else min(len(self.faces), max(0, int(max_scan_faces))) + for face_id, face in enumerate(self.faces[:face_scan_limit]): + if progress_callback is not None and face_id % 30 == 0: + progress_callback() + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + continue + cyl = surf.Cylinder() + props = GProp_GProps() + brepgprop.SurfaceProperties(face, props) + radius = cyl.Radius() + u_span = abs(surf.LastUParameter() - surf.FirstUParameter()) + v_span = abs(surf.LastVParameter() - surf.FirstVParameter()) + swept_area = max(radius * max(u_span, 1e-9), 1e-9) + height_estimate = props.Mass() / swept_area + boundary_edges = len(list(TopologyExplorer(face, ignore_orientation=True).edges())) + classification = self._classify_cylindrical_face(face_id, surf) + candidate = { + "face_id": face_id, + "part_id": self.face_part_ids[face_id], + "solid_id": self.face_solid_ids[face_id], + "radius": radius, + "diameter": radius * 2.0, + "axis": _dir_tuple(cyl.Axis().Direction()), + "area": props.Mass(), + "angular_span": u_span, + "height_estimate": height_estimate, + "param_height": v_span, + "boundary_edges": boundary_edges, + "feature_guess": classification["feature_guess"], + "material_toward_axis": classification["toward_axis"], + "material_away_axis": classification["away_axis"], + "material_vote_summary": classification["vote_summary"], + "material_sample_count": classification["sample_count"], + "confidence": classification["confidence"], + "note": classification["note"], + } + 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 + return candidates + + def cylindrical_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"]) + feature = self.feature_info(face_id) + axis_range = self._cylindrical_axis_range( + face_id, + BRepAdaptor_Surface(self.faces[face_id]), + _int_values(feature.get("feature_side_face_ids")), + ) + scoped_info = dict(info) + scoped_info["height_estimate"] = axis_range["span"] + scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"]) + scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range)) + readiness = _cylinder_resize_readiness(scoped_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(scoped_info.get("height_estimate", 0.0)) + target_to_height_ratio = new_diameter / height_estimate if height_estimate > 1e-9 else "" + 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"], + "risk": readiness["resize_risk"], + "message": readiness["resize_note"], + "warnings": readiness["resize_warnings"], + "blockers": readiness["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_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"), + "height_estimate": scoped_info.get("height_estimate"), + "same_domain_face_ids": axis_range["same_domain_face_ids"], + "same_domain_face_count": axis_range["same_domain_face_count"], + "same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]), + "same_domain_range_source": axis_range["range_source"], + "material_vote_summary": info.get("material_vote_summary"), + "material_sample_count": info.get("material_sample_count"), + **cutter_plan, + **fill_plan, + } + + 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"]) + feature = self.feature_info(face_id) + axis_range = self._cylindrical_axis_range( + face_id, + BRepAdaptor_Surface(self.faces[face_id]), + _int_values(feature.get("feature_side_face_ids")), + ) + scoped_info = dict(info) + scoped_info["height_estimate"] = axis_range["span"] + scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"]) + readiness = _cylinder_boss_resize_readiness(scoped_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(scoped_info.get("height_estimate", 0.0)) + target_to_height_ratio = new_diameter / height_estimate if height_estimate > 1e-9 else "" + 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": scoped_info.get("height_estimate"), + "same_domain_face_ids": axis_range["same_domain_face_ids"], + "same_domain_face_count": axis_range["same_domain_face_count"], + "same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]), + "same_domain_range_source": axis_range["range_source"], + "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 不是圆柱面,不能封堵圆柱孔。", + } + feature = self.feature_info(face_id) + axis_range = self._cylindrical_axis_range( + face_id, + BRepAdaptor_Surface(self.faces[face_id]), + _int_values(feature.get("feature_side_face_ids")), + ) + scoped_info = dict(info) + scoped_info["height_estimate"] = axis_range["span"] + scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"]) + scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range)) + readiness = _cylinder_suppress_readiness(scoped_info) + 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": scoped_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": scoped_info.get("cylinder_end_type"), + "same_domain_face_ids": axis_range["same_domain_face_ids"], + "same_domain_face_count": axis_range["same_domain_face_count"], + "same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]), + "same_domain_range_source": axis_range["range_source"], + "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, + bottom_face_id: int | None = None, + ) -> 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, + bottom_face_id=bottom_face_id, + ) + 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"]) + depth_info["manual_bottom_face_used"] = bool(context.get("manual_bottom_face_used")) + 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": context.get("feature_bottom_face_ids", feature.get("feature_bottom_face_ids")), + "manual_bottom_face_id": context.get("manual_bottom_face_id", ""), + "manual_bottom_face_used": bool(context.get("manual_bottom_face_used")), + "manual_bottom_face_note": context.get("manual_bottom_face_note", ""), + "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, + bottom_face_id: int | None = None, + ) -> dict[str, object]: + face = self.faces[face_id] + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + return { + "context_status": "blocked", + "context_message": "当前选中的 face 不是圆柱面。", + } + + manual_bottom_face_id = None + manual_bottom_face_used = bottom_face_id is not None + if bottom_face_id is not None: + manual_bottom_face_id = int(bottom_face_id) + if manual_bottom_face_id < 0 or manual_bottom_face_id >= len(self.faces): + return { + "context_status": "blocked", + "context_message": f"手动底面 Face ID {manual_bottom_face_id} 不存在。", + } + if manual_bottom_face_id == face_id: + return { + "context_status": "blocked", + "context_message": "手动底面不能和当前圆柱侧壁使用同一个 Face ID。", + } + source_solid_id = self.face_solid_ids[face_id] + bottom_solid_id = self.face_solid_ids[manual_bottom_face_id] + if source_solid_id >= 0 and bottom_solid_id >= 0 and source_solid_id != bottom_solid_id: + return { + "context_status": "blocked", + "context_message": "手动底面 Face 与当前圆柱面不属于同一个 solid,已阻止孔深修改。", + } + bottom_face_ids = (manual_bottom_face_id,) + manual_bottom_face_note = "使用用户手动指定的底面 Face ID 计算孔深。" + else: + bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ())) + manual_bottom_face_note = "" + + if not bottom_face_ids: + return { + "context_status": "blocked", + "context_message": "第一版孔深调整需要疑似底面;如果自动识别失败,请手动填写底面 Face ID。", + } + + cyl = surf.Cylinder() + radius = float(cyl.Radius()) + axis = cyl.Axis() + axis_point = axis.Location() + axis_dir = axis.Direction() + axis_range = self._cylindrical_axis_range( + face_id, + surf, + _int_values(feature.get("feature_side_face_ids")), + ) + v_min = float(axis_range["v_min"]) + v_max = float(axis_range["v_max"]) + start_open = info.get("start_end_open") is True + end_open = info.get("end_end_open") is True + open_direction_source = "axis-end-material-sampling" + if start_open != end_open: + open_parameter = v_min + nominal_bottom_parameter = v_max + direction_sign = 1.0 + if not start_open: + 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 + elif manual_bottom_face_used: + bottom_parameter = self._bottom_face_axis_parameter( + bottom_face_ids, + axis_point, + axis_dir, + (v_min + v_max) * 0.5, + ) + if bottom_parameter is None: + return { + "context_status": "blocked", + "context_message": "手动底面无法投影到当前圆柱轴线上,不能计算孔深。", + } + if abs(bottom_parameter - v_min) <= abs(bottom_parameter - v_max): + open_parameter = v_max + nominal_bottom_parameter = v_min + direction_sign = -1.0 + else: + open_parameter = v_min + nominal_bottom_parameter = v_max + direction_sign = 1.0 + current_depth_source = "manual-bottom-face-axis-parameter" + open_direction_source = "manual-bottom-face-nearest-axis-end" + else: + return { + "context_status": "blocked", + "context_message": "圆柱端部开口方向不唯一,不能可靠判断孔深方向。", + } + 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_open_direction_source": open_direction_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_scope_face_ids": axis_range["same_domain_face_ids"], + "depth_scope_face_count": axis_range["same_domain_face_count"], + "depth_range_source": axis_range["range_source"], + "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)), + "feature_bottom_face_ids": bottom_face_ids, + "manual_bottom_face_id": manual_bottom_face_id if manual_bottom_face_used else "", + "manual_bottom_face_used": manual_bottom_face_used, + "manual_bottom_face_note": manual_bottom_face_note, + } + + 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: + return { + "cutter_strategy": "unavailable", + "cutter_note": "selected face is not cylindrical", + } + + cyl = surf.Cylinder() + old_radius = cyl.Radius() + new_radius = new_diameter / 2.0 + feature = feature or self.feature_info(face_id) + axis_range = self._cylindrical_axis_range( + face_id, + surf, + _int_values(feature.get("feature_side_face_ids")), + ) + v_min = float(axis_range["v_min"]) + v_max = float(axis_range["v_max"]) + span = max(v_max - v_min, 0.0) + end_info = self._cylinder_end_opening_info(face_id, surf, axis_range) + base_margin = min(max(new_radius * 0.05, abs(new_radius - old_radius) * 0.5, 0.02), max(span * 0.05, 0.2)) + 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 + 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) + axis = cyl.Axis() + direction = axis.Direction() + axis_point = axis.Location() + start = gp_Pnt( + axis_point.X() + direction.X() * start_parameter, + axis_point.Y() + direction.Y() * start_parameter, + axis_point.Z() + direction.Z() * start_parameter, + ) + return { + "cutter_strategy": "bounded-to-selected-cylinder-v-range", + "cutter_note": ( + "有限长度切削:优先按同域圆柱侧壁整体 V 范围生成 cutter," + "如果没有同域拆分则退回选中 face 范围,减少贯穿整个零件的误切风险。" + ), + "cutter_scope_face_ids": axis_range["same_domain_face_ids"], + "cutter_scope_face_count": axis_range["same_domain_face_count"], + "cutter_range_source": axis_range["range_source"], + "cutter_start_parameter": start_parameter, + "cutter_end_parameter": end_parameter, + "cutter_height": height, + "cutter_margin": base_margin, + "cutter_start_margin": start_margin, + "cutter_end_margin": end_margin, + "cutter_radius": new_radius, + "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, + } + + def _bounded_cylinder_fill_plan(self, face_id: int) -> dict[str, object]: + face = self.faces[face_id] + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + return { + "fill_strategy": "unavailable", + "fill_note": "selected face is not cylindrical", + } + + cyl = surf.Cylinder() + radius = cyl.Radius() + axis_range = self._cylindrical_axis_range(face_id, surf) + v_min = float(axis_range["v_min"]) + v_max = float(axis_range["v_max"]) + height = max(v_max - v_min, 1e-6) + overlap = min(max(radius * 0.001, 0.001), 0.05) + axis = cyl.Axis() + direction = axis.Direction() + axis_point = axis.Location() + start = _point_on_axis(axis_point, direction, v_min) + return { + "fill_strategy": "bounded-fill-then-recut", + "fill_note": ( + "缩小孔径实验策略:先在同域圆柱侧壁范围内补料,再按目标直径重切。" + "补料不向开口端外伸。" + ), + "fill_scope_face_ids": axis_range["same_domain_face_ids"], + "fill_scope_face_count": axis_range["same_domain_face_count"], + "fill_range_source": axis_range["range_source"], + "fill_start_parameter": v_min, + "fill_end_parameter": v_max, + "fill_height": height, + "fill_radius": radius + overlap, + "fill_radius_overlap": overlap, + "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 + axis_range = self._cylindrical_axis_range(face_id, surf) + v_min = float(axis_range["v_min"]) + v_max = float(axis_range["v_max"]) + span = max(v_max - v_min, 1e-6) + delta_radius = abs(new_radius - old_radius) + base_radius = max(old_radius, new_radius) + resize_mode = _resize_mode(old_radius * 2.0, new_diameter) + if resize_mode == "enlarge": + axial_margin = 0.0 + start_parameter = v_min + end_parameter = v_max + else: + axial_margin = min( + max(base_radius * 0.001, delta_radius * 0.01, span * 0.001, 0.001), + max(span * 0.01, 0.02), + ) + start_parameter = v_min - axial_margin + end_parameter = v_max + axial_margin + radial_overlap = min(max(old_radius * 0.001, 0.001), 0.05) + 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) + exact_start = _point_on_axis(axis_point, direction, v_min) + return { + "boss_tool_strategy": "bounded-cylinder-fuse" if resize_mode == "enlarge" else "remove-envelope-then-fuse-target-cylinder", + "boss_tool_note": ( + "扩大凸台会在同域圆柱侧壁整体 V 范围内生成目标半径圆柱并 Fuse;" + "缩小凸台会先用旧半径包络体移除原凸台范围,再 Fuse 目标半径圆柱重建。" + "第一版会给轴向两端保留少量重叠,让布尔结果更容易和原实体合并。" + ), + "boss_tool_scope_face_ids": axis_range["same_domain_face_ids"], + "boss_tool_scope_face_count": axis_range["same_domain_face_count"], + "boss_tool_range_source": axis_range["range_source"], + "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), + "boss_tool_exact_start_point": _point_tuple(exact_start), + "boss_tool_exact_height": max(v_max - v_min, 1e-6), + } + + def _cylinder_end_opening_info( + self, + face_id: int, + surf: BRepAdaptor_Surface, + axis_range: dict[str, object] | None = None, + ) -> dict[str, object]: + solid_id = self.face_solid_ids[face_id] + fallback = { + "cylinder_end_type": "unknown", + "hole_depth_estimate": abs(surf.LastVParameter() - surf.FirstVParameter()), + "start_end_state": "unknown", + "end_end_state": "unknown", + "start_end_open": False, + "end_end_open": False, + "open_end_count": 0, + "closed_end_count": 0, + "end_sample_offset": "", + "end_sample_note": "no owning solid was found", + } + if solid_id < 0 or solid_id >= len(self.solids): + return fallback + + solid = self.solids[solid_id][1] + cyl = surf.Cylinder() + axis = cyl.Axis() + axis_point = axis.Location() + direction = axis.Direction() + axis_range = axis_range or self._cylindrical_axis_range(face_id, surf) + v_min = float(axis_range["v_min"]) + v_max = float(axis_range["v_max"]) + span = max(v_max - v_min, 0.0) + radius = cyl.Radius() + offset = min(max(radius * 0.08, span * 0.02, 0.05), max(span * 0.25, 0.2)) + + start_probe = _point_on_axis(axis_point, direction, v_min - offset) + end_probe = _point_on_axis(axis_point, direction, v_max + offset) + start_state = _solid_state(solid, start_probe) + end_state = _solid_state(solid, end_probe) + start_open = start_state == "outside" + end_open = end_state == "outside" + start_closed = start_state == "inside" + end_closed = end_state == "inside" + open_count = int(start_open) + int(end_open) + closed_count = int(start_closed) + int(end_closed) + + if open_count == 2: + end_type = "through/open-ended" + note = "both axis-end probes are outside material" + elif open_count == 1 and closed_count == 1: + end_type = "blind" + note = "one axis-end probe is outside material and the other is inside material" + elif closed_count == 2: + end_type = "closed/internal" + note = "both axis-end probes are inside material" + else: + end_type = "unclear" + note = "axis-end probes did not produce a clear open/closed pattern" + + return { + "cylinder_end_type": end_type, + "hole_depth_estimate": span, + "start_end_state": start_state, + "end_end_state": end_state, + "start_end_open": start_open, + "end_end_open": end_open, + "open_end_count": open_count, + "closed_end_count": closed_count, + "end_sample_offset": offset, + "end_sample_note": note, + "end_sample_range_source": axis_range["range_source"], + "end_sample_scope_face_ids": axis_range["same_domain_face_ids"], + "end_sample_scope_face_count": axis_range["same_domain_face_count"], + } + + def _classify_cylindrical_face( + self, + face_id: int, + surf: BRepAdaptor_Surface, + detailed: bool = False, + ) -> dict[str, object]: + solid_id = self.face_solid_ids[face_id] + if solid_id < 0 or solid_id >= len(self.solids): + return { + "feature_guess": "cylindrical face", + "toward_axis": "unknown", + "away_axis": "unknown", + "vote_summary": "hole=0, boss=0, unclear=0", + "sample_count": 0, + "confidence": "low", + "note": "no owning solid was found", + } + + solid = self.solids[solid_id][1] + radius = surf.Cylinder().Radius() + angular_span = abs(surf.LastUParameter() - surf.FirstUParameter()) + boundary_edges = len(list(TopologyExplorer(self.faces[face_id], ignore_orientation=True).edges())) + 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: + return { + "feature_guess": "cylindrical face", + "toward_axis": "unknown", + "away_axis": "unknown", + "vote_summary": "hole=0, boss=0, unclear=0", + "sample_count": 0, + "confidence": "low", + "note": "could not sample cylinder material sides", + } + + toward_states = [sample["toward"] for sample in samples] + away_states = [sample["away"] for sample in samples] + hole_votes = sum(1 for sample in samples if sample["toward"] == "outside" and sample["away"] == "inside") + boss_votes = sum(1 for sample in samples if sample["toward"] == "inside" and sample["away"] == "outside") + unclear_votes = sample_count - hole_votes - boss_votes + vote_summary = f"hole={hole_votes}, boss={boss_votes}, unclear={unclear_votes}" + threshold = max(1, math.ceil(sample_count * 0.6)) + base = { + "toward_axis": _state_summary(toward_states), + "away_axis": _state_summary(away_states), + "vote_summary": vote_summary, + "sample_count": sample_count, + } + + if hole_votes >= threshold: + confidence = "high" if hole_votes == sample_count and not is_partial_cylinder else "medium" + return { + "feature_guess": "hole/groove candidate", + "confidence": confidence, + "note": "axis side is mostly empty and outer side is mostly material", + **base, + } + + 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", + "note": "partial small-radius cylinder; may be a fillet or blend", + **base, + } + + if boss_votes >= threshold: + return { + "feature_guess": "boss/outer-round candidate", + "confidence": "high" if boss_votes == sample_count and not is_partial_cylinder else "medium", + "note": "axis side is mostly material and outer side is mostly empty", + **base, + } + + return { + "feature_guess": "cylindrical face", + "confidence": "low", + "note": "material sampling did not produce a clear inside/outside pattern", + **base, + } + + def _sample_cylinder_material_states( + self, + surf: BRepAdaptor_Surface, + solid: TopoDS_Shape, + detailed: bool = False, + ) -> list[dict[str, str]]: + cyl = surf.Cylinder() + axis = cyl.Axis() + axis_point = axis.Location() + axis_dir = axis.Direction() + radius = cyl.Radius() + u_first = surf.FirstUParameter() + u_last = surf.LastUParameter() + v = (surf.FirstVParameter() + surf.LastVParameter()) / 2.0 + u_span = u_last - u_first + fractions = [0.5] + if detailed and abs(u_span) > 0.2: + fractions = [0.25, 0.5, 0.75] + + samples: list[dict[str, str]] = [] + for fraction in fractions: + u = u_first + u_span * fraction + point = surf.Value(u, v) + axis_to_point = _vec_from_points(axis_point, point) + projection = _dot(axis_to_point, axis_dir) + center = gp_Pnt( + axis_point.X() + axis_dir.X() * projection, + axis_point.Y() + axis_dir.Y() * projection, + axis_point.Z() + axis_dir.Z() * projection, + ) + radial = _vec_from_points(center, point) + radial_len = radial.Magnitude() + if radial_len <= 1e-9: + continue + + unit = gp_Vec(radial.X() / radial_len, radial.Y() / radial_len, radial.Z() / radial_len) + epsilon = min(max(radius * 0.03, 0.05), 1.0) + toward_point = gp_Pnt( + point.X() - unit.X() * epsilon, + point.Y() - unit.Y() * epsilon, + point.Z() - unit.Z() * epsilon, + ) + away_point = gp_Pnt( + point.X() + unit.X() * epsilon, + point.Y() + unit.Y() * epsilon, + point.Z() + unit.Z() * epsilon, + ) + samples.append( + { + "toward": _solid_state(solid, toward_point), + "away": _solid_state(solid, away_point), + } + ) + return samples + + def _plane_push_pull_direction(self, face_id: int, surf: BRepAdaptor_Surface) -> dict[str, object]: + face = self.faces[face_id] + direction = surf.Plane().Axis().Direction() + axis_tuple = _dir_tuple(direction) + oriented_tuple = _oriented_dir_tuple(direction, face) + fallback = { + "outward_direction": oriented_tuple, + "inward_direction": _neg_tuple(oriented_tuple), + "plus_side_state": "unknown", + "minus_side_state": "unknown", + "confidence": "low", + "note": "falling back to topology-oriented plane normal", + } + + solid_id = self.face_solid_ids[face_id] + if solid_id < 0 or solid_id >= len(self.solids): + fallback["note"] = "no owning solid was found; using topology-oriented plane normal" + return fallback + + solid = self.solids[solid_id][1] + props = GProp_GProps() + brepgprop.SurfaceProperties(face, props) + sample = props.CentreOfMass() + diagonal = _shape_diagonal(solid) + epsilon = min(max(diagonal * 1e-4, 0.05), 1.0) + plus_point = gp_Pnt( + sample.X() + direction.X() * epsilon, + sample.Y() + direction.Y() * epsilon, + sample.Z() + direction.Z() * epsilon, + ) + minus_point = gp_Pnt( + sample.X() - direction.X() * epsilon, + sample.Y() - direction.Y() * epsilon, + sample.Z() - direction.Z() * epsilon, + ) + plus_state = _solid_state(solid, plus_point) + minus_state = _solid_state(solid, minus_point) + + if plus_state == "outside" and minus_state == "inside": + return { + "outward_direction": axis_tuple, + "inward_direction": _neg_tuple(axis_tuple), + "plus_side_state": plus_state, + "minus_side_state": minus_state, + "confidence": "high", + "note": "positive plane normal side is outside material", + } + if plus_state == "inside" and minus_state == "outside": + return { + "outward_direction": _neg_tuple(axis_tuple), + "inward_direction": axis_tuple, + "plus_side_state": plus_state, + "minus_side_state": minus_state, + "confidence": "high", + "note": "negative plane normal side is outside material", + } + + fallback["plus_side_state"] = plus_state + fallback["minus_side_state"] = minus_state + fallback["note"] = "inside/outside sampling was unclear; using topology-oriented plane normal" + return fallback + diff --git a/step_editor/geometry_utils.py b/step_editor/geometry_utils.py new file mode 100644 index 0000000..5b562b4 --- /dev/null +++ b/step_editor/geometry_utils.py @@ -0,0 +1,1339 @@ +from __future__ import annotations + +import math +from typing import Iterable + +from OCC.Core.BRep import BRep_Tool +from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface +from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Defeaturing +from OCC.Core.BRepBndLib import brepbndlib +from OCC.Core.BOPAlgo import BOPAlgo_GlueFull +from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform +from OCC.Core.BRepCheck import BRepCheck_Analyzer +from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier +from OCC.Core.BRepGProp import brepgprop +from OCC.Core.Bnd import Bnd_Box +from OCC.Core.GeomAbs import ( + GeomAbs_Circle, + GeomAbs_Cylinder, + GeomAbs_Line, + GeomAbs_Plane, +) +from OCC.Core.GProp import GProp_GProps +from OCC.Core.ShapeFix import ShapeFix_Shape +from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain +from OCC.Core.TopAbs import ( + TopAbs_EDGE, + TopAbs_FACE, + TopAbs_IN, + TopAbs_OUT, + TopAbs_REVERSED, + TopAbs_SOLID, +) +from OCC.Core.TopExp import TopExp_Explorer +from OCC.Core.TopLoc import TopLoc_Location +from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods +from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape +from OCC.Core.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec +from OCC.Extend.TopologyUtils import TopologyExplorer + +from .constants import CURVE_TYPES, ORIENTATION_TYPES + + +def _compound_from_shapes(shapes: Iterable[TopoDS_Shape]) -> TopoDS_Shape: + from OCC.Core.BRep import BRep_Builder + + valid_shapes = [shape for shape in shapes if not shape.IsNull()] + if len(valid_shapes) == 1: + return valid_shapes[0] + + compound = TopoDS_Compound() + builder = BRep_Builder() + builder.MakeCompound(compound) + for shape in valid_shapes: + builder.Add(compound, shape) + return compound + + +def _explore(shape: TopoDS_Shape, shape_type: int) -> list[TopoDS_Shape]: + items: list[TopoDS_Shape] = [] + explorer = TopExp_Explorer(shape, shape_type) + while explorer.More(): + current = explorer.Current() + if shape_type == TopAbs_FACE: + items.append(topods.Face(current)) + elif shape_type == TopAbs_EDGE: + items.append(topods.Edge(current)) + elif shape_type == TopAbs_SOLID: + items.append(topods.Solid(current)) + else: + items.append(current) + explorer.Next() + 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 _surfaces_are_cocylindrical(left: BRepAdaptor_Surface, right: BRepAdaptor_Surface, tolerance: float) -> bool: + if left.GetType() != GeomAbs_Cylinder or right.GetType() != GeomAbs_Cylinder: + return False + left_cylinder = left.Cylinder() + right_cylinder = right.Cylinder() + left_axis = left_cylinder.Axis() + right_axis = right_cylinder.Axis() + left_dir = left_axis.Direction() + right_dir = right_axis.Direction() + axis_dot = abs(_direction_dot(left_dir, right_dir)) + if axis_dot < 1.0 - 1e-6: + return False + radius_tolerance = max(tolerance, max(left_cylinder.Radius(), right_cylinder.Radius()) * 1e-6) + if abs(left_cylinder.Radius() - right_cylinder.Radius()) > radius_tolerance: + return False + axis_distance = _point_axis_distance(left_axis.Location(), left_dir, right_axis.Location()) + return axis_distance <= max(tolerance, radius_tolerance) + + +def _surface_matches_plane_spec(surf: BRepAdaptor_Surface, spec: dict[str, object], tolerance: float) -> bool: + if surf.GetType() != GeomAbs_Plane: + return False + plane = surf.Plane() + normal = plane.Axis().Direction() + spec_normal = gp_Dir(*spec["normal"]) + if abs(_direction_dot(normal, spec_normal)) < 1.0 - 1e-7: + return False + spec_point = gp_Pnt(*spec["point"]) + distance = abs(_axis_parameter(spec_point, spec_normal, plane.Location())) + return distance <= tolerance + + +def _surface_matches_cylinder_spec(surf: BRepAdaptor_Surface, spec: dict[str, object], tolerance: float) -> bool: + if surf.GetType() != GeomAbs_Cylinder: + return False + cylinder = surf.Cylinder() + axis = cylinder.Axis() + axis_dir = axis.Direction() + spec_axis_point = gp_Pnt(*spec["axis_point"]) + spec_axis_dir = gp_Dir(*spec["axis_direction"]) + if abs(_direction_dot(axis_dir, spec_axis_dir)) < 1.0 - 1e-6: + return False + radius = float(spec["radius"]) + radius_tolerance = max(tolerance, max(radius, float(cylinder.Radius())) * 1e-6) + if abs(float(cylinder.Radius()) - radius) > radius_tolerance: + return False + return _point_axis_distance(spec_axis_point, spec_axis_dir, axis.Location()) <= max(tolerance, radius_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 + + points = vtk.vtkPoints() + polys = vtk.vtkCellArray() + for face in _explore(shape, TopAbs_FACE): + loc = TopLoc_Location() + tri = BRep_Tool.Triangulation(topods.Face(face), loc) + if tri is None: + continue + transform = loc.Transformation() + node_offset = points.GetNumberOfPoints() + for node_index in range(1, tri.NbNodes() + 1): + pnt = tri.Node(node_index).Transformed(transform) + points.InsertNextPoint(pnt.X(), pnt.Y(), pnt.Z()) + + reversed_face = face.Orientation() == TopAbs_REVERSED + for tri_index in range(1, tri.NbTriangles() + 1): + n1, n2, n3 = tri.Triangle(tri_index).Get() + if reversed_face: + n2, n3 = n3, n2 + vtk_tri = vtk.vtkTriangle() + vtk_tri.GetPointIds().SetId(0, node_offset + n1 - 1) + vtk_tri.GetPointIds().SetId(1, node_offset + n2 - 1) + vtk_tri.GetPointIds().SetId(2, node_offset + n3 - 1) + polys.InsertNextCell(vtk_tri) + + poly = vtk.vtkPolyData() + poly.SetPoints(points) + poly.SetPolys(polys) + return poly + + +def _shape_bounds(shape: TopoDS_Shape) -> tuple[float, float, float, float, float, float]: + if shape.IsNull(): + raise RuntimeError("Shape is null and has no usable bounds.") + box = Bnd_Box() + brepbndlib.Add(shape, box) + try: + if box.IsVoid(): + raise RuntimeError("Shape has no usable bounds.") + except AttributeError: + pass + try: + return box.Get() + except Exception as exc: + raise RuntimeError("Shape has no usable bounds.") from exc + + +def _shape_bounds_info(shape: TopoDS_Shape) -> dict[str, object]: + xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(shape) + dx = xmax - xmin + dy = ymax - ymin + dz = zmax - zmin + return { + "bbox_min": (xmin, ymin, zmin), + "bbox_max": (xmax, ymax, zmax), + "bbox_size": (dx, dy, dz), + "bbox_diagonal": math.sqrt(dx * dx + dy * dy + dz * dz), + } + + +def _shape_volume_info(shape: TopoDS_Shape) -> dict[str, object]: + props = GProp_GProps() + try: + brepgprop.VolumeProperties(shape, props) + except Exception: + return {"volume": "unavailable"} + volume = props.Mass() + info: dict[str, object] = {"volume": volume} + if abs(volume) > 1e-9: + info["center_of_mass"] = _point_tuple(props.CentreOfMass()) + return info + + +def _shape_diagonal(shape: TopoDS_Shape) -> float: + xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(shape) + 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_cross(left: tuple[float, float, float], right: tuple[float, float, float]) -> tuple[float, float, float]: + return ( + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ) + + +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) + if hasattr(op, "SetGlue"): + try: + op.SetGlue(BOPAlgo_GlueFull) + except Exception: + pass + if hasattr(op, "SetFuzzyValue"): + try: + op.SetFuzzyValue(1e-7) + except Exception: + pass + 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: + _simplify_boolean_builder(op) + 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 _simplify_boolean_builder(builder) -> None: + if not hasattr(builder, "SimplifyResult"): + return + for args in ((True, True, 1e-5), (True, True)): + try: + builder.SimplifyResult(*args) + return + except TypeError: + continue + except Exception: + return + + +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 + for safe_input_mode in (True, False): + candidate = _unify_same_domain_shape( + cleaned, + linear_tolerance=tolerance, + angular_tolerance=1e-5, + allow_internal_edges=False, + safe_input_mode=safe_input_mode, + ) + 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, + safe_input_mode: bool = True, + concat_bsplines: bool = False, +) -> TopoDS_Shape: + try: + unifier = ShapeUpgrade_UnifySameDomain(shape, True, True, concat_bsplines) + unifier.SetSafeInputMode(safe_input_mode) + 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.") + analyzer = BRepCheck_Analyzer(shape) + if not analyzer.IsValid(): + raise RuntimeError("Operation returned an invalid B-Rep shape.") + try: + _shape_bounds(shape) + except RuntimeError as exc: + raise RuntimeError("Operation returned a shape without usable geometry.") from exc + + +def _solid_state(solid: TopoDS_Shape, point: gp_Pnt) -> str: + classifier = BRepClass3d_SolidClassifier(solid, point, 1e-6) + state = classifier.State() + if state == TopAbs_IN: + return "inside" + if state == TopAbs_OUT: + return "outside" + return "on/unknown" + + +def _state_summary(states: list[str]) -> str: + if not states: + return "unknown" + counts: dict[str, int] = {} + for state in states: + counts[state] = counts.get(state, 0) + 1 + if len(counts) == 1: + return states[0] + return ", ".join(f"{state}:{count}" for state, count in sorted(counts.items())) + + +def _cylinder_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)) + + if guess == "round/fillet candidate": + risk = "high" + warnings.append("当前圆柱面更像圆角/倒圆,调整圆柱孔径很可能误切圆角。") + elif guess == "boss/outer-round candidate": + risk = "high" + warnings.append("当前圆柱面更像凸柱或外圆,调整圆柱孔径可能切掉外部结构。") + elif guess != "hole/groove candidate": + risk = "high" + warnings.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 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" + blockers.append("目标直径必须大于 0。") + elif abs(new_diameter - current_diameter) <= max(current_diameter * 1e-5, 1e-6): + status = "blocked" + risk = "blocked" + blockers.append("目标直径与当前直径几乎相同,不需要修改。") + 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("目标直径变化超过当前直径的 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" + if not warnings and not blockers: + note = "可以尝试调整圆柱孔径。" + else: + note = " ".join(blockers + warnings) + return { + "resize_status": status, + "resize_risk": risk, + "resize_warnings": ";".join(warnings), + "resize_blockers": ";".join(blockers), + "resize_note": note, + } + + +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)) + manual_bottom_face_used = bool(info.get("manual_bottom_face_used")) + + if guess != "hole/groove candidate": + blockers.append("孔深调整第一版只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。") + if end_type != "blind" and not manual_bottom_face_used: + blockers.append("孔深调整第一版只支持端部类型为 blind 的盲孔/盲槽。") + elif end_type != "blind" and manual_bottom_face_used: + risk = _max_risk(risk, "medium") + warnings.append("端部类型不是明确 blind,当前按手动底面 Face ID 推断孔深方向。") + 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 + + +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()) + + +def _oriented_dir_tuple(direction, shape: TopoDS_Shape) -> tuple[float, float, float]: + values = _dir_tuple(direction) + if shape.Orientation() == TopAbs_REVERSED: + return (-values[0], -values[1], -values[2]) + return values + + +def _neg_tuple(values: tuple[float, float, float]) -> tuple[float, float, float]: + return (-values[0], -values[1], -values[2]) + + +def _point_tuple(point) -> tuple[float, float, float]: + return (point.X(), point.Y(), point.Z()) + + +def _point_on_axis(axis_point: gp_Pnt, direction, parameter: float) -> gp_Pnt: + return gp_Pnt( + axis_point.X() + direction.X() * parameter, + axis_point.Y() + direction.Y() * parameter, + axis_point.Z() + direction.Z() * parameter, + ) + + +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 _shape_axis_interval(shape: TopoDS_Shape, axis_point: gp_Pnt, direction) -> tuple[float, float] | None: + parameters = _shape_axis_parameters(shape, axis_point, direction) + if not parameters: + return None + return (min(parameters), max(parameters)) + + +def _shape_plane_interval( + shape: TopoDS_Shape, + origin: gp_Pnt, + u_dir: gp_Dir, + v_dir: gp_Dir, +) -> tuple[tuple[float, float], tuple[float, float]] | None: + u_values = _shape_axis_parameters(shape, origin, u_dir) + v_values = _shape_axis_parameters(shape, origin, v_dir) + if not u_values or not v_values: + return None + return ((min(u_values), max(u_values)), (min(v_values), max(v_values))) + + +def _plane_intervals_touch_or_overlap( + left: tuple[tuple[float, float], tuple[float, float]], + right: tuple[tuple[float, float], tuple[float, float]], + tolerance: float, +) -> bool: + return _intervals_touch_or_overlap(left[0], right[0], tolerance) and _intervals_touch_or_overlap(left[1], right[1], tolerance) + + +def _intervals_touch_or_overlap( + left: tuple[float, float], + right: tuple[float, float], + tolerance: float, +) -> bool: + left_min, left_max = min(left), max(left) + right_min, right_max = min(right), max(right) + return left_max + tolerance >= right_min and right_max + tolerance >= left_min + + +def _plane_basis_dirs(normal) -> tuple[gp_Dir, gp_Dir]: + nx, ny, nz = _dir_tuple(normal) + reference = (1.0, 0.0, 0.0) if abs(nx) < 0.85 else (0.0, 1.0, 0.0) + u = _tuple_normalized(_tuple_cross((nx, ny, nz), reference)) or (1.0, 0.0, 0.0) + v = _tuple_normalized(_tuple_cross((nx, ny, nz), u)) or (0.0, 1.0, 0.0) + return gp_Dir(*u), gp_Dir(*v) + + +def _edge_duplicate_key(edge: TopoDS_Shape, tolerance: float) -> tuple[object, ...] | None: + try: + curve = BRepAdaptor_Curve(edge) + curve_type = curve.GetType() + start = curve.Value(curve.FirstParameter()) + end = curve.Value(curve.LastParameter()) + except Exception: + return None + + if curve_type == GeomAbs_Circle: + try: + circle = curve.Circle() + endpoint_keys = sorted((_point_quantized_key(start, tolerance), _point_quantized_key(end, tolerance))) + return ( + "circle", + _point_quantized_key(circle.Location(), tolerance), + _direction_quantized_key(circle.Axis().Direction()), + _number_quantized_key(float(circle.Radius()), tolerance), + tuple(endpoint_keys), + ) + except Exception: + pass + + endpoint_keys = sorted((_point_quantized_key(start, tolerance), _point_quantized_key(end, tolerance))) + if curve_type == GeomAbs_Line: + return ("line", tuple(endpoint_keys)) + return (CURVE_TYPES.get(curve_type, f"type {curve_type}"), tuple(endpoint_keys)) + + +def _point_quantized_key(point, tolerance: float) -> tuple[int, int, int]: + scale = max(float(tolerance), 1e-9) + return ( + _number_quantized_key(float(point.X()), scale), + _number_quantized_key(float(point.Y()), scale), + _number_quantized_key(float(point.Z()), scale), + ) + + +def _direction_quantized_key(direction) -> tuple[int, int, int]: + values = _tuple_normalized((float(direction.X()), float(direction.Y()), float(direction.Z()))) or (1.0, 0.0, 0.0) + for value in values: + if abs(value) > 1e-9: + if value < 0: + values = (-values[0], -values[1], -values[2]) + break + return ( + int(round(values[0] * 1_000_000)), + int(round(values[1] * 1_000_000)), + int(round(values[2] * 1_000_000)), + ) + + +def _number_quantized_key(value: float, tolerance: float) -> int: + return int(round(float(value) / max(float(tolerance), 1e-9))) + + +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}") + + +def _format_tuple(values: tuple[float, float, float]) -> str: + return "(" + ", ".join(f"{float(value):.6g}" for value in values) + ")" + + +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() + +__all__ = [name for name, value in globals().items() if name.startswith("_") and callable(value)] diff --git a/step_editor/info_panel.py b/step_editor/info_panel.py new file mode 100644 index 0000000..f91fe9f --- /dev/null +++ b/step_editor/info_panel.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +from datetime import datetime +import math +from pathlib import Path + +import vtk +from PySide6.QtCore import Qt, QThread, QTimer, Slot +from PySide6.QtWidgets import ( + QApplication, + QFileDialog, + QMessageBox, + QTableWidgetItem, + QTreeWidgetItem, +) + +from .model import StepModel +from .records import OperationRecord +from .ui_helpers import * # noqa: F403 +from .workers import EditWorker, ScanWorker + + +class InfoPanelMixin: + def set_info(self, info: dict[str, object]) -> None: + self.current_info_values = dict(info) + self.current_info_text = _info_to_text(info) + self.info_text.setPlainText(self.current_info_text) + self._populate_info_tree(info) + self.info_tabs.setCurrentWidget(self.info_tree) + + def set_plain_info(self, text: str) -> None: + self.current_info_values = {} + self.current_info_text = text + self.info_tree.clear() + self.info_text.setPlainText(text) + self.info_tabs.setCurrentWidget(self.info_text) + + def _populate_info_tree(self, info: dict[str, object]) -> None: + self.info_tree.clear() + emitted: set[str] = set() + for group_name, keys in INFO_GROUPS: + items = [(key, info[key]) for key in keys if key in info] + if not items: + continue + self._add_info_group(group_name, items) + emitted.update(key for key, _value in items) + + remaining = [(key, value) for key, value in info.items() if key not in emitted] + if remaining: + self._add_info_group("其他", remaining) + + self.info_tree.expandAll() + self.info_tree.resizeColumnToContents(0) + + def _add_info_group(self, group_name: str, items: list[tuple[str, object]]) -> None: + group = QTreeWidgetItem([group_name, ""]) + group.setFirstColumnSpanned(True) + self.info_tree.addTopLevelItem(group) + for key, value in items: + child = QTreeWidgetItem([INFO_LABELS.get(key, key), _format_value(value)]) + child.setData(0, Qt.UserRole, key) + child.setToolTip(0, key) + child.setToolTip(1, _format_value(value)) + group.addChild(child) + + def copy_selected_id(self) -> None: + text = self._selected_id_text() + if not text: + self.statusBar().showMessage("没有可复制的对象 ID") + return + QApplication.clipboard().setText(text) + self.statusBar().showMessage(f"已复制 {text}") + + def copy_pick_position(self) -> None: + pick_position = self.selected_pick_position + if pick_position is None and "pick_position" in self.current_info_values: + value = self.current_info_values["pick_position"] + if isinstance(value, tuple) and len(value) == 3: + pick_position = (float(value[0]), float(value[1]), float(value[2])) + if pick_position is None: + self.statusBar().showMessage("没有可复制的拾取坐标") + return + text = _format_value(pick_position) + QApplication.clipboard().setText(text) + self.statusBar().showMessage(f"已复制拾取坐标 {text}") + + def copy_current_info(self) -> None: + if not self.current_info_text: + self.statusBar().showMessage("没有可复制的信息") + return + QApplication.clipboard().setText(self.current_info_text) + self.statusBar().showMessage("已复制当前信息") + + def set_measure_point(self, label: str) -> None: + point, source = self._current_measure_point() + if point is None: + QMessageBox.information(self, "没有可测量的点", "请先在模型中选择一个对象,最好用鼠标点击到具体位置。") + self.statusBar().showMessage("没有可设为测量点的坐标") + return + if label.upper() == "A": + self.measure_point_a = point + self.measure_label_a = source + self.statusBar().showMessage(f"已设置测量点 A: {_format_value(point)}") + else: + self.measure_point_b = point + self.measure_label_b = source + self.statusBar().showMessage(f"已设置测量点 B: {_format_value(point)}") + self._refresh_measurement_panel() + + def clear_measurement(self) -> None: + self.measure_point_a = None + self.measure_point_b = None + self.measure_label_a = "" + self.measure_label_b = "" + self._clear_measure_actor() + self._refresh_measurement_panel() + self.statusBar().showMessage("已清除测量") + + def copy_measurement(self) -> None: + text = self.measure_text.toPlainText() if hasattr(self, "measure_text") else "" + if not text or "尚未设置" in text: + self.statusBar().showMessage("没有可复制的测量结果") + return + QApplication.clipboard().setText(text) + self.statusBar().showMessage("已复制测量结果") + + def _current_measure_point(self) -> tuple[tuple[float, float, float] | None, str]: + selected_label = self._selected_id_text() or "当前对象" + if self.selected_pick_position is not None: + return self._tuple3(self.selected_pick_position), f"{selected_label} 拾取点" + + for key, readable in ( + ("pick_position", "拾取点"), + ("area_center", "面积中心"), + ("length_center", "长度中心"), + ("surface_center", "表面积中心"), + ("center_of_mass", "重心"), + ("center", "中心"), + ("rotation_center", "旋转中心"), + ): + point = self._tuple3(self.current_info_values.get(key)) + if point is not None: + return point, f"{selected_label} {readable}" + + bbox_min = self._tuple3(self.current_info_values.get("bbox_min")) + bbox_max = self._tuple3(self.current_info_values.get("bbox_max")) + if bbox_min is not None and bbox_max is not None: + center = ( + (bbox_min[0] + bbox_max[0]) / 2.0, + (bbox_min[1] + bbox_max[1]) / 2.0, + (bbox_min[2] + bbox_max[2]) / 2.0, + ) + return center, f"{selected_label} 包围盒中心" + return None, "" + + def _refresh_measurement_panel(self) -> None: + if not hasattr(self, "measure_text"): + return + lines = ["两点测量"] + if self.measure_point_a is None: + lines.append("A: 尚未设置") + else: + lines.append(f"A: {_format_value(self.measure_point_a)}") + if self.measure_label_a: + lines.append(f" 来源: {self.measure_label_a}") + if self.measure_point_b is None: + lines.append("B: 尚未设置") + else: + lines.append(f"B: {_format_value(self.measure_point_b)}") + if self.measure_label_b: + lines.append(f" 来源: {self.measure_label_b}") + + if self.measure_point_a is not None and self.measure_point_b is not None: + dx = self.measure_point_b[0] - self.measure_point_a[0] + dy = self.measure_point_b[1] - self.measure_point_a[1] + dz = self.measure_point_b[2] - self.measure_point_a[2] + distance = math.sqrt(dx * dx + dy * dy + dz * dz) + lines.extend( + [ + f"距离: {_format_value(distance)}", + f"ΔX/ΔY/ΔZ: {_format_value((dx, dy, dz))}", + ] + ) + else: + lines.append("距离: 需要同时设置 A 和 B") + self.measure_text.setPlainText("\n".join(lines)) + self._update_measure_actor() + + def _update_measure_actor(self) -> None: + self._clear_measure_actor(render=False) + if self.measure_point_a is None or self.measure_point_b is None: + return + if not hasattr(self, "renderer") or not hasattr(self, "render_window"): + return + line = vtk.vtkLineSource() + line.SetPoint1(*self.measure_point_a) + line.SetPoint2(*self.measure_point_b) + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputConnection(line.GetOutputPort()) + actor = vtk.vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetColor(0.1, 0.95, 1.0) + actor.GetProperty().SetLineWidth(4) + actor.GetProperty().SetAmbient(0.9) + actor.PickableOff() + self.measure_actor = actor + self.renderer.AddActor(actor) + self.render_window.Render() + + def _clear_measure_actor(self, render: bool = True) -> None: + actor = getattr(self, "measure_actor", None) + if actor is not None and hasattr(self, "renderer"): + try: + self.renderer.RemoveActor(actor) + except Exception: + pass + self.measure_actor = None + if render and hasattr(self, "render_window"): + self.render_window.Render() + + def _tuple3(self, value: object) -> tuple[float, float, float] | None: + if isinstance(value, (tuple, list)) and len(value) == 3: + try: + return (float(value[0]), float(value[1]), float(value[2])) + except (TypeError, ValueError): + return None + return None + + def _selected_id_text(self) -> str: + if self.selected_kind == "part" and self.selected_part_id is not None: + return f"part {self.selected_part_id}" + if self.selected_kind == "solid" and self.selected_solid_id is not None: + return f"solid {self.selected_solid_id}" + if self.selected_kind in {"face", "feature"} and self.selected_face_id is not None: + return f"face {self.selected_face_id}" + if self.selected_kind == "edge" and self.selected_edge_id is not None: + return f"edge {self.selected_edge_id}" + for kind, key in (("face", "face_id"), ("edge", "edge_id"), ("solid", "solid_id"), ("part", "part_id")): + if key in self.current_info_values: + return f"{kind} {self.current_info_values[key]}" + return "" + diff --git a/step_editor/model.py b/step_editor/model.py new file mode 100644 index 0000000..e881600 --- /dev/null +++ b/step_editor/model.py @@ -0,0 +1,1379 @@ +from __future__ import annotations + +import math +from pathlib import Path +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_Defeaturing, BRepAlgoAPI_Fuse +from OCC.Core.BRepBndLib import brepbndlib +from OCC.Core.BOPAlgo import BOPAlgo_GlueFull +from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform +from OCC.Core.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 +from OCC.Core.Bnd import Bnd_Box +from OCC.Core.GeomAbs import ( + GeomAbs_BSplineCurve, + GeomAbs_BSplineSurface, + GeomAbs_BezierCurve, + GeomAbs_BezierSurface, + GeomAbs_Circle, + GeomAbs_Cone, + GeomAbs_Cylinder, + GeomAbs_Ellipse, + GeomAbs_Hyperbola, + GeomAbs_Line, + GeomAbs_OffsetSurface, + GeomAbs_OtherCurve, + GeomAbs_OtherSurface, + GeomAbs_Parabola, + GeomAbs_Plane, + GeomAbs_Sphere, + GeomAbs_SurfaceOfExtrusion, + GeomAbs_SurfaceOfRevolution, + GeomAbs_Torus, +) +from OCC.Core.GProp import GProp_GProps +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 ( + TopAbs_EDGE, + TopAbs_EXTERNAL, + TopAbs_FACE, + TopAbs_FORWARD, + TopAbs_IN, + TopAbs_INTERNAL, + TopAbs_OUT, + TopAbs_REVERSED, + TopAbs_SOLID, +) +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_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec +from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge + +from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES +from .export import ExportMixin +from .features import FeatureMixin +from .operations import OperationMixin +from .polydata import PolydataMixin +from .transforms import TransformMixin +from .geometry_utils import * # noqa: F403 +from .model_types import PartNode, TopologyStats +from .step_io import ( + _load_plain_step, + _load_with_xcaf, + _parse_product_names, + _prepare_shape_for_step_export, + _write_step, +) + + +class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, PolydataMixin): + def __init__(self, filename: Path, parts: list[PartNode], shape: TopoDS_Shape): + self.filename = filename + self.parts = parts + self.shape = shape + self.faces: list[TopoDS_Shape] = [] + self.face_logical_ids: list[int] = [] + self.face_part_ids: list[int] = [] + 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._same_domain_face_ids_cache: dict[int, list[int]] = {} + self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None + self._same_domain_internal_edge_ids_cache: set[int] | None = None + self._same_domain_duplicate_edge_ids_cache: set[int] | None = None + self._face_polydata_cache: dict[tuple[object, ...], object] = {} + self._edge_polydata_cache: dict[tuple[object, ...], object] = {} + self._polydata_cache_limit = 96 + self._mesh_deflection: float | None = None + self.refresh_topology() + + @classmethod + def load(cls, filename: str | Path) -> "StepModel": + path = Path(filename) + if not path.exists(): + raise FileNotFoundError(path) + + product_names = _parse_product_names(path) + parts, whole_shape = _load_with_xcaf(path, product_names) + if not parts or whole_shape.IsNull(): + whole_shape = _load_plain_step(path) + fallback_name = product_names[0] if product_names else path.stem + parts = [PartNode(1, fallback_name, "part", whole_shape, path=fallback_name)] + return cls(path, parts, whole_shape) + + def display_parts(self) -> list[PartNode]: + leaf_parts = [p for p in self.parts if p.kind == "part" and not p.shape.IsNull()] + if leaf_parts: + return leaf_parts + return [p for p in self.parts if not p.shape.IsNull()] + + def stats(self) -> TopologyStats: + topo = TopologyExplorer(self.shape, ignore_orientation=True) + return TopologyStats( + parts=len(self.display_parts()), + solids=len(list(topo.solids())), + faces=len(list(topo.faces())), + edges=len(list(topo.edges())), + 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) + info: dict[str, object] = { + "surface_area": surface_props.Mass(), + "surface_center": _point_tuple(surface_props.CentreOfMass()), + } + info.update(_shape_bounds_info(self.shape)) + info.update(_shape_volume_info(self.shape)) + return info + + def refresh_topology(self) -> None: + self.shape = _compound_from_shapes([p.shape for p in self.display_parts()]) + self.faces.clear() + self.face_logical_ids.clear() + self.face_part_ids.clear() + 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() + self._same_domain_face_ids_cache.clear() + self._edge_duplicate_key_ids_cache = None + self._same_domain_internal_edge_ids_cache = None + self._same_domain_duplicate_edge_ids_cache = None + self._face_polydata_cache.clear() + self._edge_polydata_cache.clear() + self._mesh_deflection = None + + 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_logical_ids.append(face_id) + self.face_part_ids.append(part.id) + 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_logical_ids.append(face_id) + 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) + + 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) + + def snapshot(self) -> dict[object, object]: + data: dict[object, object] = {part.id: part.shape for part in self.parts} + data[SNAPSHOT_FACE_LOGICAL_IDS_KEY] = tuple(self.face_logical_ids) + return data + + def restore_snapshot(self, snapshot: dict[object, object]) -> None: + for part in self.parts: + if part.id in snapshot: + part.shape = snapshot[part.id] + self.refresh_topology() + logical_ids = snapshot.get(SNAPSHOT_FACE_LOGICAL_IDS_KEY) + if isinstance(logical_ids, (list, tuple)) and len(logical_ids) == len(self.faces): + self.face_logical_ids = [int(item) for item in logical_ids] + self._face_info_cache.clear() + self._same_domain_face_ids_cache.clear() + + 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) + + surf = BRepAdaptor_Surface(face) + surface_type = surf.GetType() + boundary_edges = len(list(TopologyExplorer(face, ignore_orientation=True).edges())) + info: dict[str, object] = { + "kind": "face", + "face_id": face_id, + "topological_face_id": face_id, + "logical_face_id": self.face_logical_id(face_id), + "face_region_logical_id": self.face_region_logical_id(face_id), + "part_id": self.face_part_ids[face_id], + "solid_id": self.face_solid_ids[face_id], + "orientation": _orientation_name(face.Orientation()), + "surface": SURFACE_TYPES.get(surface_type, f"type {surface_type}"), + "area": props.Mass(), + "area_center": _point_tuple(props.CentreOfMass()), + "u_range": (surf.FirstUParameter(), surf.LastUParameter()), + "v_range": (surf.FirstVParameter(), surf.LastVParameter()), + "boundary_edges": boundary_edges, + } + info.update(_shape_bounds_info(face)) + if surface_type == GeomAbs_Plane: + plane = surf.Plane() + direction = plane.Axis().Direction() + push_pull_direction = self._plane_push_pull_direction(face_id, surf) + info["plane_origin"] = _point_tuple(plane.Location()) + info["normal"] = _dir_tuple(direction) + info["oriented_normal"] = _oriented_dir_tuple(direction, face) + info["push_pull_outward_direction"] = push_pull_direction["outward_direction"] + info["push_pull_inward_direction"] = push_pull_direction["inward_direction"] + info["push_pull_plus_side"] = push_pull_direction["plus_side_state"] + info["push_pull_minus_side"] = push_pull_direction["minus_side_state"] + info["push_pull_confidence"] = push_pull_direction["confidence"] + info["push_pull_note"] = push_pull_direction["note"] + elif surface_type == GeomAbs_Cylinder: + cyl = surf.Cylinder() + axis = cyl.Axis() + radius = cyl.Radius() + u_span = abs(surf.LastUParameter() - surf.FirstUParameter()) + swept_area = max(radius * max(u_span, 1e-9), 1e-9) + classification = self._classify_cylindrical_face(face_id, surf, detailed=True) + info["radius"] = cyl.Radius() + info["diameter"] = cyl.Radius() * 2.0 + info["axis_point"] = _point_tuple(axis.Location()) + info["axis"] = _dir_tuple(axis.Direction()) + info["angular_span"] = u_span + info["is_full_cylinder"] = u_span >= math.tau * 0.98 + info["height_estimate"] = props.Mass() / swept_area + info["feature_guess"] = classification["feature_guess"] + info["confidence"] = classification["confidence"] + info["material_toward_axis"] = classification["toward_axis"] + info["material_away_axis"] = classification["away_axis"] + info["material_vote_summary"] = classification["vote_summary"] + info["material_sample_count"] = classification["sample_count"] + 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()) + info["axis"] = _dir_tuple(cone.Axis().Direction()) + info["reference_radius"] = cone.RefRadius() + info["semi_angle"] = cone.SemiAngle() + elif surface_type == GeomAbs_Sphere: + sphere = surf.Sphere() + info["center"] = _point_tuple(sphere.Location()) + info["radius"] = sphere.Radius() + info["diameter"] = sphere.Radius() * 2.0 + elif surface_type == GeomAbs_Torus: + torus = surf.Torus() + info["center"] = _point_tuple(torus.Location()) + info["axis"] = _dir_tuple(torus.Axis().Direction()) + info["major_radius"] = torus.MajorRadius() + info["minor_radius"] = torus.MinorRadius() + 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) + shell_info = self._planar_shell_region_info(face_id, coplanar_face_ids, info) + if len(coplanar_face_ids) > 1: + scope_note = f"已检测到 {len(coplanar_face_ids)} 个共面且相接/重叠的 face,推拉时会作为同一片平面区域处理。" + else: + scope_note = "当前 face 没有检测到可一起推拉的共面相接/重叠邻居。" + edit_actions = "推拉平面" + if shell_info.get("shell_region_status") == "candidate": + edit_actions += ";查看薄壁/壳体厚度估算" + 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": edit_actions, + "feature_mode": "这是从 B-Rep 几何推断出的平面编辑候选,不是 CAD 历史特征。", + **shell_info, + } + ) + return result + + def _planar_shell_region_info( + self, + face_id: int, + coplanar_face_ids: Iterable[int], + info: dict[str, object], + ) -> dict[str, object]: + try: + source_surf = BRepAdaptor_Surface(self.faces[face_id]) + except Exception: + return { + "shell_region_status": "not-detected", + "shell_region_note": "无法读取当前平面,不能估算薄壁/壳体区域。", + } + if source_surf.GetType() != GeomAbs_Plane: + return {} + + plane = source_surf.Plane() + normal = plane.Axis().Direction() + u_dir, v_dir = _plane_basis_dirs(normal) + valid_region_ids = sorted({int(item) for item in coplanar_face_ids if 0 <= int(item) < len(self.faces)}) + if not valid_region_ids: + valid_region_ids = [face_id] + try: + region_shape = _compound_from_shapes(self.faces[item] for item in valid_region_ids) + source_interval = _shape_plane_interval(region_shape, plane.Location(), u_dir, v_dir) + except Exception: + source_interval = _shape_plane_interval(self.faces[face_id], plane.Location(), u_dir, v_dir) + if source_interval is None: + return { + "shell_region_status": "not-detected", + "shell_region_note": "当前平面区域缺少稳定投影范围,不能估算薄壁/壳体厚度。", + } + + def interval_length(interval: tuple[float, float]) -> float: + return max(float(max(interval) - min(interval)), 0.0) + + def overlap_length(left: tuple[float, float], right: tuple[float, float]) -> float: + left_min, left_max = min(left), max(left) + right_min, right_max = min(right), max(right) + return max(min(left_max, right_max) - max(left_min, right_min), 0.0) + + source_area = max(interval_length(source_interval[0]) * interval_length(source_interval[1]), 1e-12) + diagonal = max(_shape_diagonal(self.shape), 1.0) + tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) + source_solid_id = self.face_solid_ids[face_id] + region_id_set = set(valid_region_ids) + best: dict[str, object] | None = None + best_score: tuple[float, float] | None = None + + for candidate_id, face in enumerate(self.faces): + if candidate_id in region_id_set: + continue + if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id: + continue + try: + candidate_surf = BRepAdaptor_Surface(face) + except Exception: + continue + if candidate_surf.GetType() != GeomAbs_Plane: + continue + candidate_plane = candidate_surf.Plane() + normal_dot = _direction_dot(normal, candidate_plane.Axis().Direction()) + if abs(normal_dot) < 0.985: + continue + thickness = abs(_axis_parameter(plane.Location(), normal, candidate_plane.Location())) + if thickness <= tolerance: + continue + interval = _shape_plane_interval(face, plane.Location(), u_dir, v_dir) + if interval is None: + continue + overlap_area = overlap_length(source_interval[0], interval[0]) * overlap_length(source_interval[1], interval[1]) + overlap_ratio = overlap_area / source_area + if overlap_ratio <= 0.02: + continue + score = (thickness, -overlap_ratio) + if best_score is None or score < best_score: + best_score = score + best = { + "shell_opposite_face_id": candidate_id, + "shell_thickness_estimate": thickness, + "shell_overlap_ratio_estimate": overlap_ratio, + "shell_opposite_normal_dot": normal_dot, + } + + if best is None: + return { + "shell_region_status": "not-detected", + "shell_region_note": "未找到与当前平面投影重叠的相对平面;暂不能估算局部壳体/薄壁厚度。", + } + + thickness = float(best["shell_thickness_estimate"]) + overlap_ratio = float(best["shell_overlap_ratio_estimate"]) + thin_ratio = thickness / diagonal + if overlap_ratio >= 0.55 and thin_ratio <= 0.08: + confidence = "high" + elif overlap_ratio >= 0.25 and thin_ratio <= 0.18: + confidence = "medium" + else: + confidence = "low" + kind = "thin-wall-opposite-plane-candidate" if thin_ratio <= 0.18 else "opposite-plane-region-candidate" + return { + "shell_region_kind": kind, + "shell_region_status": "candidate", + "shell_confidence": confidence, + "shell_source_face_ids": tuple(valid_region_ids), + **best, + "shell_note": ( + "通过同一 solid 内投影重叠的相对平面估算薄壁/壳体厚度;" + "这是 B-Rep 几何近似,不等同于原 CAD 壳命令参数。" + ), + } + + def _cylindrical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]: + side_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id] + surf = BRepAdaptor_Surface(self.faces[face_id]) + axis_range = self._cylindrical_axis_range(face_id, surf, side_face_ids) + side_face_set = set(side_face_ids) + boundary_edge_ids = self._region_boundary_edge_ids(side_face_ids) + adjacent_face_ids = sorted(set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - side_face_set) + domain_info = dict(info) + domain_info["v_range"] = (axis_range["v_min"], axis_range["v_max"]) + domain_info["height_estimate"] = axis_range["span"] + end_faces = self._cylindrical_end_face_groups(face_id, adjacent_face_ids, domain_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, domain_info) + fillet_info = self._cylindrical_existing_fillet_info(face_id, adjacent_face_ids, end_face_ids, domain_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( + { + *side_face_ids, + *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": tuple(side_face_ids), + "feature_side_face_ids": tuple(side_face_ids), + "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), + "same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]), + "same_domain_height_estimate": axis_range["span"], + "same_domain_range_source": axis_range["range_source"], + "same_domain_face_ids": tuple(side_face_ids), + "same_domain_face_count": len(side_face_ids), + "same_domain_note": ( + "已把同一实体内同轴、同半径且轴向连续/重叠的圆柱 face 当作同一几何区域。" + if len(side_face_ids) > 1 + else "当前圆柱 face 没有检测到可合并选择的同域圆柱面。" + ), + "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 face_logical_id(self, face_id: int) -> int: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + if face_id < len(self.face_logical_ids): + return int(self.face_logical_ids[face_id]) + return face_id + + def face_region_logical_id(self, face_id: int) -> int: + face_ids = self.connected_same_domain_face_ids(face_id) or [face_id] + return min(self.face_logical_id(item) for item in face_ids if 0 <= item < len(self.faces)) + + def face_ids_for_logical_id(self, logical_id: int) -> list[int]: + logical_id = int(logical_id) + return [face_id for face_id in range(len(self.faces)) if self.face_logical_id(face_id) == logical_id] + + def resolve_face_selection_id(self, face_or_logical_id: int) -> int | None: + logical_matches = self.face_ids_for_logical_id(int(face_or_logical_id)) + if logical_matches: + return logical_matches[0] + if 0 <= int(face_or_logical_id) < len(self.faces): + return int(face_or_logical_id) + return None + + def assign_logical_face_region(self, logical_id: int, face_ids: Iterable[int]) -> None: + valid_face_ids = sorted({int(face_id) for face_id in face_ids if 0 <= int(face_id) < len(self.faces)}) + if not valid_face_ids: + return + for face_id in valid_face_ids: + self.face_logical_ids[face_id] = int(logical_id) + self._face_info_cache.pop(face_id, None) + + 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 [] + source_solid_id = self.face_solid_ids[face_id] + adjacent: set[int] = set() + for edge_id in edge_ids: + if edge_id < 0 or edge_id >= len(self.edges): + continue + for candidate_id in self._edge_adjacent_face_ids(edge_id): + if candidate_id == face_id: + continue + if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id: + continue + adjacent.add(candidate_id) + return sorted(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) + interval_tolerance = max(tolerance * 20.0, _shape_diagonal(self.shape) * 1e-6, 1e-4) + plane = source_surf.Plane() + axis_point = plane.Location() + u_dir, v_dir = _plane_basis_dirs(plane.Axis().Direction()) + candidates: dict[int, tuple[tuple[float, float], tuple[float, float]]] = {} + for candidate_id, face in enumerate(self.faces): + if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id: + continue + candidate_surf = BRepAdaptor_Surface(face) + if not _surfaces_are_coplanar(source_surf, candidate_surf, tolerance): + continue + interval = _shape_plane_interval(face, axis_point, u_dir, v_dir) + if interval is not None: + candidates[candidate_id] = interval + + if face_id in candidates: + visited = {face_id} + queue = [face_id] + while queue: + current_id = queue.pop(0) + current_interval = candidates[current_id] + for candidate_id, candidate_interval in candidates.items(): + if candidate_id in visited: + continue + if _plane_intervals_touch_or_overlap( + current_interval, + candidate_interval, + interval_tolerance, + ): + visited.add(candidate_id) + queue.append(candidate_id) + return sorted(visited) + + 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 connected_same_domain_face_ids(self, face_id: int) -> list[int]: + if face_id < 0 or face_id >= len(self.faces): + return [] + if face_id in self._same_domain_face_ids_cache: + return list(self._same_domain_face_ids_cache[face_id]) + source_surf = BRepAdaptor_Surface(self.faces[face_id]) + surface_type = source_surf.GetType() + if surface_type == GeomAbs_Plane: + face_ids = self._connected_coplanar_planar_face_ids(face_id) + elif surface_type == GeomAbs_Cylinder: + face_ids = self._connected_cocylindrical_face_ids(face_id) + else: + face_ids = [face_id] + face_ids = sorted(set(face_ids or [face_id])) + for item in face_ids: + self._same_domain_face_ids_cache[item] = list(face_ids) + return list(face_ids) + + def _connected_cocylindrical_face_ids(self, face_id: int) -> list[int]: + if face_id < 0 or face_id >= len(self.faces): + return [] + source_solid_id = self.face_solid_ids[face_id] + source_surf = BRepAdaptor_Surface(self.faces[face_id]) + if source_surf.GetType() != GeomAbs_Cylinder: + return [face_id] + cylinder = source_surf.Cylinder() + axis = cylinder.Axis() + axis_point = axis.Location() + axis_dir = axis.Direction() + radius = max(float(cylinder.Radius()), 0.0) + diagonal = _shape_diagonal(self.shape) + tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) + interval_tolerance = max(tolerance * 50.0, diagonal * 1e-5, radius * 1e-4, 1e-3) + source_interval = _shape_axis_interval(self.faces[face_id], axis_point, axis_dir) + + candidates: dict[int, tuple[float, float]] = {} + for candidate_id, face in enumerate(self.faces): + if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id: + continue + candidate_surf = BRepAdaptor_Surface(face) + if not _surfaces_are_cocylindrical(source_surf, candidate_surf, tolerance): + continue + interval = _shape_axis_interval(face, axis_point, axis_dir) + if interval is not None: + candidates[candidate_id] = interval + + if source_interval is not None and face_id in candidates: + visited = {face_id} + queue = [face_id] + while queue: + current_id = queue.pop(0) + current_interval = candidates[current_id] + for candidate_id, candidate_interval in candidates.items(): + if candidate_id in visited: + continue + if _intervals_touch_or_overlap( + current_interval, + candidate_interval, + interval_tolerance, + ): + visited.add(candidate_id) + queue.append(candidate_id) + return sorted(visited) + + 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_cocylindrical(source_surf, candidate_surf, tolerance): + visited.add(adjacent_id) + queue.append(adjacent_id) + return sorted(visited) + + def _cylindrical_axis_range( + self, + face_id: int, + surf: BRepAdaptor_Surface | None = None, + face_ids: Iterable[int] | None = None, + ) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + surf = surf or BRepAdaptor_Surface(self.faces[face_id]) + if surf.GetType() != GeomAbs_Cylinder: + raise ValueError("Selected face is not cylindrical.") + + cyl = surf.Cylinder() + axis = cyl.Axis() + axis_point = axis.Location() + axis_dir = axis.Direction() + source_v_min = min(float(surf.FirstVParameter()), float(surf.LastVParameter())) + source_v_max = max(float(surf.FirstVParameter()), float(surf.LastVParameter())) + + if face_ids is None: + domain_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id] + else: + domain_face_ids = sorted({int(item) for item in face_ids if 0 <= int(item) < len(self.faces)}) + if face_id not in domain_face_ids: + domain_face_ids.append(face_id) + domain_face_ids.sort() + + intervals: list[tuple[float, float]] = [] + tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3) + for item in domain_face_ids: + item_surf = BRepAdaptor_Surface(self.faces[item]) + if item_surf.GetType() != GeomAbs_Cylinder: + continue + if not _surfaces_are_cocylindrical(item_surf, surf, tolerance): + continue + interval = _shape_axis_interval(self.faces[item], axis_point, axis_dir) + if interval is not None: + intervals.append(interval) + + if intervals and len(domain_face_ids) > 1: + v_min = min(interval[0] for interval in intervals) + v_max = max(interval[1] for interval in intervals) + range_source = "same-domain-cylinder-faces" + else: + v_min = source_v_min + v_max = source_v_max + range_source = "selected-face-v-range" + + return { + "axis_point": axis_point, + "axis_direction": axis_dir, + "v_min": v_min, + "v_max": v_max, + "span": max(v_max - v_min, 0.0), + "source_v_min": source_v_min, + "source_v_max": source_v_max, + "same_domain_face_ids": tuple(domain_face_ids), + "same_domain_face_count": len(domain_face_ids), + "range_source": range_source, + } + + 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 _face_region_mapping_specs(self, face_ids: Iterable[int]) -> list[dict[str, object]]: + specs: list[dict[str, object]] = [] + seen_logical_ids: set[int] = set() + diagonal = _shape_diagonal(self.shape) + tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) + for face_id in sorted({int(item) for item in face_ids if 0 <= int(item) < len(self.faces)}): + logical_id = self.face_region_logical_id(face_id) + if logical_id in seen_logical_ids: + continue + seen_logical_ids.add(logical_id) + face = self.faces[face_id] + surf = BRepAdaptor_Surface(face) + if surf.GetType() == GeomAbs_Plane: + plane = surf.Plane() + normal = plane.Axis().Direction() + u_dir, v_dir = _plane_basis_dirs(normal) + interval = _shape_plane_interval(face, plane.Location(), u_dir, v_dir) + if interval is None: + continue + specs.append( + { + "surface": "plane", + "logical_id": logical_id, + "part_id": self.face_part_ids[face_id], + "point": _point_tuple(plane.Location()), + "normal": _dir_tuple(normal), + "u_dir": _dir_tuple(u_dir), + "v_dir": _dir_tuple(v_dir), + "interval": interval, + "tolerance": tolerance, + } + ) + elif surf.GetType() == GeomAbs_Cylinder: + cylinder = surf.Cylinder() + axis = cylinder.Axis() + interval = _shape_axis_interval(face, axis.Location(), axis.Direction()) + if interval is None: + continue + specs.append( + { + "surface": "cylinder", + "logical_id": logical_id, + "part_id": self.face_part_ids[face_id], + "axis_point": _point_tuple(axis.Location()), + "axis_direction": _dir_tuple(axis.Direction()), + "radius": float(cylinder.Radius()), + "interval": interval, + "tolerance": tolerance, + } + ) + return specs + + def _apply_face_region_mapping_specs(self, specs: Iterable[dict[str, object]]) -> None: + for spec in specs: + logical_id = int(spec.get("logical_id", -1)) + if logical_id < 0: + continue + seed_face_ids = self._matching_face_ids_for_region_spec(spec) + if not seed_face_ids: + continue + region_ids: set[int] = set() + for seed_face_id in seed_face_ids: + region_ids.update(self.connected_same_domain_face_ids(seed_face_id) or [seed_face_id]) + self.assign_logical_face_region(logical_id, region_ids) + + def _matching_face_ids_for_region_spec(self, spec: dict[str, object]) -> list[int]: + surface = str(spec.get("surface", "")) + part_id = int(spec.get("part_id", -1)) + tolerance_value = spec.get("tolerance") + if tolerance_value is None: + tolerance_value = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3) + tolerance = float(tolerance_value) + matches: list[int] = [] + for face_id, face in enumerate(self.faces): + if part_id >= 0 and self.face_part_ids[face_id] != part_id: + continue + surf = BRepAdaptor_Surface(face) + if surface == "plane": + if not _surface_matches_plane_spec(surf, spec, tolerance): + continue + plane = surf.Plane() + u_dir = gp_Dir(*spec["u_dir"]) + v_dir = gp_Dir(*spec["v_dir"]) + interval = _shape_plane_interval(face, gp_Pnt(*spec["point"]), u_dir, v_dir) + if interval is not None and _plane_intervals_touch_or_overlap(interval, spec["interval"], max(tolerance * 20.0, 1e-4)): + matches.append(face_id) + elif surface == "cylinder": + if not _surface_matches_cylinder_spec(surf, spec, tolerance): + continue + interval = _shape_axis_interval(face, gp_Pnt(*spec["axis_point"]), gp_Dir(*spec["axis_direction"])) + interval_tolerance = max(tolerance * 50.0, _shape_diagonal(self.shape) * 1e-5, float(spec.get("radius", 0.0)) * 1e-4, 1e-3) + if interval is not None and _intervals_touch_or_overlap(interval, spec["interval"], interval_tolerance): + matches.append(face_id) + return matches + + 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) + + curve = BRepAdaptor_Curve(edge) + curve_type = curve.GetType() + info: dict[str, object] = { + "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(), + "length_center": _point_tuple(props.CentreOfMass()), + "first_parameter": curve.FirstParameter(), + "last_parameter": curve.LastParameter(), + "start_point": _point_tuple(curve.Value(curve.FirstParameter())), + "end_point": _point_tuple(curve.Value(curve.LastParameter())), + } + info.update(_shape_bounds_info(edge)) + if curve_type == GeomAbs_Line: + line = curve.Line() + info["line_origin"] = _point_tuple(line.Location()) + info["direction"] = _dir_tuple(line.Direction()) + if curve_type == GeomAbs_Circle: + circle = curve.Circle() + info["center"] = _point_tuple(circle.Location()) + info["axis"] = _dir_tuple(circle.Axis().Direction()) + info["radius"] = circle.Radius() + info["diameter"] = circle.Radius() * 2.0 + 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): + raise ValueError(f"Unknown solid id {solid_id}") + part_id, solid = self.solids[solid_id] + topo = TopologyExplorer(solid, ignore_orientation=True) + surface_props = GProp_GProps() + brepgprop.SurfaceProperties(solid, surface_props) + info: dict[str, object] = { + "kind": "solid", + "solid_id": solid_id, + "part_id": part_id, + "faces": len(list(topo.faces())), + "edges": len(list(topo.edges())), + "vertices": len(list(topo.vertices())), + "surface_area": surface_props.Mass(), + "surface_center": _point_tuple(surface_props.CentreOfMass()), + } + info.update(_shape_bounds_info(solid)) + info.update(_shape_volume_info(solid)) + return info + + def part_info(self, part_id: int) -> dict[str, object]: + 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) + info: dict[str, object] = { + "kind": part.kind, + "part_id": part.id, + "name": part.name, + "path": part.path, + "parent_id": part.parent_id if part.parent_id is not None else "", + "depth": part.depth, + "solids": len(list(topo.solids())), + "faces": len(list(topo.faces())), + "edges": len(list(topo.edges())), + "vertices": len(list(topo.vertices())), + } + info.update(_shape_bounds_info(part.shape)) + info.update(_shape_volume_info(part.shape)) + return info diff --git a/step_editor/model_types.py b/step_editor/model_types.py new file mode 100644 index 0000000..eacf6e3 --- /dev/null +++ b/step_editor/model_types.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from OCC.Core.TopoDS import TopoDS_Shape + + +@dataclass +class PartNode: + id: int + name: str + kind: str + shape: TopoDS_Shape + parent_id: int | None = None + depth: int = 0 + path: str = "" + + +@dataclass +class TopologyStats: + parts: int + solids: int + faces: int + edges: int + vertices: int diff --git a/step_editor/operations.py b/step_editor/operations.py new file mode 100644 index 0000000..469de18 --- /dev/null +++ b/step_editor/operations.py @@ -0,0 +1,1739 @@ +from __future__ import annotations + +import math +from pathlib import Path +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_Defeaturing, BRepAlgoAPI_Fuse +from OCC.Core.BRepBndLib import brepbndlib +from OCC.Core.BOPAlgo import BOPAlgo_GlueFull +from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_GTransform, 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 +from OCC.Core.Bnd import Bnd_Box +from OCC.Core.GeomAbs import ( + GeomAbs_BSplineCurve, + GeomAbs_BSplineSurface, + GeomAbs_BezierCurve, + GeomAbs_BezierSurface, + GeomAbs_Circle, + GeomAbs_Cone, + GeomAbs_Cylinder, + GeomAbs_Ellipse, + GeomAbs_Hyperbola, + GeomAbs_Line, + GeomAbs_OffsetSurface, + GeomAbs_OtherCurve, + GeomAbs_OtherSurface, + GeomAbs_Parabola, + GeomAbs_Plane, + GeomAbs_Sphere, + GeomAbs_SurfaceOfExtrusion, + GeomAbs_SurfaceOfRevolution, + GeomAbs_Torus, +) +from OCC.Core.GProp import GProp_GProps +from OCC.Core.ShapeFix import ShapeFix_Shape +from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain +from OCC.Core.TopAbs import ( + TopAbs_EDGE, + TopAbs_EXTERNAL, + TopAbs_FACE, + TopAbs_FORWARD, + TopAbs_IN, + TopAbs_INTERNAL, + TopAbs_OUT, + TopAbs_REVERSED, + TopAbs_SOLID, +) +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.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_GTrsf, gp_Pnt, gp_Trsf, gp_Vec, gp_XYZ +from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge + +from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES +from .geometry_utils import * # noqa: F403 +from .step_io import _prepare_shape_for_step_export + + +class OperationMixin: + def repair_model(self) -> str: + before_stats = self.stats() + repaired_parts = 0 + skipped_parts = 0 + for part in self.display_parts(): + if part.shape.IsNull(): + skipped_parts += 1 + continue + repaired = _prepare_shape_for_step_export(part.shape) + if repaired.IsNull(): + skipped_parts += 1 + continue + part.shape = repaired + repaired_parts += 1 + + if repaired_parts == 0: + raise RuntimeError("No valid part shape was available for repair.") + + self.refresh_topology() + after_stats = self.stats() + return ( + "Model repair completed: " + f"parts repaired={repaired_parts}, skipped={skipped_parts}, " + f"solids {before_stats.solids}->{after_stats.solids}, " + f"faces {before_stats.faces}->{after_stats.faces}, " + f"edges {before_stats.edges}->{after_stats.edges}." + ) + + def repair_part(self, part_id: int) -> str: + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + if part.shape.IsNull(): + raise RuntimeError(f"Part {part_id} has a null shape and cannot be repaired.") + before_stats = self.part_topology_stats(part_id) + repaired = _prepare_shape_for_step_export(part.shape) + if repaired.IsNull(): + raise RuntimeError(f"Part {part_id} repair returned a null shape.") + part.shape = repaired + self.refresh_topology() + after_stats = self.part_topology_stats(part_id) + return ( + f"Part repair completed: part {part_id}, " + f"solids {before_stats.solids}->{after_stats.solids}, " + f"faces {before_stats.faces}->{after_stats.faces}, " + f"edges {before_stats.edges}->{after_stats.edges}." + ) + + def repair_solid(self, solid_id: int) -> str: + 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] + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + + before_part_stats = self.part_topology_stats(part_id) + repaired = _prepare_shape_for_step_export(solid) + if repaired.IsNull(): + raise RuntimeError(f"Solid {solid_id} repair returned a null shape.") + + part_solids = _explore(part.shape, TopAbs_SOLID) + if len(part_solids) <= 1: + part.shape = repaired + else: + replaced = False + shapes: list[TopoDS_Shape] = [] + for item in part_solids: + if not replaced and _same_shape(item, solid): + shapes.append(repaired) + 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() + after_part_stats = self.part_topology_stats(part_id) + return ( + f"Solid repair completed: solid {solid_id}, part {part_id}, " + f"part solids {before_part_stats.solids}->{after_part_stats.solids}, " + f"faces {before_part_stats.faces}->{after_part_stats.faces}, " + f"edges {before_part_stats.edges}->{after_part_stats.edges}." + ) + + 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 general_edge_length_plan( + self, + edge_id: int, + target_length: float, + anchor_mode: str = "auto", + ) -> 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)) + target_length = float(target_length) + delta_length = target_length - current_length + curve = str(info.get("curve", "")) + anchor_mode = self._edge_length_anchor_mode(anchor_mode) + base: dict[str, object] = { + "edge_id": edge_id, + "part_id": info.get("part_id"), + "solid_id": info.get("solid_id", -1), + "curve": curve, + "edge_length_anchor_mode": anchor_mode, + "edge_length_anchor_label": self._edge_length_anchor_label(anchor_mode), + "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"), + "length_center": info.get("length_center"), + } + warnings: list[str] = [ + "边长直接修改基于当前 STEP/B-Rep 结果几何,不是 CAD 建模历史里的参数编辑。" + ] + blockers: list[str] = [] + risk = "low" + status = "ready" + + 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%,请确认预览范围。") + + if not blockers and curve == "line" and anchor_mode != "center": + candidate = self._straight_edge_length_end_face_candidate(info, delta_length, anchor_mode=anchor_mode) + if candidate is not None: + push_plan = self.push_pull_plan(int(candidate["end_face_id"]), float(candidate["push_pull_distance"])) + if push_plan["status"] != "blocked": + risk = _max_risk(risk, str(push_plan["risk"])) + push_warnings = str(push_plan.get("warnings", "")) + if push_warnings: + warnings.append(push_warnings) + base.update(candidate) + base.update( + { + "resize_strategy": "move-edge-end-plane-by-push-pull", + "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", ""), + } + ) + else: + warnings.append(f"端面推拉路径不可用,将尝试通用仿射缩放:{push_plan['message']}") + elif anchor_mode in {"keep-start", "keep-end"}: + warnings.append( + f"未找到可用于{self._edge_length_anchor_label(anchor_mode)}的端面推拉路径,将尝试按该基准缩放所属对象。" + ) + elif not blockers and curve == "line" and anchor_mode == "center": + warnings.append("边长基准为固定中心;将使用轴向仿射缩放,让 edge 中心尽量保持不动。") + + if not blockers and "resize_strategy" not in base and curve == "circle": + cylinder_candidate, cylinder_notes = self._circular_edge_length_cylinder_candidate(info, target_length) + if cylinder_candidate is not None: + risk = _max_risk(risk, str(cylinder_candidate["cylinder_resize_risk"])) + status = "caution" if risk != "low" else status + warnings.append("识别到相邻圆柱面;将优先把目标边长换算成圆柱直径做局部编辑。") + cylinder_warnings = str(cylinder_candidate.get("cylinder_resize_warnings", "")) + if cylinder_warnings: + warnings.append(cylinder_warnings) + base.update(cylinder_candidate) + elif cylinder_notes: + warnings.extend(cylinder_notes[:3]) + + if not blockers and "resize_strategy" not in base: + axis = self._edge_length_affine_axis(info, anchor_mode=anchor_mode) + if axis is None: + blockers.append("无法为当前 edge 推断可靠的缩放方向。") + else: + part_id = int(info.get("part_id", -1)) + solid_id = int(info.get("solid_id", -1)) + part = self.part_by_id(part_id) if part_id >= 0 else None + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0 + target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" + scale = target_length / max(current_length, 1e-9) + transform_kind = "axis-affine" if curve == "line" else "uniform" + risk = _max_risk(risk, "medium") + if curve != "line" or abs(scale - 1.0) > 0.25: + risk = _max_risk(risk, "high") + status = "caution" + if transform_kind == "axis-affine": + warnings.append( + "未找到可推拉端面;将沿该 edge 的几何方向对所属 " + f"{target_kind} 做仿射缩放。该 fallback 会影响同一 {target_kind} 上的其他尺寸。" + ) + else: + warnings.append( + "当前 edge 不是直线;将以 edge 中心为基准对所属 " + f"{target_kind} 做均匀缩放。该 fallback 会影响同一 {target_kind} 上的其他尺寸。" + ) + if curve != "line": + warnings.append("非直线 edge 的目标长度通过整体比例缩放实现,执行后请复查周边尺寸。") + base.update( + { + "resize_strategy": "scale-owning-shape-from-edge", + "affine_scale": scale, + "affine_transform_kind": transform_kind, + "affine_axis_point": axis["axis_point"], + "affine_axis_direction": axis["axis_direction"], + "affine_axis_source": axis["axis_source"], + "affine_anchor_source": axis["anchor_source"], + "affine_target_kind": target_kind, + "part_solid_count": part_solid_count, + } + ) + + if blockers: + status = "blocked" + risk = "blocked" + message = " ".join(blockers) + elif risk != "low": + status = "caution" + message = " ".join(warnings) + else: + strategy = str(base.get("resize_strategy", "")) + if strategy == "move-edge-end-plane-by-push-pull": + message = "可以通过端面推拉调整这条直线 edge 长度。" + elif strategy == "resize-adjacent-cylinder-from-circular-edge-length": + message = "可以通过相邻圆柱直径编辑调整这条圆形/圆弧 edge 长度。" + elif strategy == "scale-owning-shape-from-edge": + message = "可以通过几何缩放 fallback 尝试调整该 edge 长度。" + else: + message = "可以尝试直接修改该 edge 长度。" + + base.update( + { + "status": status, + "risk": risk, + "message": message, + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + } + ) + return base + + def _edge_length_anchor_mode(self, anchor_mode: str | None) -> str: + normalized = str(anchor_mode or "auto").strip().lower() + aliases = { + "自动": "auto", + "auto": "auto", + "center": "center", + "centre": "center", + "固定中心": "center", + "keep-center": "center", + "start": "keep-start", + "起点": "keep-start", + "固定起点": "keep-start", + "keep-start": "keep-start", + "end": "keep-end", + "终点": "keep-end", + "固定终点": "keep-end", + "keep-end": "keep-end", + } + return aliases.get(normalized, "auto") + + def _edge_length_anchor_label(self, anchor_mode: str) -> str: + return { + "auto": "自动选择局部端面", + "center": "固定中心", + "keep-start": "固定起点", + "keep-end": "固定终点", + }.get(anchor_mode, "自动选择局部端面") + + def _circular_edge_length_cylinder_candidate( + self, + edge_info: dict[str, object], + target_length: float, + ) -> tuple[dict[str, object] | None, list[str]]: + current_length = float(edge_info.get("length", 0.0)) + current_radius = float(edge_info.get("radius", 0.0)) + if current_length <= 1e-9 or current_radius <= 1e-9: + return None, [] + + length_scale = float(target_length) / current_length + target_radius = current_radius * length_scale + target_diameter = target_radius * 2.0 + if target_diameter <= 1e-9: + return None, [] + + notes: list[str] = [] + candidates: list[tuple[tuple[int, int, int, int], dict[str, object]]] = [] + risk_rank = {"low": 0, "medium": 1, "high": 2, "blocked": 3} + status_rank = {"ready": 0, "caution": 1, "blocked": 2} + adjacent_face_ids = _int_values(edge_info.get("adjacent_face_ids")) + if not adjacent_face_ids: + return None, [] + + for face_id in adjacent_face_ids: + if face_id < 0 or face_id >= len(self.faces): + continue + face_info = self.face_info(face_id) + if face_info.get("surface") != "cylinder" or "diameter" not in face_info: + continue + face_radius = float(face_info.get("radius", 0.0)) + if face_radius <= 1e-9: + continue + radius_tolerance = max(current_radius * 0.06, face_radius * 0.06, _shape_diagonal(self.faces[face_id]) * 1e-5, 1e-4) + if abs(face_radius - current_radius) > radius_tolerance: + continue + + feature_guess = str(face_info.get("feature_guess", "cylindrical face")) + if feature_guess == "round/fillet candidate": + notes.append(f"相邻圆柱 face {face_id} 更像已有圆角,未自动按孔/凸台直径改边长。") + continue + + if feature_guess == "hole/groove candidate": + mode_order = ("hole",) + elif feature_guess == "boss/outer-round candidate": + mode_order = ("boss",) + else: + notes.append(f"相邻圆柱 face {face_id} 尚未明确识别为孔/槽或凸台,未自动按圆柱直径改边长。") + continue + for mode_index, mode in enumerate(mode_order): + mode_label = "圆柱凸台直径" if mode == "boss" else "圆柱孔/槽直径" + try: + cylinder_plan = ( + self.cylindrical_boss_resize_plan(face_id, target_diameter) + if mode == "boss" + else self.cylindrical_resize_plan(face_id, target_diameter) + ) + except Exception as exc: + notes.append(f"相邻圆柱 face {face_id} 的{mode_label}计划生成失败:{exc}") + continue + + plan_status = str(cylinder_plan.get("status", "blocked")) + plan_risk = str(cylinder_plan.get("risk", "blocked")) + if plan_status == "blocked": + notes.append(f"相邻圆柱 face {face_id} 的{mode_label}不可用:{cylinder_plan.get('message', '')}") + continue + + candidate = { + "resize_strategy": "resize-adjacent-cylinder-from-circular-edge-length", + "circular_edge_current_radius": current_radius, + "circular_edge_target_radius": target_radius, + "circular_edge_length_scale": length_scale, + "circular_edge_cylinder_face_id": face_id, + "circular_edge_cylinder_mode": mode, + "circular_edge_cylinder_mode_label": mode_label, + "cylinder_resize_face_id": face_id, + "cylinder_resize_operation": "resize_cylindrical_boss" if mode == "boss" else "resize_cylindrical_hole", + "cylinder_resize_current_diameter": cylinder_plan.get("current_diameter"), + "cylinder_resize_target_diameter": target_diameter, + "cylinder_resize_delta_diameter": cylinder_plan.get("delta_diameter"), + "cylinder_resize_delta_ratio": cylinder_plan.get("diameter_delta_ratio"), + "cylinder_resize_status": plan_status, + "cylinder_resize_risk": plan_risk, + "cylinder_resize_message": cylinder_plan.get("message"), + "cylinder_resize_warnings": cylinder_plan.get("warnings", ""), + "cylinder_resize_blockers": cylinder_plan.get("blockers", ""), + "cylinder_resize_feature_guess": cylinder_plan.get("feature_guess", feature_guess), + "cylinder_resize_confidence": cylinder_plan.get("confidence", face_info.get("confidence", "")), + "cylinder_resize_same_domain_face_ids": cylinder_plan.get("same_domain_face_ids", ()), + "cylinder_resize_same_domain_face_count": cylinder_plan.get("same_domain_face_count", ""), + } + score = ( + status_rank.get(plan_status, 9), + risk_rank.get(plan_risk, 9), + mode_index, + face_id, + ) + candidates.append((score, candidate)) + + if not candidates: + if notes: + notes.insert(0, "圆边没有找到可直接复用的相邻圆柱直径编辑路径,将回退到几何缩放。") + return None, notes + + candidates.sort(key=lambda item: item[0]) + return candidates[0][1], notes + + def _edge_length_affine_axis( + self, + edge_info: dict[str, object], + anchor_mode: str = "auto", + ) -> dict[str, object] | None: + start = _tuple_or_none(edge_info.get("start_point")) + end = _tuple_or_none(edge_info.get("end_point")) + center = _tuple_or_none(edge_info.get("length_center")) + anchor_mode = self._edge_length_anchor_mode(anchor_mode) + if start is not None and end is not None: + direction = _tuple_normalized(_tuple_sub(end, start)) + if direction is not None: + midpoint = ( + (start[0] + end[0]) * 0.5, + (start[1] + end[1]) * 0.5, + (start[2] + end[2]) * 0.5, + ) + if anchor_mode == "keep-start": + axis_point = start + anchor_source = "edge start point" + elif anchor_mode == "keep-end": + axis_point = end + anchor_source = "edge end point" + else: + axis_point = center or midpoint + anchor_source = "edge center" + return { + "axis_point": axis_point, + "axis_direction": direction, + "axis_source": "edge start/end chord", + "anchor_source": anchor_source, + } + bbox_min = _tuple_or_none(edge_info.get("bbox_min")) + bbox_max = _tuple_or_none(edge_info.get("bbox_max")) + if bbox_min is not None and bbox_max is not None: + sizes = [abs(bbox_max[index] - bbox_min[index]) for index in range(3)] + axis_index = max(range(3), key=lambda index: sizes[index]) + if sizes[axis_index] > 1e-9: + direction = [0.0, 0.0, 0.0] + direction[axis_index] = 1.0 + return { + "axis_point": center or ( + (bbox_min[0] + bbox_max[0]) * 0.5, + (bbox_min[1] + bbox_max[1]) * 0.5, + (bbox_min[2] + bbox_max[2]) * 0.5, + ), + "axis_direction": tuple(direction), + "axis_source": "edge bounding-box longest axis", + "anchor_source": "edge bounding-box center", + } + return None + + 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, + anchor_mode: str = "auto", + ) -> 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]]] = [] + anchor_mode = self._edge_length_anchor_mode(anchor_mode) + if anchor_mode == "keep-start": + endpoint_specs = [("终点端", end, _tuple_scale(axis, delta_length))] + elif anchor_mode == "keep-end": + endpoint_specs = [("起点端", start, _tuple_scale(axis, -delta_length))] + elif anchor_mode == "center": + endpoint_specs = [] + else: + 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), + } + ] + + removal = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape() + replacement = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape() + BRepMesh_IncrementalMesh(removal, deflection) + BRepMesh_IncrementalMesh(replacement, deflection) + return [ + { + "role": "cutter", + "label": "凸台缩小移除范围预览", + "polydata": _shape_faces_polydata(removal), + }, + { + "role": "fill", + "label": "凸台缩小重建目标预览", + "polydata": _shape_faces_polydata(replacement), + }, + ] + + 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, + bottom_face_id: int | None = None, + deflection: float = 0.8, + ) -> list[dict[str, object]]: + plan = self.cylindrical_depth_plan( + face_id, + target_depth, + bottom_face_id=bottom_face_id, + ) + 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.") + + outward = plan["outward_direction"] + vec = gp_Vec( + float(outward[0]) * distance, + float(outward[1]) * distance, + float(outward[2]) * distance, + ) + 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, + anchor_mode: str = "auto", + ): + plan = self.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + if plan.get("resize_strategy") == "move-edge-end-plane-by-push-pull": + return self.push_pull_preview_polydata(int(plan["end_face_id"]), float(plan["push_pull_distance"]), deflection) + if plan.get("resize_strategy") == "resize-adjacent-cylinder-from-circular-edge-length": + face_id = int(plan["cylinder_resize_face_id"]) + target_diameter = float(plan["cylinder_resize_target_diameter"]) + if plan.get("circular_edge_cylinder_mode") == "boss": + return self.cylindrical_boss_resize_preview_polydata(face_id, target_diameter, deflection) + return self.cylindrical_resize_preview_polydata(face_id, target_diameter, deflection) + preview_shape = self._edge_length_affine_preview_shape(plan) + BRepMesh_IncrementalMesh(preview_shape, deflection) + return _shape_faces_polydata(preview_shape) + + def resize_straight_edge_length(self, edge_id: int, target_length: float, anchor_mode: str = "auto") -> str: + return self.resize_general_edge_length(edge_id, target_length, anchor_mode=anchor_mode) + + def resize_general_edge_length(self, edge_id: int, target_length: float, anchor_mode: str = "auto") -> str: + plan = self.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + if plan.get("resize_strategy") == "move-edge-end-plane-by-push-pull": + push_result = self.push_pull_face(int(plan["end_face_id"]), float(plan["push_pull_distance"])) + return ( + "Edge length resize completed by end-face push/pull: " + 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"anchor={plan.get('edge_length_anchor_label')}, " + f"risk={plan['risk']}. {push_result}" + ) + + if plan.get("resize_strategy") == "resize-adjacent-cylinder-from-circular-edge-length": + face_id = int(plan["cylinder_resize_face_id"]) + target_diameter = float(plan["cylinder_resize_target_diameter"]) + if plan.get("circular_edge_cylinder_mode") == "boss": + resize_result = self.resize_cylindrical_boss(face_id, target_diameter) + else: + resize_result = self.resize_cylindrical_hole(face_id, target_diameter) + return ( + "Edge length resize completed by adjacent cylinder diameter edit: " + 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"cylinder_face={face_id}, " + f"target_diameter={target_diameter:g}, " + f"mode={plan.get('circular_edge_cylinder_mode_label')}, " + f"risk={plan['risk']}. {resize_result}" + ) + + self._apply_edge_length_affine_transform(plan) + return ( + "Edge length resize completed by geometric scale fallback: " + 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"scale={float(plan['affine_scale']):g}, " + f"axis_source={plan.get('affine_axis_source')}, " + f"anchor={plan.get('edge_length_anchor_label')}, " + f"target={plan.get('affine_target_kind')}, " + f"risk={plan['risk']}." + ) + + def _edge_length_affine_preview_shape(self, plan: dict[str, object]) -> TopoDS_Shape: + target_kind, source_shape, _part, _solid = self._edge_length_affine_target(plan) + return self._affine_scaled_shape_along_edge(source_shape, plan) + + def _apply_edge_length_affine_transform(self, plan: dict[str, object]) -> None: + target_kind, source_shape, part, solid = self._edge_length_affine_target(plan) + transformed = self._affine_scaled_shape_along_edge(source_shape, plan) + _ensure_valid_shape(transformed) + if target_kind == "part": + part.shape = transformed + else: + part_solids = _explore(part.shape, TopAbs_SOLID) + replaced = False + shapes: list[TopoDS_Shape] = [] + for item in part_solids: + if not replaced and _same_shape(item, solid): + shapes.append(transformed) + replaced = True + else: + shapes.append(item) + if not replaced: + raise RuntimeError(f"Could not locate solid {plan.get('solid_id')} inside part {plan.get('part_id')}.") + part.shape = _compound_from_shapes(shapes) + _ensure_valid_shape(part.shape) + self.refresh_topology() + + def _edge_length_affine_target( + self, + plan: dict[str, object], + ) -> tuple[str, TopoDS_Shape, object, TopoDS_Shape | None]: + part_id = int(plan.get("part_id", -1)) + solid_id = int(plan.get("solid_id", -1)) + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + target_kind = str(plan.get("affine_target_kind", "part")) + if target_kind == "solid" and 0 <= solid_id < len(self.solids): + return target_kind, self.solids[solid_id][1], part, self.solids[solid_id][1] + return "part", part.shape, part, None + + def _affine_scaled_shape_along_edge(self, shape: TopoDS_Shape, plan: dict[str, object]) -> TopoDS_Shape: + axis_point = _tuple_or_none(plan.get("affine_axis_point")) + axis_direction = _tuple_normalized(_tuple_or_none(plan.get("affine_axis_direction"))) + scale = float(plan.get("affine_scale", 1.0)) + transform_kind = str(plan.get("affine_transform_kind", "axis-affine")) + if axis_point is None: + raise ValueError("Missing affine edge-length axis.") + if transform_kind == "uniform": + transform = gp_Trsf() + transform.SetScale(gp_Pnt(*axis_point), scale) + builder = BRepBuilderAPI_Transform(shape, transform, True) + builder.Build() + if not builder.IsDone(): + raise RuntimeError("Uniform edge-length scale transform failed.") + result = builder.Shape() + if result.IsNull(): + raise RuntimeError("Uniform edge-length scale transform produced an empty shape.") + return result + + if axis_direction is None: + raise ValueError("Missing affine edge-length axis direction.") + ux, uy, uz = axis_direction + matrix = [ + [1.0 + (scale - 1.0) * ux * ux, (scale - 1.0) * ux * uy, (scale - 1.0) * ux * uz], + [(scale - 1.0) * uy * ux, 1.0 + (scale - 1.0) * uy * uy, (scale - 1.0) * uy * uz], + [(scale - 1.0) * uz * ux, (scale - 1.0) * uz * uy, 1.0 + (scale - 1.0) * uz * uz], + ] + cx, cy, cz = axis_point + moved_center = ( + matrix[0][0] * cx + matrix[0][1] * cy + matrix[0][2] * cz, + matrix[1][0] * cx + matrix[1][1] * cy + matrix[1][2] * cz, + matrix[2][0] * cx + matrix[2][1] * cy + matrix[2][2] * cz, + ) + translation = (cx - moved_center[0], cy - moved_center[1], cz - moved_center[2]) + transform = gp_GTrsf() + for row in range(3): + for column in range(3): + transform.SetValue(row + 1, column + 1, matrix[row][column]) + transform.SetTranslationPart(gp_XYZ(*translation)) + builder = BRepBuilderAPI_GTransform(shape, transform, True) + builder.Build() + if not builder.IsDone(): + raise RuntimeError("Affine edge-length transform failed.") + result = builder.Shape() + if result.IsNull(): + raise RuntimeError("Affine edge-length transform produced an empty shape.") + return 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.") + + 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) + boundary_edge_ids = self._region_boundary_edge_ids(scope_face_ids) + side_face_ids = sorted( + set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - set(scope_face_ids) + ) + side_region_mapping_specs = self._face_region_mapping_specs(side_face_ids) + + cap_extension = self._cylindrical_cap_extension_plan(face_id, distance, outward) + if cap_extension is not None: + op = BRepAlgoAPI_Fuse(part.shape, cap_extension["tool_shape"]) + result = _finalize_boolean_result(op, "cylindrical cap push/pull") + result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance) + part.shape = result + self.refresh_topology() + self._apply_face_region_mapping_specs(side_region_mapping_specs) + return ( + "Planar face push/pull completed: cylindrical cap extension, " + f"semantic_distance={distance:g}, " + f"radius={float(cap_extension['radius']):g}, " + f"old_height={float(cap_extension['old_height']):g}, " + f"new_height={float(cap_extension['new_height']):g}, " + f"side_faces={cap_extension['side_face_ids']}, " + f"outward_direction={_format_tuple(outward)}, " + f"direction_confidence={plan['direction_confidence']}, " + f"risk={plan['risk']}." + ) + + 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) + result = _finalize_boolean_result(op, "push/pull") + result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance) + part.shape = result + self.refresh_topology() + self._apply_face_region_mapping_specs(side_region_mapping_specs) + 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={plan['direction_confidence']}, " + f"risk={plan['risk']}." + ) + + def _cylindrical_cap_extension_plan( + self, + face_id: int, + distance: float, + outward: tuple[float, float, float], + ) -> dict[str, object] | None: + if distance <= 0: + return None + if face_id < 0 or face_id >= len(self.faces): + return None + + cap_center = _surface_center(self.faces[face_id]) + boundary_edge_ids = self._face_boundary_edge_ids(face_id) + adjacent_face_ids = self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id) + if not adjacent_face_ids: + return None + + diagonal = _shape_diagonal(self.shape) + tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) + outward_dir = gp_Dir(float(outward[0]), float(outward[1]), float(outward[2])) + best: tuple[float, dict[str, object]] | None = None + + for adjacent_id in adjacent_face_ids: + side_surf = BRepAdaptor_Surface(self.faces[adjacent_id]) + if side_surf.GetType() != GeomAbs_Cylinder: + continue + angular_span = abs(side_surf.LastUParameter() - side_surf.FirstUParameter()) + if angular_span < math.tau * 0.92: + continue + + cylinder = side_surf.Cylinder() + radius = float(cylinder.Radius()) + axis = cylinder.Axis() + axis_point = axis.Location() + axis_dir = axis.Direction() + axis_alignment = _direction_dot(outward_dir, axis_dir) + if abs(axis_alignment) < 0.92: + continue + if _point_axis_distance(axis_point, axis_dir, cap_center) > max(radius * 0.08, tolerance * 10.0): + continue + + axis_range = self._cylindrical_axis_range(adjacent_id, side_surf) + v_min = float(axis_range["v_min"]) + v_max = float(axis_range["v_max"]) + old_height = max(v_max - v_min, 1e-9) + cap_parameter = _axis_parameter(axis_point, axis_dir, cap_center) + start_distance = abs(cap_parameter - v_min) + end_distance = abs(cap_parameter - v_max) + end_tolerance = max(old_height * 0.05, radius * 0.2, tolerance * 10.0, 0.05) + + if axis_alignment > 0 and end_distance <= end_tolerance: + new_min = v_min + new_max = v_max + distance + end_score = end_distance + elif axis_alignment < 0 and start_distance <= end_tolerance: + new_min = v_min - distance + new_max = v_max + end_score = start_distance + else: + continue + + height = max(new_max - new_min, 1e-6) + start = _point_on_axis(axis_point, axis_dir, new_min) + tool_shape = BRepPrimAPI_MakeCylinder(gp_Ax2(start, gp_Dir(axis_dir.X(), axis_dir.Y(), axis_dir.Z())), radius, height).Shape() + score = end_score + _point_axis_distance(axis_point, axis_dir, cap_center) + candidate = { + "tool_shape": tool_shape, + "side_face_ids": axis_range["same_domain_face_ids"], + "radius": radius, + "old_height": old_height, + "new_height": height, + "axis_alignment": axis_alignment, + "cap_axis_parameter": cap_parameter, + "start_parameter": new_min, + "end_parameter": new_max, + } + if best is None or score < best[0]: + best = (score, candidate) + + return best[1] if best is not None else None + + 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: + return self.resize_cylindrical_hole(face_id, new_diameter) + + def resize_cylindrical_hole(self, face_id: int, new_diameter: float) -> str: + 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("Hole resize currently supports cylindrical faces only.") + + cyl = surf.Cylinder() + old_radius = cyl.Radius() + new_radius = new_diameter / 2.0 + if new_radius <= 0: + raise ValueError("Target diameter must be greater than 0.") + + 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}") + + direction = cyl.Axis().Direction() + source_shape = part.shape + if plan["resize_mode"] == "shrink": + 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) + 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) + 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" + return ( + f"Cylindrical resize completed: diameter {old_radius * 2.0:g} -> {new_diameter:g}, " + f"mode={plan['resize_mode']}, action={action}, " + f"risk={plan['risk']}, feature={plan['feature_guess']}, " + f"cutter={plan['cutter_strategy']}, height={float(plan['cutter_height']):g}." + ) + + 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: + removal = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape() + replacement = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape() + remove_op = BRepAlgoAPI_Cut(part.shape, removal) + removed = _finalize_boolean_result(remove_op, "cylindrical boss shrink remove envelope") + if _topology_shape_count(removed, TopAbs_FACE) == 0: + exact_start = gp_Pnt(*plan["boss_tool_exact_start_point"]) + exact_axis = gp_Ax2(exact_start, direction) + exact_replacement = BRepPrimAPI_MakeCylinder( + exact_axis, + float(plan["boss_tool_inner_radius"]), + float(plan["boss_tool_exact_height"]), + ).Shape() + result = _ensure_valid_or_repaired_shape(exact_replacement, "cylindrical boss shrink replacement") + else: + fuse_op = BRepAlgoAPI_Fuse(removed, replacement) + result = _finalize_boolean_result(fuse_op, "cylindrical boss shrink rebuild") + action = "shrunk by removing old envelope and fusing target cylinder" + + 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, + bottom_face_id: int | None = None, + ) -> str: + plan = self.cylindrical_depth_plan( + face_id, + target_depth, + bottom_face_id=bottom_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}") + + 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}." + ) + diff --git a/step_editor/polydata.py b/step_editor/polydata.py new file mode 100644 index 0000000..15f4688 --- /dev/null +++ b/step_editor/polydata.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import math +from pathlib import Path +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_Defeaturing, BRepAlgoAPI_Fuse +from OCC.Core.BRepBndLib import brepbndlib +from OCC.Core.BOPAlgo import BOPAlgo_GlueFull +from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform +from OCC.Core.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 +from OCC.Core.Bnd import Bnd_Box +from OCC.Core.GeomAbs import ( + GeomAbs_BSplineCurve, + GeomAbs_BSplineSurface, + GeomAbs_BezierCurve, + GeomAbs_BezierSurface, + GeomAbs_Circle, + GeomAbs_Cone, + GeomAbs_Cylinder, + GeomAbs_Ellipse, + GeomAbs_Hyperbola, + GeomAbs_Line, + GeomAbs_OffsetSurface, + GeomAbs_OtherCurve, + GeomAbs_OtherSurface, + GeomAbs_Parabola, + GeomAbs_Plane, + GeomAbs_Sphere, + GeomAbs_SurfaceOfExtrusion, + GeomAbs_SurfaceOfRevolution, + GeomAbs_Torus, +) +from OCC.Core.GProp import GProp_GProps +from OCC.Core.ShapeFix import ShapeFix_Shape +from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain +from OCC.Core.TopAbs import ( + TopAbs_EDGE, + TopAbs_EXTERNAL, + TopAbs_FACE, + TopAbs_FORWARD, + TopAbs_IN, + TopAbs_INTERNAL, + TopAbs_OUT, + TopAbs_REVERSED, + TopAbs_SOLID, +) +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.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec +from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge + +from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES +from .geometry_utils import * # noqa: F403 + + +def _polydata_id_key(values: Iterable[int] | None) -> tuple[int, ...] | None: + if values is None: + return None + return tuple(sorted({int(value) for value in values})) + + +class PolydataMixin: + def build_face_polydata( + self, + face_ids: Iterable[int] | None = None, + part_ids: Iterable[int] | None = None, + deflection: float = 0.8, + ): + import vtk + + face_key = _polydata_id_key(face_ids) + part_key = _polydata_id_key(part_ids) + cache_key = ("faces", face_key, part_key, float(deflection)) + cached = self._polydata_cache_get("_face_polydata_cache", cache_key) + if cached is not None: + return cached + + selected_faces = set(face_key) if face_key is not None else None + selected_parts = set(part_key) if part_key is not None else None + self._ensure_mesh(deflection) + + points = vtk.vtkPoints() + polys = vtk.vtkCellArray() + face_arr = vtk.vtkIntArray() + face_arr.SetName("face_id") + part_arr = vtk.vtkIntArray() + part_arr.SetName("part_id") + solid_arr = vtk.vtkIntArray() + solid_arr.SetName("solid_id") + + for face_id, face in enumerate(self.faces): + part_id = self.face_part_ids[face_id] + if selected_faces is not None and face_id not in selected_faces: + continue + if selected_parts is not None and part_id not in selected_parts: + continue + + loc = TopLoc_Location() + tri = BRep_Tool.Triangulation(topods.Face(face), loc) + if tri is None: + continue + transform = loc.Transformation() + node_offset = points.GetNumberOfPoints() + for node_index in range(1, tri.NbNodes() + 1): + pnt = tri.Node(node_index).Transformed(transform) + points.InsertNextPoint(pnt.X(), pnt.Y(), pnt.Z()) + + reversed_face = face.Orientation() == TopAbs_REVERSED + for tri_index in range(1, tri.NbTriangles() + 1): + n1, n2, n3 = tri.Triangle(tri_index).Get() + if reversed_face: + n2, n3 = n3, n2 + vtk_tri = vtk.vtkTriangle() + vtk_tri.GetPointIds().SetId(0, node_offset + n1 - 1) + vtk_tri.GetPointIds().SetId(1, node_offset + n2 - 1) + vtk_tri.GetPointIds().SetId(2, node_offset + n3 - 1) + polys.InsertNextCell(vtk_tri) + face_arr.InsertNextValue(face_id) + part_arr.InsertNextValue(part_id) + solid_arr.InsertNextValue(self.face_solid_ids[face_id]) + + poly = vtk.vtkPolyData() + poly.SetPoints(points) + poly.SetPolys(polys) + poly.GetCellData().AddArray(face_arr) + poly.GetCellData().AddArray(part_arr) + poly.GetCellData().AddArray(solid_arr) + return self._polydata_cache_remember("_face_polydata_cache", cache_key, poly) + + def _ensure_mesh(self, deflection: float) -> None: + requested = max(float(deflection), 1e-9) + if self._mesh_deflection is None or requested < self._mesh_deflection * 0.999: + BRepMesh_IncrementalMesh(self.shape, requested) + self._mesh_deflection = requested + + def build_snapshot_polydata(self, snapshot: dict[object, object], deflection: float = 0.8): + shape = _compound_from_shapes(value for value in snapshot.values() if isinstance(value, TopoDS_Shape)) + BRepMesh_IncrementalMesh(shape, deflection) + return _shape_faces_polydata(shape) + + def build_edge_polydata( + self, + edge_ids: Iterable[int] | None = None, + part_ids: Iterable[int] | None = None, + deflection: float = 0.8, + show_same_domain_internal_edges: bool = False, + ): + import vtk + + edge_key = _polydata_id_key(edge_ids) + part_key = _polydata_id_key(part_ids) + cache_key = ("edges", edge_key, part_key, float(deflection), bool(show_same_domain_internal_edges)) + cached = self._polydata_cache_get("_edge_polydata_cache", cache_key) + if cached is not None: + return cached + + selected_edges = set(edge_key) if edge_key is not None else None + selected_parts = set(part_key) if part_key is not None else None + points = vtk.vtkPoints() + lines = vtk.vtkCellArray() + edge_arr = vtk.vtkIntArray() + edge_arr.SetName("edge_id") + part_arr = vtk.vtkIntArray() + part_arr.SetName("part_id") + hidden_edge_ids = ( + set() + if selected_edges is not None or show_same_domain_internal_edges + else self._same_domain_internal_edge_ids() + ) + + 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 + if edge_id in hidden_edge_ids: + continue + samples = discretize_edge(edge, deflection) + if len(samples) < 2: + continue + polyline = vtk.vtkPolyLine() + polyline.GetPointIds().SetNumberOfIds(len(samples)) + for i, coords in enumerate(samples): + point_id = points.InsertNextPoint(float(coords[0]), float(coords[1]), float(coords[2])) + polyline.GetPointIds().SetId(i, point_id) + lines.InsertNextCell(polyline) + edge_arr.InsertNextValue(edge_id) + part_arr.InsertNextValue(part_id) + + poly = vtk.vtkPolyData() + poly.SetPoints(points) + poly.SetLines(lines) + poly.GetCellData().AddArray(edge_arr) + poly.GetCellData().AddArray(part_arr) + return self._polydata_cache_remember("_edge_polydata_cache", cache_key, poly) + + def _polydata_cache_get(self, cache_name: str, key: tuple[object, ...]): + cache = getattr(self, cache_name, None) + if not isinstance(cache, dict): + return None + return cache.get(key) + + def _polydata_cache_remember(self, cache_name: str, key: tuple[object, ...], polydata): + cache = getattr(self, cache_name, None) + if not isinstance(cache, dict): + return polydata + limit = max(int(getattr(self, "_polydata_cache_limit", 96)), 1) + if len(cache) >= limit and key not in cache: + try: + cache.pop(next(iter(cache))) + except StopIteration: + pass + cache[key] = polydata + return polydata + + def _is_same_domain_internal_edge(self, edge_id: int) -> bool: + return edge_id in self._same_domain_internal_edge_ids() + + def _same_domain_internal_edge_ids(self) -> set[int]: + if self._same_domain_internal_edge_ids_cache is not None: + return self._same_domain_internal_edge_ids_cache + + hidden_edge_ids: set[int] = set() + tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3) + for edge_id in range(len(self.edges)): + if self._is_topological_same_domain_internal_edge(edge_id, tolerance): + hidden_edge_ids.add(edge_id) + hidden_edge_ids.update(self._same_domain_duplicate_edge_ids(tolerance)) + self._same_domain_internal_edge_ids_cache = hidden_edge_ids + return self._same_domain_internal_edge_ids_cache + + def _is_topological_same_domain_internal_edge(self, edge_id: int, tolerance: float) -> bool: + if edge_id < 0 or edge_id >= len(self.edges): + return False + face_ids = self._edge_adjacent_face_ids(edge_id) + if len(face_ids) == 2: + left_id, right_id = face_ids + if self.face_solid_ids[left_id] == self.face_solid_ids[right_id]: + left = BRepAdaptor_Surface(self.faces[left_id]) + right = BRepAdaptor_Surface(self.faces[right_id]) + if _surfaces_are_coplanar(left, right, tolerance): + return True + if _surfaces_are_cocylindrical(left, right, tolerance): + return True + + return False + + def _same_domain_duplicate_edge_ids(self, tolerance: float) -> set[int]: + if self._same_domain_duplicate_edge_ids_cache is not None: + return self._same_domain_duplicate_edge_ids_cache + + duplicate_edge_ids: set[int] = set() + for bucket_edge_ids in self._edge_duplicate_key_ids(tolerance).values(): + if len(bucket_edge_ids) <= 1: + continue + for index, left_edge_id in enumerate(bucket_edge_ids): + left_face_ids = self._edge_adjacent_face_ids(left_edge_id) + if not left_face_ids: + continue + for right_edge_id in bucket_edge_ids[index + 1 :]: + right_face_ids = self._edge_adjacent_face_ids(right_edge_id) + if not right_face_ids: + continue + if self._edge_face_sets_share_same_domain(left_face_ids, right_face_ids): + duplicate_edge_ids.add(left_edge_id) + duplicate_edge_ids.add(right_edge_id) + + self._same_domain_duplicate_edge_ids_cache = set(duplicate_edge_ids) + return self._same_domain_duplicate_edge_ids_cache + + def _edge_duplicate_key_ids(self, tolerance: float) -> dict[tuple[object, ...], list[int]]: + if self._edge_duplicate_key_ids_cache is not None: + return self._edge_duplicate_key_ids_cache + key_tolerance = max(tolerance * 10.0, _shape_diagonal(self.shape) * 1e-7, 1e-6) + buckets: dict[tuple[object, ...], list[int]] = {} + for edge_id, edge in enumerate(self.edges): + key = _edge_duplicate_key(edge, key_tolerance) + if key is None: + continue + buckets.setdefault(key, []).append(edge_id) + self._edge_duplicate_key_ids_cache = {key: list(value) for key, value in buckets.items() if len(value) > 1} + return self._edge_duplicate_key_ids_cache + + def _edge_face_sets_share_same_domain(self, left_face_ids: list[int], right_face_ids: list[int]) -> bool: + tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3) + for left_face_id in left_face_ids: + if left_face_id < 0 or left_face_id >= len(self.faces): + continue + left_solid_id = self.face_solid_ids[left_face_id] + left_surface = BRepAdaptor_Surface(self.faces[left_face_id]) + for right_face_id in right_face_ids: + if right_face_id == left_face_id or right_face_id < 0 or right_face_id >= len(self.faces): + continue + if left_solid_id != self.face_solid_ids[right_face_id]: + continue + right_surface = BRepAdaptor_Surface(self.faces[right_face_id]) + if _surfaces_are_coplanar(left_surface, right_surface, tolerance): + return True + if _surfaces_are_cocylindrical(left_surface, right_surface, tolerance): + return True + return False + diff --git a/step_editor/records.py b/step_editor/records.py new file mode 100644 index 0000000..1dc6941 --- /dev/null +++ b/step_editor/records.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class OperationRecord: + summary: str + detail: str + target_kind: str | None = None + target_id: int | None = None + target_logical_id: int | None = None + pick_position: tuple[float, float, float] | None = None + before_snapshot: dict[int, object] | None = None + after_snapshot: dict[int, object] | None = None + diff_stats: dict[str, object] | None = None diff --git a/step_editor/step_io.py b/step_editor/step_io.py new file mode 100644 index 0000000..908b26c --- /dev/null +++ b/step_editor/step_io.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform +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.TDF import TDF_Label, TDF_LabelSequence +from OCC.Core.TDocStd import TDocStd_Document +from OCC.Core.TopLoc import TopLoc_Location +from OCC.Core.TopoDS import TopoDS_Shape +from OCC.Core.XCAFDoc import XCAFDoc_DocumentTool + +from .geometry_utils import _compound_from_shapes, _repair_shape, _unify_same_domain_shape +from .model_types import PartNode + + +def _load_with_xcaf(path: Path, product_names: list[str]) -> tuple[list[PartNode], TopoDS_Shape]: + doc = TDocStd_Document("pythonocc-step-document") + shape_tool = XCAFDoc_DocumentTool.ShapeTool(doc.Main()) + + reader = STEPCAFControl_Reader() + reader.SetColorMode(True) + reader.SetLayerMode(True) + reader.SetNameMode(True) + reader.SetMatMode(True) + reader.SetGDTMode(True) + status = reader.ReadFile(str(path)) + if status != IFSelect_RetDone: + raise ValueError(f"Could not read STEP file: {path}") + if not reader.Transfer(doc): + raise ValueError(f"Could not transfer STEP document: {path}") + + parts: list[PartNode] = [] + free_shapes = TDF_LabelSequence() + shape_tool.GetFreeShapes(free_shapes) + + def next_name(label: TDF_Label, index: int) -> str: + label_name = str(label.GetLabelName()).strip() + if label_name: + return label_name + if index - 1 < len(product_names): + return product_names[index - 1] + return f"Part {index}" + + def add_node( + name: str, + kind: str, + shape: TopoDS_Shape, + parent_id: int | None, + depth: int, + path_text: str, + ) -> PartNode: + node = PartNode(len(parts) + 1, name, kind, shape, parent_id, depth, path_text) + parts.append(node) + return node + + def transformed_shape(label: TDF_Label, locations: list[TopLoc_Location]) -> TopoDS_Shape: + shape = shape_tool.GetShape(label) + if shape.IsNull() or not locations: + return shape + location = TopLoc_Location() + for loc in locations: + location = location.Multiplied(loc) + return BRepBuilderAPI_Transform(shape, location.Transformation()).Shape() + + def walk(label: TDF_Label, parent_id: int | None, depth: int, locations: list[TopLoc_Location], path_names: list[str]): + name = next_name(label, len(parts) + 1) + label_path = " / ".join(path_names + [name]) + + if shape_tool.IsAssembly(label): + node = add_node(name, "assembly", transformed_shape(label, locations), parent_id, depth, label_path) + components = TDF_LabelSequence() + shape_tool.GetComponents(label, components) + for i in range(1, components.Length() + 1): + component = components.Value(i) + if shape_tool.IsReference(component): + referred = TDF_Label() + shape_tool.GetReferredShape(component, referred) + loc = shape_tool.GetLocation(component) + walk(referred, node.id, depth + 1, locations + [loc], path_names + [name]) + else: + walk(component, node.id, depth + 1, locations, path_names + [name]) + return + + if shape_tool.IsSimpleShape(label) or shape_tool.IsShape(label): + add_node(name, "part", transformed_shape(label, locations), parent_id, depth, label_path) + + for i in range(1, free_shapes.Length() + 1): + walk(free_shapes.Value(i), None, 0, [], []) + + display_shapes = [p.shape for p in parts if p.kind == "part" and not p.shape.IsNull()] + if not display_shapes: + display_shapes = [p.shape for p in parts if not p.shape.IsNull()] + return parts, _compound_from_shapes(display_shapes) + + +def _load_plain_step(path: Path) -> TopoDS_Shape: + reader = STEPControl_Reader() + status = reader.ReadFile(str(path)) + if status != IFSelect_RetDone: + raise ValueError(f"Could not read STEP file: {path}") + if not reader.TransferRoots(): + raise ValueError(f"Could not transfer STEP roots: {path}") + return reader.Shape() + + +def _parse_product_names(path: Path) -> list[str]: + text = path.read_text(errors="ignore") + names = re.findall(r"PRODUCT\('((?:''|[^'])*)'", text) + return [name.replace("''", "'") for name in names if name.strip()] + + +def _write_step(shape: TopoDS_Shape, filename: Path) -> None: + if shape.IsNull(): + raise ValueError("Cannot export a null shape.") + filename.parent.mkdir(parents=True, exist_ok=True) + Interface_Static.SetCVal("write.step.schema", "AP214IS") + writer = STEPControl_Writer() + writer.Transfer(shape, STEPControl_AsIs) + status = writer.Write(str(filename)) + if status != IFSelect_RetDone: + 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 diff --git a/step_editor/transforms.py b/step_editor/transforms.py new file mode 100644 index 0000000..9d11c9d --- /dev/null +++ b/step_editor/transforms.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import math +from pathlib import Path +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_Defeaturing, BRepAlgoAPI_Fuse +from OCC.Core.BRepBndLib import brepbndlib +from OCC.Core.BOPAlgo import BOPAlgo_GlueFull +from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform +from OCC.Core.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 +from OCC.Core.Bnd import Bnd_Box +from OCC.Core.GeomAbs import ( + GeomAbs_BSplineCurve, + GeomAbs_BSplineSurface, + GeomAbs_BezierCurve, + GeomAbs_BezierSurface, + GeomAbs_Circle, + GeomAbs_Cone, + GeomAbs_Cylinder, + GeomAbs_Ellipse, + GeomAbs_Hyperbola, + GeomAbs_Line, + GeomAbs_OffsetSurface, + GeomAbs_OtherCurve, + GeomAbs_OtherSurface, + GeomAbs_Parabola, + GeomAbs_Plane, + GeomAbs_Sphere, + GeomAbs_SurfaceOfExtrusion, + GeomAbs_SurfaceOfRevolution, + GeomAbs_Torus, +) +from OCC.Core.GProp import GProp_GProps +from OCC.Core.ShapeFix import ShapeFix_Shape +from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain +from OCC.Core.TopAbs import ( + TopAbs_EDGE, + TopAbs_EXTERNAL, + TopAbs_FACE, + TopAbs_FORWARD, + TopAbs_IN, + TopAbs_INTERNAL, + TopAbs_OUT, + TopAbs_REVERSED, + TopAbs_SOLID, +) +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.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec +from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge + +from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES +from .geometry_utils import * # noqa: F403 + + +class TransformMixin: + 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']}." + ) + diff --git a/step_editor/ui_helpers.py b/step_editor/ui_helpers.py new file mode 100644 index 0000000..ba649e1 --- /dev/null +++ b/step_editor/ui_helpers.py @@ -0,0 +1,856 @@ +from __future__ import annotations + +import math + +import vtk +from PySide6.QtCore import Qt + +from .records import OperationRecord + + +INFO_GROUPS: list[tuple[str, list[str]]] = [ + ( + "身份", + [ + "kind", + "name", + "path", + "file", + "part_id", + "solid_id", + "logical_face_id", + "face_region_logical_id", + "topological_face_id", + "face_id", + "edge_id", + "parent_id", + "depth", + "feature_mode", + "feature_type", + "feature_source_face_id", + ], + ), + ( + "拓扑", + [ + "parts", + "solids", + "faces", + "edges", + "vertices", + "boundary_edges", + "same_domain_face_ids", + "same_domain_face_count", + "same_domain_v_range", + "same_domain_range_source", + "same_domain_note", + "orientation", + "surface", + "curve", + "adjacent_face_ids", + "adjacent_face_count", + ], + ), + ( + "测量", + [ + "volume", + "surface_area", + "area", + "length", + "edge_length", + "current_length", + "target_length", + "delta_length", + "length_change_ratio", + "edge_length_anchor_mode", + "edge_length_anchor_label", + "circular_edge_current_radius", + "circular_edge_target_radius", + "circular_edge_length_scale", + "cylinder_resize_current_diameter", + "cylinder_resize_target_diameter", + "cylinder_resize_delta_diameter", + "cylinder_resize_delta_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", + "semi_angle", + "angular_span", + "height_estimate", + "same_domain_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", + ], + ), + ( + "位置", + [ + "pick_position", + "center", + "center_of_mass", + "surface_center", + "area_center", + "length_center", + "bbox_min", + "bbox_max", + "bbox_size", + "start_point", + "end_point", + "translation_vector", + "rotation_center", + "end_face_id", + "end_face_label", + "end_face_plane_distance", + "push_pull_distance", + ], + ), + ( + "方向 / 轴线", + [ + "normal", + "oriented_normal", + "plane_origin", + "axis_point", + "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", + "shell_region_kind", + "shell_region_status", + "shell_confidence", + "shell_source_face_ids", + "shell_opposite_face_id", + "shell_thickness_estimate", + "shell_overlap_ratio_estimate", + "shell_opposite_normal_dot", + "shell_note", + "shell_region_note", + ], + ), + ( + "参数", + [ + "u_range", + "v_range", + "first_parameter", + "last_parameter", + "param_height", + ], + ), + ( + "特征判断", + [ + "feature_guess", + "confidence", + "material_vote_summary", + "material_sample_count", + "material_toward_axis", + "material_away_axis", + "cylinder_end_type", + "start_end_state", + "end_end_state", + "start_end_open", + "end_end_open", + "open_end_count", + "closed_end_count", + "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_chord_width_estimate", + "slot_arc_length_estimate", + "slot_sagitta_depth_estimate", + "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_strategy", + "resize_mode", + "resize_risk", + "resize_warnings", + "resize_blockers", + "resize_note", + "circular_edge_cylinder_face_id", + "circular_edge_cylinder_mode", + "circular_edge_cylinder_mode_label", + "cylinder_resize_face_id", + "cylinder_resize_operation", + "cylinder_resize_status", + "cylinder_resize_risk", + "cylinder_resize_feature_guess", + "cylinder_resize_confidence", + "cylinder_resize_same_domain_face_ids", + "cylinder_resize_same_domain_face_count", + "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", + "cutter_start_margin", + "cutter_end_margin", + "cutter_radius", + "cutter_start_parameter", + "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", + "fill_radius", + "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", + ], + ), +] + + +INFO_LABELS = { + "kind": "类型", + "name": "名称", + "path": "层级路径", + "file": "文件", + "part_id": "Part ID", + "solid_id": "Solid ID", + "logical_face_id": "逻辑 Face ID", + "face_region_logical_id": "面区域逻辑 ID", + "topological_face_id": "拓扑 Face ID", + "face_id": "Face ID", + "face_region_ids": "面区域 Face", + "face_region_count": "面区域 Face 数", + "edge_id": "Edge ID", + "parent_id": "父级 ID", + "depth": "层级深度", + "feature_mode": "特征模式说明", + "feature_type": "特征类型", + "feature_source_face_id": "特征来源 Face", + "parts": "零件数", + "solids": "Solid 数", + "faces": "Face 数", + "edges": "Edge 数", + "vertices": "Vertex 数", + "boundary_edges": "边界边数", + "same_domain_face_ids": "同域区域 Face", + "same_domain_face_count": "同域区域 Face 数", + "same_domain_v_range": "同域区域 V 范围", + "same_domain_height_estimate": "同域区域估算高度", + "same_domain_range_source": "同域范围来源", + "same_domain_note": "同域区域说明", + "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": "边长变化比例", + "edge_length_anchor_mode": "边长基准模式", + "edge_length_anchor_label": "边长基准", + "circular_edge_current_radius": "圆边当前半径", + "circular_edge_target_radius": "圆边目标半径", + "circular_edge_length_scale": "圆边长度比例", + "circular_edge_cylinder_face_id": "圆边相邻圆柱 Face", + "circular_edge_cylinder_mode": "圆边圆柱模式", + "circular_edge_cylinder_mode_label": "圆边编辑模式", + "cylinder_resize_face_id": "圆柱编辑 Face", + "cylinder_resize_operation": "圆柱编辑操作", + "cylinder_resize_current_diameter": "圆柱当前直径", + "cylinder_resize_target_diameter": "圆柱目标直径", + "cylinder_resize_delta_diameter": "圆柱直径变化量", + "cylinder_resize_delta_ratio": "圆柱直径变化比例", + "cylinder_resize_status": "圆柱编辑状态", + "cylinder_resize_risk": "圆柱编辑风险", + "cylinder_resize_feature_guess": "圆柱候选判断", + "cylinder_resize_confidence": "圆柱判断置信度", + "cylinder_resize_same_domain_face_ids": "圆柱同域 Face", + "cylinder_resize_same_domain_face_count": "圆柱同域 Face 数", + "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": "参考半径", + "semi_angle": "半角", + "angular_span": "角度跨度", + "height_estimate": "估算高度", + "hole_depth_estimate": "孔/槽深度估算", + "current_depth": "当前深度", + "target_depth": "目标深度", + "delta_depth": "深度变化量", + "depth_delta_ratio": "深度变化比例", + "manual_bottom_face_id": "手动底面 Face", + "manual_bottom_face_used": "使用手动底面", + "manual_bottom_face_note": "手动底面说明", + "depth_open_direction_source": "孔深方向来源", + "is_full_cylinder": "接近完整圆柱", + "bbox_diagonal": "包围盒对角线", + "pick_position": "拾取点", + "center": "中心", + "center_of_mass": "重心", + "surface_center": "表面积中心", + "area_center": "面积中心", + "length_center": "长度中心", + "bbox_min": "包围盒最小点", + "bbox_max": "包围盒最大点", + "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": "平面原点", + "axis_point": "轴线点", + "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": "推拉共面区域说明", + "shell_region_kind": "壳体/薄壁候选类型", + "shell_region_status": "壳体/薄壁识别状态", + "shell_confidence": "壳体/薄壁置信度", + "shell_source_face_ids": "壳体/薄壁源平面 Face", + "shell_opposite_face_id": "相对平面 Face", + "shell_thickness_estimate": "薄壁厚度估算", + "shell_overlap_ratio_estimate": "相对平面重叠率估算", + "shell_opposite_normal_dot": "相对平面法向点积", + "shell_note": "壳体/薄壁识别说明", + "shell_region_note": "壳体/薄壁识别说明", + "u_range": "U 参数范围", + "v_range": "V 参数范围", + "first_parameter": "起始参数", + "last_parameter": "结束参数", + "param_height": "参数高度", + "feature_guess": "候选判断", + "confidence": "置信度", + "material_vote_summary": "材料投票", + "material_sample_count": "采样数量", + "material_toward_axis": "轴侧材料", + "material_away_axis": "外侧材料", + "cylinder_end_type": "端部类型", + "start_end_state": "起点端状态", + "end_end_state": "终点端状态", + "start_end_open": "起点端开口", + "end_end_open": "终点端开口", + "open_end_count": "开口端数量", + "closed_end_count": "封闭端数量", + "end_sample_offset": "端部采样偏移", + "end_sample_note": "端部采样说明", + "end_sample_range_source": "端部采样范围来源", + "end_sample_scope_face_ids": "端部采样同域 Face", + "end_sample_scope_face_count": "端部采样同域 Face 数", + "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_strategy": "编辑策略", + "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_scope_face_ids": "Cutter 同域 Face", + "cutter_scope_face_count": "Cutter 同域 Face 数", + "cutter_range_source": "Cutter 范围来源", + "cutter_height": "Cutter 高度", + "cutter_margin": "Cutter 余量", + "cutter_start_margin": "Cutter 起点余量", + "cutter_end_margin": "Cutter 终点余量", + "cutter_radius": "Cutter 半径", + "cutter_start_parameter": "Cutter 起始参数", + "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_scope_face_ids": "补料同域 Face", + "fill_scope_face_count": "补料同域 Face 数", + "fill_range_source": "补料范围来源", + "fill_height": "补料高度", + "fill_radius": "补料半径", + "fill_radius_overlap": "补料重叠量", + "fill_start_point": "补料起点", + "fill_note": "补料说明", + "boss_tool_strategy": "凸台工具策略", + "boss_tool_scope_face_ids": "凸台工具同域 Face", + "boss_tool_scope_face_count": "凸台工具同域 Face 数", + "boss_tool_range_source": "凸台工具范围来源", + "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": "凸台工具起点", + "boss_tool_exact_start_point": "凸台精确重建起点", + "boss_tool_exact_height": "凸台精确重建高度", + "depth_tool_strategy": "孔深工具策略", + "depth_tool_role": "孔深工具类型", + "depth_tool_note": "孔深工具说明", + "depth_tool_height": "孔深工具高度", + "depth_tool_radius": "孔深工具半径", + "depth_tool_radius_overlap": "孔深工具半径重叠", + "depth_scope_face_ids": "孔深同域 Face", + "depth_scope_face_count": "孔深同域 Face 数", + "depth_range_source": "孔深范围来源", + "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": "旋转说明", + "scope": "作用范围", + "repair_strategy": "修复策略", +} + + +PART_TREE_KIND_ROLE = Qt.UserRole +PART_TREE_ID_ROLE = Qt.UserRole + 1 +PART_TREE_PART_ID_ROLE = Qt.UserRole + 2 +EDITABLE_TARGET_ID_ROLE = Qt.UserRole +EDITABLE_ACTION_ROLE = Qt.UserRole + 1 +EDITABLE_TARGET_KIND_ROLE = Qt.UserRole + 2 + + +def _format_float(value: float) -> str: + return f"{value:.6g}" + + +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 _smooth_surface_polydata(polydata): + clean = vtk.vtkCleanPolyData() + clean.SetInputData(polydata) + clean.PointMergingOn() + clean.SetTolerance(1e-7) + clean.Update() + + normals = vtk.vtkPolyDataNormals() + normals.SetInputConnection(clean.GetOutputPort()) + normals.ComputePointNormalsOn() + normals.ComputeCellNormalsOff() + normals.ConsistencyOn() + normals.SplittingOn() + normals.SetFeatureAngle(35.0) + if hasattr(normals, "AutoOrientNormalsOn"): + normals.AutoOrientNormalsOn() + normals.Update() + + smoothed = vtk.vtkPolyData() + smoothed.DeepCopy(normals.GetOutput()) + return smoothed + + +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) + if isinstance(value, tuple): + return "(" + ", ".join(_format_float(float(v)) for v in value) + ")" + 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 _int_tuple_or_none(values) -> tuple[int, ...] | None: + if values is None: + return None + if isinstance(values, int): + return (values,) + return tuple(sorted({int(value) for value in values})) + + +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) + if isinstance(before_value, (int, float)) and isinstance(after_value, (int, float)): + return f"{_format_value(float(after_value))} ({_signed_float_delta(float(after_value) - float(before_value))})" + if isinstance(before_value, tuple) and isinstance(after_value, tuple) and len(before_value) == len(after_value): + try: + deltas = tuple(float(after_item) - float(before_item) for before_item, after_item in zip(before_value, after_value)) + except (TypeError, ValueError): + return _format_value(after_value) + return f"{_format_value(after_value)} (delta={_format_value(deltas)})" + return _format_value(after_value if after_value is not None else "") + + +def _vector_tuple(values) -> tuple[float, float, float]: + return (float(values[0]), float(values[1]), float(values[2])) + + +def _signed_delta(value: int) -> str: + if value > 0: + return f"+{value}" + return str(value) + + +def _signed_float_delta(value: float) -> str: + if value > 0: + return f"+{_format_float(value)}" + return _format_float(value) + + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/step_editor/widgets.py b/step_editor/widgets.py new file mode 100644 index 0000000..f82e973 --- /dev/null +++ b/step_editor/widgets.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from PySide6.QtWidgets import QComboBox + + +class NoWheelComboBox(QComboBox): + def wheelEvent(self, event) -> None: + event.accept() diff --git a/step_editor/window_actions.py b/step_editor/window_actions.py new file mode 100644 index 0000000..b739329 --- /dev/null +++ b/step_editor/window_actions.py @@ -0,0 +1,2273 @@ +from __future__ import annotations + +from datetime import datetime +import math +from pathlib import Path + +import vtk +from PySide6.QtCore import Qt, QThread, QTimer, Slot +from PySide6.QtWidgets import ( + QApplication, + QFileDialog, + QMessageBox, + QTableWidgetItem, + QTreeWidgetItem, +) + +from .model import StepModel +from .records import OperationRecord +from .ui_helpers import * # noqa: F403 +from .workers import EditWorker, LoadWorker, ScanWorker + + +class WindowActionMixin: + 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", + str(self.step_path.parent / f"{self.step_path.stem}_edited.step"), + "STEP 文件 (*.step *.stp);;所有文件 (*.*)", + ) + if not target: + return + self._run_action(lambda: self.model.export_all(target), f"已导出 {Path(target).name}") + + def export_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, "未选择零件", "请先选择一个零件。") + 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( + self, + "导出选中零件", + str(self.step_path.parent / default_name), + "STEP 文件 (*.step *.stp);;所有文件 (*.*)", + ) + if not target: + return + self._run_action( + lambda: self.model.export_part(self.selected_part_id, target), + f"已导出选中零件到 {Path(target).name}", + ) + + def export_selected_solid(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再导出。"): + return + 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", + str(self.step_path.parent / f"solid_{self.selected_solid_id}_export.step"), + "STEP 文件 (*.step *.stp);;所有文件 (*.*)", + ) + if not target: + return + self._run_action( + lambda: self.model.export_solid(self.selected_solid_id, target), + f"已导出选中 solid 到 {Path(target).name}", + ) + + def export_selected_face(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再导出。"): + return + 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, + "导出选中面区域", + str(self.step_path.parent / f"face_region_{self.selected_face_id}_export.step"), + "STEP 文件 (*.step *.stp);;所有文件 (*.*)", + ) + if not target: + return + self._run_action( + lambda: self.model.export_face(self.selected_face_id, target), + f"已导出选中面区域到 {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 repair_model(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再修复模型。"): + return + + def action(): + return self.model.repair_model() + + self._run_edit_action( + action, + operation_name="修复当前模型", + target="当前完整模型", + parameters={"scope": "all", "repair_strategy": "ShapeFix + UnifySameDomain"}, + target_kind=None, + target_id=None, + ) + + def repair_selected_shape(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再修复选中对象。"): + return + + if self.selected_solid_id is not None: + solid_id = self.selected_solid_id + + def action(): + return self.model.repair_solid(solid_id) + + self._run_edit_action( + action, + operation_name="修复选中 solid", + target=f"solid {solid_id}", + parameters={ + "scope": "solid", + "solid_id": solid_id, + "part_id": self.selected_part_id, + "repair_strategy": "ShapeFix + UnifySameDomain", + }, + target_kind="solid", + target_id=solid_id, + ) + return + + if self.selected_part_id is not None: + part_id = self.selected_part_id + + def action(): + return self.model.repair_part(part_id) + + self._run_edit_action( + action, + operation_name="修复选中零件", + target=f"part {part_id}", + parameters={ + "scope": "part", + "part_id": part_id, + "repair_strategy": "ShapeFix + UnifySameDomain", + }, + target_kind="part", + target_id=part_id, + ) + return + + QMessageBox.information(self, "未选择对象", "请先选择 part、solid、face 或 edge。") + self.statusBar().showMessage("未选择可修复对象") + + def push_pull_face(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: + distance = float(self.offset_input.text()) + except ValueError: + QMessageBox.critical(self, "距离无效", "请输入数字形式的面偏移距离。") + return + face_id = self.selected_face_id + plan = self.model.push_pull_plan(face_id, distance) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能推拉平面", str(plan["message"])) + self.statusBar().showMessage("推拉平面已阻止") + return + self._show_push_pull_preview(face_id, distance) + + def action(): + return self.model.push_pull_face(face_id, distance) + + 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"), + "semantic_distance": distance, + "distance_rule": "positive=outward fuse, negative=inward cut", + "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, + ) + + def resize_hole(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: + diameter = float(self.hole_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_resize_plan(self.selected_face_id, diameter) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能调整孔径", str(plan["message"])) + self.statusBar().showMessage("圆柱孔径调整已阻止") + return + guess = str(info.get("feature_guess", "cylindrical face")) + if plan["risk"] != "low": + target_to_height_ratio = plan.get("target_to_height_ratio", "") + target_to_height_line = ( + "" + if target_to_height_ratio is None or target_to_height_ratio == "" + else f"目标直径/估算高度: {_format_value(target_to_height_ratio)}\n" + ) + warnings = str(plan.get("warnings", "")) + warnings_line = f"警告: {warnings}\n\n" if warnings else "" + result = QMessageBox.question( + self, + "确认圆柱孔径调整", + ( + f"face: {plan['face_id']}\n" + f"当前直径: {_format_value(plan['current_diameter'])}\n" + f"目标直径: {_format_value(plan['target_diameter'])}\n" + f"直径变化量: {_format_value(plan['delta_diameter'])}\n" + f"直径变化比例: {_format_percent(plan['diameter_delta_ratio'])}\n" + f"{target_to_height_line}" + f"调整模式: {plan['resize_mode']}\n" + f"候选判断: {plan['feature_guess']}\n" + f"置信度: {plan['confidence']}\n" + f"材料投票: {plan['material_vote_summary']}\n" + f"端部类型: {plan['cylinder_end_type']}\n" + f"深度估算: {_format_value(plan['hole_depth_estimate'])}\n" + f"疑似底面 Face: {_format_value(plan.get('feature_bottom_face_ids', ''))}\n" + f"开口端相邻 Face: {_format_value(plan.get('feature_opening_face_ids', ''))}\n" + f"风险: {plan['risk']}\n\n" + f"Cutter: {plan['cutter_strategy']}\n" + f"Cutter 高度: {_format_value(plan['cutter_height'])}\n" + f"Cutter 起点/终点余量: {_format_value(plan['cutter_start_margin'])} / " + f"{_format_value(plan['cutter_end_margin'])}\n\n" + f"底面保护: {_format_value(plan.get('cutter_bottom_protection', ''))}\n" + f"{_format_value(plan.get('cutter_bottom_note', ''))}\n\n" + f"补料策略: {plan.get('fill_strategy', '')}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会对当前零件执行实验性布尔修改。\n\n" + "确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if result != QMessageBox.StandardButton.Yes: + self.statusBar().showMessage("已取消圆柱孔径调整") + return + face_id = self.selected_face_id + self._show_cylinder_resize_preview(face_id, diameter) + + def action(): + return self.model.resize_cylindrical_hole(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_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"), + "start_end_state": plan.get("start_end_state"), + "end_end_state": plan.get("end_end_state"), + "cutter_strategy": plan.get("cutter_strategy"), + "cutter_height": plan.get("cutter_height"), + "cutter_margin": plan.get("cutter_margin"), + "cutter_start_margin": plan.get("cutter_start_margin"), + "cutter_end_margin": plan.get("cutter_end_margin"), + "cutter_bottom_protection": plan.get("cutter_bottom_protection"), + "cutter_protected_bottom_face_ids": plan.get("cutter_protected_bottom_face_ids"), + "cutter_opening_face_ids": plan.get("cutter_opening_face_ids"), + "cutter_bottom_note": plan.get("cutter_bottom_note"), + "cutter_note": plan.get("cutter_note"), + "fill_strategy": plan.get("fill_strategy"), + "fill_height": plan.get("fill_height"), + "fill_radius": plan.get("fill_radius"), + "fill_radius_overlap": plan.get("fill_radius_overlap"), + "fill_note": plan.get("fill_note"), + }, + target_kind="face", + target_id=face_id, + ) + + def resize_slot_width(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 + if not hasattr(self, "slot_width_input"): + return + try: + target_width = float(self.slot_width_input.text()) + except ValueError: + QMessageBox.critical(self, "目标槽宽无效", "请输入数字形式的目标槽/半孔宽度。") + return + if target_width <= 0: + QMessageBox.critical(self, "目标槽宽无效", "目标槽/半孔宽度必须大于 0。") + return + + face_id = self.selected_face_id + info = self.model.feature_info(face_id) + if info.get("surface") != "cylinder" or info.get("slot_kind") != "partial-cylindrical-groove": + QMessageBox.information(self, "不是槽/半孔候选", "当前选中 face 没有被识别为槽/半孔候选。") + return + angular_span = _float_or_none(info.get("slot_angular_span")) + if angular_span is None: + angular_span = _float_or_none(info.get("angular_span")) + if angular_span is None or angular_span <= 1e-6 or angular_span >= math.tau * 0.92: + QMessageBox.information(self, "不能调整槽宽", "当前候选没有稳定的部分圆柱角度,不能换算槽宽。") + return + sin_half_span = math.sin(min(max(angular_span, 1e-6), math.tau - 1e-6) / 2.0) + if abs(sin_half_span) <= 1e-6: + QMessageBox.information(self, "不能调整槽宽", "当前槽圆弧角度过小,不能可靠换算为圆柱直径。") + return + + target_diameter = target_width / sin_half_span + current_width = _float_or_none(info.get("slot_chord_width_estimate")) + plan = self.model.cylindrical_resize_plan(face_id, target_diameter) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能调整槽/半孔宽度", str(plan["message"])) + self.statusBar().showMessage("槽/半孔宽度调整已阻止") + return + + if plan["risk"] != "low": + warnings = str(plan.get("warnings", "")) + warnings_line = f"警告: {warnings}\n\n" if warnings else "" + result = QMessageBox.question( + self, + "确认调整槽/半孔宽度", + ( + f"face: {face_id}\n" + f"当前槽宽估算: {_format_value(current_width)}\n" + f"目标槽宽: {_format_value(target_width)}\n" + f"槽圆弧角度: {_format_value(angular_span)}\n" + f"换算目标圆柱直径: {_format_value(target_diameter)}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "第一版会把槽宽换算成圆柱直径后执行孔/槽重建,不是完整 CAD 槽参数编辑。确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if result != QMessageBox.StandardButton.Yes: + self.statusBar().showMessage("已取消槽/半孔宽度调整") + return + + self._show_cylinder_resize_preview(face_id, target_diameter) + + def action(): + return self.model.resize_cylindrical_hole(face_id, target_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_slot_width": target_width, + "old_slot_width": current_width, + "slot_width_delta": None if current_width is None else target_width - current_width, + "slot_angular_span": angular_span, + "derived_new_diameter": target_diameter, + "old_diameter": info.get("diameter"), + "delta_diameter": plan.get("delta_diameter"), + "diameter_delta_ratio": plan.get("diameter_delta_ratio"), + "resize_mode": plan.get("resize_mode"), + "feature_type": plan.get("feature_type"), + "feature_guess": plan.get("feature_guess"), + "confidence": plan.get("confidence"), + "slot_status": info.get("slot_status"), + "slot_kind": info.get("slot_kind"), + "feature_slot_boundary_face_ids": info.get("feature_slot_boundary_face_ids"), + "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"), + "feature_bottom_face_ids": plan.get("feature_bottom_face_ids"), + "feature_opening_face_ids": plan.get("feature_opening_face_ids"), + "cutter_strategy": plan.get("cutter_strategy"), + "cutter_height": plan.get("cutter_height"), + "fill_strategy": plan.get("fill_strategy"), + }, + target_kind="face", + 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 _hole_bottom_face_override(self) -> int | None: + if not hasattr(self, "hole_bottom_face_input"): + return None + text = self.hole_bottom_face_input.text().strip() + if not text: + return None + try: + bottom_face_id = int(text) + except ValueError as exc: + raise ValueError("底面 Face ID 必须是整数。") from exc + if self.model is not None and (bottom_face_id < 0 or bottom_face_id >= len(self.model.faces)): + raise ValueError(f"底面 Face ID {bottom_face_id} 不存在。") + return bottom_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 + try: + bottom_face_id = self._hole_bottom_face_override() + except ValueError as exc: + QMessageBox.critical(self, "底面 Face ID 无效", str(exc)) + return + + info = self.model.face_info(self.selected_face_id) + if info.get("surface") != "cylinder": + QMessageBox.information(self, "不是圆柱孔/槽", "当前选中的 face 不是可调整孔深的圆柱孔/槽。") + return + plan = self.model.cylindrical_depth_plan( + self.selected_face_id, + target_depth, + bottom_face_id=bottom_face_id, + ) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能调整孔深", str(plan["message"])) + self.statusBar().showMessage("盲孔深度调整已阻止") + return + + if plan["risk"] != "low": + warnings = str(plan.get("warnings", "")) + warnings_line = f"警告: {warnings}\n\n" if warnings else "" + result = QMessageBox.question( + self, + "确认盲孔深度调整", + ( + f"face: {plan['face_id']}\n" + f"当前深度: {_format_value(plan['current_depth'])}\n" + f"目标深度: {_format_value(plan['target_depth'])}\n" + f"深度变化量: {_format_value(plan['delta_depth'])}\n" + f"深度变化比例: {_format_percent(plan['depth_delta_ratio'])}\n" + f"调整模式: {plan['depth_mode']}\n" + f"候选判断: {plan['feature_guess']}\n" + f"置信度: {plan['confidence']}\n" + f"材料投票: {plan['material_vote_summary']}\n" + f"端部类型: {plan['cylinder_end_type']}\n" + f"疑似底面 Face: {_format_value(plan.get('feature_bottom_face_ids', ''))}\n" + f"手动底面 Face: {_format_value(plan.get('manual_bottom_face_id', ''))}\n" + f"底面识别来源: {_format_value(plan.get('feature_bottom_detection', ''))}\n" + f"当前深度来源: {_format_value(plan.get('depth_current_depth_source', ''))}\n" + f"开口方向来源: {_format_value(plan.get('depth_open_direction_source', ''))}\n" + f"开口端相邻 Face: {_format_value(plan.get('feature_opening_face_ids', ''))}\n" + f"风险: {plan['risk']}\n\n" + f"工具策略: {plan['depth_tool_strategy']}\n" + f"工具类型: {plan['depth_tool_role']}\n" + f"工具高度: {_format_value(plan['depth_tool_height'])}\n" + f"工具半径: {_format_value(plan['depth_tool_radius'])}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会对当前零件执行实验性布尔修改。\n\n" + "确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if result != QMessageBox.StandardButton.Yes: + self.statusBar().showMessage("已取消盲孔深度调整") + return + + face_id = self.selected_face_id + self._show_cylinder_depth_preview(face_id, target_depth, bottom_face_id=bottom_face_id) + + def action(): + return self.model.resize_cylindrical_depth( + face_id, + target_depth, + bottom_face_id=bottom_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"), + "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"), + "manual_bottom_face_id": plan.get("manual_bottom_face_id"), + "manual_bottom_face_used": plan.get("manual_bottom_face_used"), + "manual_bottom_face_note": plan.get("manual_bottom_face_note"), + "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_direction_source": plan.get("depth_open_direction_source"), + "depth_open_point": plan.get("depth_open_point"), + "depth_current_bottom_point": plan.get("depth_current_bottom_point"), + "depth_target_bottom_point": plan.get("depth_target_bottom_point"), + }, + target_kind="face", + target_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 self._block_unisolated_high_risk_operation("已阻止高风险已有圆角修改", plan): + return + 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 self._block_unisolated_high_risk_operation("已阻止高风险边圆角", plan): + return + 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 self._block_unisolated_high_risk_operation("已阻止高风险边倒角", plan): + return + 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 + anchor_mode = self._edge_length_anchor_mode_from_ui() + plan = self.model.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode) + 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"边长基准: {_format_value(plan.get('edge_length_anchor_label', ''))}\n" + f"策略: {_format_value(plan.get('resize_strategy', ''))}\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"圆柱 face: {_format_value(plan.get('cylinder_resize_face_id', ''))}\n" + f"圆边模式: {_format_value(plan.get('circular_edge_cylinder_mode_label', ''))}\n" + f"换算目标直径: {_format_value(plan.get('cylinder_resize_target_diameter', ''))}\n" + f"缩放目标: {_format_value(plan.get('affine_target_kind', ''))}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "当前会优先通过端面推拉或相邻圆柱直径编辑改变边长;没有明确局部路径时会尝试几何缩放 fallback。" + "这不是通用参数化边长编辑。\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, anchor_mode=anchor_mode) + + def action(): + return self.model.resize_general_edge_length(edge_id, target_length, anchor_mode=anchor_mode) + + 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"), + "edge_length_anchor_mode": plan.get("edge_length_anchor_mode"), + "edge_length_anchor_label": plan.get("edge_length_anchor_label"), + "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"), + "affine_scale": plan.get("affine_scale"), + "affine_transform_kind": plan.get("affine_transform_kind"), + "affine_axis_point": plan.get("affine_axis_point"), + "affine_axis_direction": plan.get("affine_axis_direction"), + "affine_axis_source": plan.get("affine_axis_source"), + "affine_anchor_source": plan.get("affine_anchor_source"), + "affine_target_kind": plan.get("affine_target_kind"), + "circular_edge_current_radius": plan.get("circular_edge_current_radius"), + "circular_edge_target_radius": plan.get("circular_edge_target_radius"), + "circular_edge_length_scale": plan.get("circular_edge_length_scale"), + "circular_edge_cylinder_face_id": plan.get("circular_edge_cylinder_face_id"), + "circular_edge_cylinder_mode": plan.get("circular_edge_cylinder_mode"), + "circular_edge_cylinder_mode_label": plan.get("circular_edge_cylinder_mode_label"), + "cylinder_resize_face_id": plan.get("cylinder_resize_face_id"), + "cylinder_resize_operation": plan.get("cylinder_resize_operation"), + "cylinder_resize_current_diameter": plan.get("cylinder_resize_current_diameter"), + "cylinder_resize_target_diameter": plan.get("cylinder_resize_target_diameter"), + "cylinder_resize_delta_diameter": plan.get("cylinder_resize_delta_diameter"), + "cylinder_resize_delta_ratio": plan.get("cylinder_resize_delta_ratio"), + "cylinder_resize_status": plan.get("cylinder_resize_status"), + "cylinder_resize_risk": plan.get("cylinder_resize_risk"), + "cylinder_resize_feature_guess": plan.get("cylinder_resize_feature_guess"), + "cylinder_resize_confidence": plan.get("cylinder_resize_confidence"), + "cylinder_resize_same_domain_face_ids": plan.get("cylinder_resize_same_domain_face_ids"), + "cylinder_resize_same_domain_face_count": plan.get("cylinder_resize_same_domain_face_count"), + "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 resize_any_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 + anchor_mode = self._edge_length_anchor_mode_from_ui() + plan = self.model.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode) + 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 "" + strategy = str(plan.get("resize_strategy", "")) + if plan["risk"] != "low": + result = QMessageBox.question( + self, + "确认直接修改边长", + ( + f"edge: {plan['edge_id']}\n" + f"曲线类型: {_format_value(plan.get('curve', ''))}\n" + f"当前边长: {_format_value(plan['current_length'])}\n" + f"目标边长: {_format_value(plan['target_length'])}\n" + f"变化量: {_format_value(plan['delta_length'])}\n" + f"边长基准: {_format_value(plan.get('edge_length_anchor_label', ''))}\n" + f"策略: {_format_value(strategy)}\n" + f"端面 face: {_format_value(plan.get('end_face_id', ''))}\n" + f"端面推拉距离: {_format_value(plan.get('push_pull_distance', ''))}\n" + f"圆柱 face: {_format_value(plan.get('cylinder_resize_face_id', ''))}\n" + f"圆边模式: {_format_value(plan.get('circular_edge_cylinder_mode_label', ''))}\n" + f"换算目标直径: {_format_value(plan.get('cylinder_resize_target_diameter', ''))}\n" + f"缩放类型: {_format_value(plan.get('affine_transform_kind', ''))}\n" + f"缩放目标: {_format_value(plan.get('affine_target_kind', ''))}\n" + f"缩放比例: {_format_value(plan.get('affine_scale', ''))}\n" + f"轴线来源: {_format_value(plan.get('affine_axis_source', ''))}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?" + ), + 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, anchor_mode=anchor_mode) + + def action(): + return self.model.resize_general_edge_length(edge_id, target_length, anchor_mode=anchor_mode) + + 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"), + "curve": plan.get("curve"), + "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"), + "edge_length_anchor_mode": plan.get("edge_length_anchor_mode"), + "edge_length_anchor_label": plan.get("edge_length_anchor_label"), + "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"), + "affine_scale": plan.get("affine_scale"), + "affine_transform_kind": plan.get("affine_transform_kind"), + "affine_axis_point": plan.get("affine_axis_point"), + "affine_axis_direction": plan.get("affine_axis_direction"), + "affine_axis_source": plan.get("affine_axis_source"), + "affine_anchor_source": plan.get("affine_anchor_source"), + "affine_target_kind": plan.get("affine_target_kind"), + "circular_edge_current_radius": plan.get("circular_edge_current_radius"), + "circular_edge_target_radius": plan.get("circular_edge_target_radius"), + "circular_edge_length_scale": plan.get("circular_edge_length_scale"), + "circular_edge_cylinder_face_id": plan.get("circular_edge_cylinder_face_id"), + "circular_edge_cylinder_mode": plan.get("circular_edge_cylinder_mode"), + "circular_edge_cylinder_mode_label": plan.get("circular_edge_cylinder_mode_label"), + "cylinder_resize_face_id": plan.get("cylinder_resize_face_id"), + "cylinder_resize_operation": plan.get("cylinder_resize_operation"), + "cylinder_resize_current_diameter": plan.get("cylinder_resize_current_diameter"), + "cylinder_resize_target_diameter": plan.get("cylinder_resize_target_diameter"), + "cylinder_resize_delta_diameter": plan.get("cylinder_resize_delta_diameter"), + "cylinder_resize_delta_ratio": plan.get("cylinder_resize_delta_ratio"), + "cylinder_resize_status": plan.get("cylinder_resize_status"), + "cylinder_resize_risk": plan.get("cylinder_resize_risk"), + "cylinder_resize_feature_guess": plan.get("cylinder_resize_feature_guess"), + "cylinder_resize_confidence": plan.get("cylinder_resize_confidence"), + "cylinder_resize_same_domain_face_ids": plan.get("cylinder_resize_same_domain_face_ids"), + "cylinder_resize_same_domain_face_count": plan.get("cylinder_resize_same_domain_face_count"), + "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 _edge_length_anchor_mode_from_ui(self) -> str: + combo = getattr(self, "edge_length_anchor_combo", None) + if combo is None: + return "auto" + data = combo.currentData() + return str(data or "auto") + + 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 _run_scan_task(self, scan_kind: str, action, finished_callback, failed_callback) -> None: + if self.scan_in_progress: + self.statusBar().showMessage("后台扫描中,请等待当前扫描完成。") + return + if self.operation_in_progress: + self.statusBar().showMessage("编辑计算中,请等待当前操作完成后再扫描。") + return + self.scan_in_progress = True + self.pending_scan_kind = scan_kind + self._clear_hover(render=True) + self._update_action_states() + thread = QThread(self) + worker = ScanWorker(action) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(finished_callback) + worker.failed.connect(failed_callback) + worker.finished.connect(thread.quit) + worker.failed.connect(thread.quit) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._forget_scan_thread) + self.scan_thread = thread + self.scan_worker = worker + thread.start() + + def _end_scan_task(self) -> None: + self.scan_in_progress = False + self.pending_scan_kind = None + self._update_action_states() + + def _forget_scan_thread(self) -> None: + self.scan_thread = None + self.scan_worker = None + + def list_cylinders(self) -> None: + self.refresh_cylinder_candidates(show_info=True) + + def _clear_editable_candidates(self) -> None: + if hasattr(self, "editable_table"): + self.editable_table.setRowCount(0) + + 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 + limit = 48 if deep_scan else 24 + max_scan_faces = 1600 if deep_scan else 700 + max_scan_edges = 2500 if deep_scan else 1200 + scan_label = "深度扫描" if deep_scan else "扫描" + self.statusBar().showMessage(f"正在后台{scan_label}第一版可编辑对象...") + + def action(): + if self.model is None: + return [] + return self.model.editable_feature_candidates( + limit=limit, + max_scan_faces=max_scan_faces, + max_scan_edges=max_scan_edges, + ) + + self._run_scan_task( + "editable", + action, + lambda result: self._finish_editable_scan(result, show_info, scan_label, limit), + self._fail_editable_scan, + ) + + def _finish_editable_scan(self, result: object, show_info: bool, scan_label: str, limit: int) -> None: + try: + candidates = list(result) if isinstance(result, (list, tuple)) else [] + self._populate_editable_candidates(candidates, show_info, scan_label, limit) + finally: + self._end_scan_task() + + def _fail_editable_scan(self, message: str) -> None: + self._end_scan_task() + QMessageBox.critical(self, "扫描失败", message) + self.statusBar().showMessage("第一版可编辑对象扫描失败") + + def _populate_editable_candidates( + self, + candidates: list[dict[str, object]], + show_info: bool, + scan_label: str, + limit: int, + ) -> None: + self.editable_table.setRowCount(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_TARGET_ID_ROLE, target_id) + operation_item.setData(EDITABLE_TARGET_KIND_ROLE, target_kind) + operation_item.setData(EDITABLE_ACTION_ROLE, str(item["operation_key"])) + 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'])}" + ) + status_item = QTableWidgetItem(str(item["status"])) + risk_item = QTableWidgetItem(str(item["risk"])) + confidence_item = QTableWidgetItem(str(item["confidence"])) + note_item = QTableWidgetItem(str(item["note"])) + row_items = [ + operation_item, + id_item, + object_item, + current_item, + status_item, + risk_item, + confidence_item, + note_item, + ] + for column, table_item in enumerate(row_items): + table_item.setToolTip(table_item.text()) + self.editable_table.setItem(row, column, table_item) + lines.append( + f"{target_kind} {target_id}: {item['operation']}, " + f"part={item['part_id']}, solid={item['solid_id']}, " + f"object={item['feature_guess']}, " + f"{item['current_value_label']}={_format_value(item['current_value'])}, " + f"status={item['status']}, risk={item['risk']}, " + f"confidence={item['confidence']}, note={item['note']}" + ) + self.editable_table.resizeColumnsToContents() + self.statusBar().showMessage(f"已{scan_label}第一版可编辑对象:{len(candidates)} 个") + if show_info: + self.set_plain_info("\n".join(lines)) + + def _clear_cylinder_candidates(self) -> None: + self.cylinder_candidate_cache = [] + self.cylinder_candidates_loaded = False + if hasattr(self, "cylinder_table"): + self.cylinder_table.setRowCount(0) + + def refresh_cylinder_candidates(self, show_info: bool = True) -> None: + if self.model is None: + return + if self._edit_busy("编辑计算中,暂时不能扫描圆柱候选。"): + return + self.statusBar().showMessage("正在后台扫描圆柱候选...") + + def action(): + if self.model is None: + return [] + return self.model.cylindrical_feature_candidates(limit=60, max_scan_faces=1200) + + self._run_scan_task( + "cylinder", + action, + lambda result: self._finish_cylinder_scan(result, show_info), + self._fail_cylinder_scan, + ) + + def _finish_cylinder_scan(self, result: object, show_info: bool) -> None: + try: + self.cylinder_candidate_cache = list(result) if isinstance(result, (list, tuple)) else [] + self.cylinder_candidates_loaded = True + self._filter_cached_cylinder_candidates(show_info=show_info) + finally: + self._end_scan_task() + + def _fail_cylinder_scan(self, message: str) -> None: + self._end_scan_task() + self._clear_cylinder_candidates() + QMessageBox.critical(self, "扫描失败", message) + self.statusBar().showMessage("圆柱候选扫描失败") + + def _filter_cached_cylinder_candidates(self, show_info: bool = False) -> None: + if not self.cylinder_candidates_loaded: + self.cylinder_table.setRowCount(0) + if show_info: + self.set_plain_info("圆柱候选尚未扫描。点击 `列出圆柱候选` 后再切换筛选类型。") + return + candidates = [ + item + for item in self.cylinder_candidate_cache + if self._candidate_matches_filter(str(item["feature_guess"])) + ] + self._populate_cylinder_table(candidates, show_info=show_info) + + def _populate_cylinder_table(self, candidates: list[dict[str, object]], show_info: bool = True) -> None: + self.cylinder_table.setRowCount(len(candidates)) + lines = [f"圆柱候选:显示 {len(candidates)} 个"] + for row, item in enumerate(candidates): + face_item = QTableWidgetItem(str(item["face_id"])) + face_item.setData(Qt.UserRole, int(item["face_id"])) + guess_item = QTableWidgetItem(str(item["feature_guess"])) + diameter_item = QTableWidgetItem(_format_float(float(item["diameter"]))) + span_item = QTableWidgetItem(_format_float(float(item["angular_span"]))) + height_item = QTableWidgetItem(_format_float(float(item["height_estimate"]))) + confidence_item = QTableWidgetItem(str(item["confidence"])) + risk_item = QTableWidgetItem(str(item["resize_risk"])) + part_item = QTableWidgetItem(str(item["part_id"])) + self.cylinder_table.setItem(row, 0, face_item) + self.cylinder_table.setItem(row, 1, guess_item) + self.cylinder_table.setItem(row, 2, diameter_item) + self.cylinder_table.setItem(row, 3, span_item) + self.cylinder_table.setItem(row, 4, height_item) + self.cylinder_table.setItem(row, 5, confidence_item) + self.cylinder_table.setItem(row, 6, risk_item) + self.cylinder_table.setItem(row, 7, part_item) + lines.append( + f"face {item['face_id']}: part {item['part_id']}, " + f"guess={item['feature_guess']}, " + f"diameter={_format_float(float(item['diameter']))}, " + f"height={_format_float(float(item['height_estimate']))}, " + f"confidence={item['confidence']}, " + f"risk={item['resize_risk']}" + ) + self.cylinder_table.resizeColumnsToContents() + self.statusBar().showMessage(f"已显示圆柱候选:{len(candidates)} 个") + if show_info: + self.set_plain_info("\n".join(lines)) + + def _candidate_matches_filter(self, feature_guess: str) -> bool: + current = self.candidate_filter_combo.currentText() + if current == "All": + return True + if current == "Hole/Groove": + return feature_guess == "hole/groove candidate" + if current == "Round/Fillet": + return feature_guess == "round/fillet candidate" + if current == "Boss/Outer": + return feature_guess == "boss/outer-round candidate" + if current == "Unclear": + return feature_guess == "cylindrical face" + return True + + def _run_action(self, action, success_message: str | None) -> None: + try: + result = action() + except Exception as exc: + QMessageBox.critical(self, "操作失败", str(exc)) + self.statusBar().showMessage("操作失败") + return + message = success_message or str(result) + self.statusBar().showMessage(message) + self.set_plain_info(message) + + def _block_unisolated_high_risk_operation(self, title: str, plan: dict[str, object]) -> bool: + if str(plan.get("risk", "")) != "high": + return False + QMessageBox.information( + self, + title, + ( + "该操作被稳定性保护阻止。\n\n" + "当前计划被判定为 high risk;这类 OCCT 布尔、倒圆或局部重建在复杂 STEP 上" + "可能不是普通失败,而是让进程卡死或直接退出。请先尝试更小的参数、修复模型、" + "选择更明确的面/边,或等后续子进程隔离执行通道实现后再开放。" + ), + ) + self.statusBar().showMessage("高风险操作已被稳定性保护阻止") + return True + + def _run_edit_action( + self, + action, + operation_name: str, + target: str, + parameters: dict[str, object], + target_kind: str | None = None, + target_id: int | None = None, + ) -> None: + if self.model is None: + return + if self.operation_in_progress: + self.statusBar().showMessage("后台编辑正在计算,请等待当前操作完成。") + return + target_logical_id = self._edit_target_logical_id(target_kind, target_id) + context = { + "operation_name": operation_name, + "target": target, + "parameters": parameters, + "target_kind": target_kind, + "target_id": target_id, + "target_logical_id": target_logical_id, + "pick_position": self.selected_pick_position, + "show_same_domain_internal_edges": self._show_same_domain_internal_edges(), + } + + self._begin_edit_task(operation_name) + self.pending_edit_context = context + thread = QThread(self) + worker = EditWorker(self._make_edit_job(action, context)) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(self._finish_edit_action) + worker.failed.connect(self._fail_edit_action) + worker.finished.connect(thread.quit) + worker.failed.connect(thread.quit) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._forget_edit_thread) + self.edit_thread = thread + self.edit_worker = worker + thread.start() + + def _edit_target_logical_id(self, target_kind: str | None, target_id: int | None) -> int | None: + if self.model is None or target_id is None or target_kind not in {"face", "feature"}: + return None + try: + numeric_id = int(target_id) + except (TypeError, ValueError): + return None + if 0 <= numeric_id < len(self.model.faces): + try: + return self.model.face_region_logical_id(numeric_id) + except Exception: + return None + return None + + 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() + 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() + 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, + } + + 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._update_action_states() + if "推拉" in operation_name: + progress_text = f"{operation_name} 正在后台计算,半透明预览会持续渲染,3D 视图仍可旋转查看。" + 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.statusBar().showMessage(progress_text) + if self.edit_preview_timer is not None: + self.edit_preview_timer.stop() + QApplication.processEvents() + + def _set_edit_status_text(self, text: str) -> None: + self.statusBar().showMessage(text) + QApplication.processEvents() + + @Slot(object) + def _finish_edit_action(self, result: object) -> None: + context = self.pending_edit_context + if context is None: + self._end_edit_task(clear_preview=True) + self.statusBar().showMessage("编辑已完成,但上下文丢失") + return + if self.model is None: + self._end_edit_task(clear_preview=True) + return + self._set_edit_status_text("布尔计算已完成,正在刷新模型显示和历史记录...") + if not isinstance(result, dict): + self._end_edit_task(clear_preview=True) + QMessageBox.critical(self, "操作失败", "后台编辑返回了无法识别的结果。") + self.statusBar().showMessage("编辑结果无法识别") + return + message = str(result["message"]) + try: + record = self._make_operation_record( + operation_name=str(context["operation_name"]), + target=str(context["target"]), + parameters=dict(context["parameters"]), + 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"], + target_id=context["target_id"], + target_logical_id=context.get("target_logical_id"), + pick_position=context["pick_position"], + before_snapshot=result["snapshot"], + after_snapshot=result["after_snapshot"], + ) + model_polydata = result.get("model_polydata") + edge_polydata = result.get("edge_polydata") + if model_polydata is None or edge_polydata is None: + model_polydata = self.model.build_face_polydata() + edge_polydata = self.model.build_edge_polydata( + show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False)) + ) + self.clear_edit_preview(render=False) + self._reset_selection() + self._populate_part_tree() + self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=False) + 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, "操作失败", f"编辑结果刷新失败:{exc}\n\n{rollback_message}") + self.statusBar().showMessage("编辑已完成,但刷新结果时失败") + return + + self.undo_stack.append(result["snapshot"]) + self.redo_stack.clear() + self.operation_history.append(record) + self.redo_history.clear() + self._clear_editable_candidates() + self._clear_cylinder_candidates() + self._refresh_history_list() + self._end_edit_task(clear_preview=False) + 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._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._update_action_states() + QApplication.processEvents() + + def _forget_edit_thread(self) -> None: + self.edit_thread = None + self.edit_worker = None + + def _make_operation_record( + self, + operation_name: str, + target: str, + parameters: dict[str, object], + result_message: str, + before_stats, + 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, + target_logical_id: int | None = None, + pick_position: tuple[float, float, float] | None = None, + before_snapshot: dict[int, object] | None = None, + after_snapshot: dict[int, object] | None = None, + ) -> OperationRecord: + target_summary = target + if target_kind in {"face", "feature"} and target_logical_id is not None: + target_summary = f"{target_kind} logical {target_logical_id}" + if target_id is not None and target_id != target_logical_id: + target_summary += f" (topology {target_id})" + summary_parts = [operation_name, target_summary] + if "distance" in parameters: + summary_parts.append(f"distance={_format_value(parameters['distance'])}") + if "semantic_distance" in parameters: + summary_parts.append(f"distance={_format_value(parameters['semantic_distance'])}") + if "new_diameter" in parameters: + summary_parts.append(f"diameter={_format_value(parameters['new_diameter'])}") + summary = " | ".join(summary_parts) + + lines = [ + f"operation: {operation_name}", + f"target: {target}", + f"target_kind: {target_kind or ''}", + f"target_id: {target_id if target_id is not None else ''}", + "parameters:", + ] + if target_logical_id is not None: + lines.insert(4, f"target_logical_id: {target_logical_id}") + if target_id is not None and target_id != target_logical_id: + lines.insert(5, f"target_topological_id_at_operation: {target_id}") + if pick_position is not None: + insert_at = 6 if target_logical_id is not None and target_id is not None and target_id != target_logical_id else 5 if target_logical_id is not None else 4 + lines.insert(insert_at, 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:", + f" solids: {before_stats.solids}", + f" faces: {before_stats.faces}", + f" edges: {before_stats.edges}", + "topology after:", + f" solids: {after_stats.solids} ({_signed_delta(after_stats.solids - before_stats.solids)})", + f" faces: {after_stats.faces} ({_signed_delta(after_stats.faces - before_stats.faces)})", + f" edges: {after_stats.edges} ({_signed_delta(after_stats.edges - before_stats.edges)})", + "geometry before:", + f" volume: {_format_value(before_geometry.get('volume', ''))}", + f" surface_area: {_format_value(before_geometry.get('surface_area', ''))}", + f" bbox_size: {_format_value(before_geometry.get('bbox_size', ''))}", + f" bbox_diagonal: {_format_value(before_geometry.get('bbox_diagonal', ''))}", + "geometry after:", + f" volume: {_format_after_delta(before_geometry, after_geometry, 'volume')}", + f" surface_area: {_format_after_delta(before_geometry, after_geometry, 'surface_area')}", + f" bbox_size: {_format_after_delta(before_geometry, after_geometry, 'bbox_size')}", + f" bbox_diagonal: {_format_after_delta(before_geometry, after_geometry, 'bbox_diagonal')}", + "result:", + f" {result_message}", + ] + ) + return OperationRecord( + summary=summary, + detail="\n".join(lines), + target_kind=target_kind, + target_id=target_id, + target_logical_id=target_logical_id, + pick_position=pick_position, + before_snapshot=before_snapshot, + after_snapshot=after_snapshot, + ) + diff --git a/step_editor/window_core.py b/step_editor/window_core.py new file mode 100644 index 0000000..1225737 --- /dev/null +++ b/step_editor/window_core.py @@ -0,0 +1,2007 @@ +from __future__ import annotations + +from datetime import datetime +import json +import math +from pathlib import Path + +import vtk +from PySide6.QtCore import QEvent, Qt, QThread, QTimer, Slot +from PySide6.QtWidgets import ( + QApplication, + QFileDialog, + QMessageBox, + QTableWidgetItem, + QTreeWidgetItem, +) + +from .model import StepModel +from .records import OperationRecord +from .ui_helpers import * # noqa: F403 +from .workers import EditWorker, LoadWorker, ScanWorker + + +class WindowCoreMixin: + def eventFilter(self, watched, event): + if watched is getattr(self, "vtk_widget", None): + event_type = event.type() + if event_type == QEvent.Type.MouseButtonRelease: + self.pointer_button_down = bool(event.buttons() != Qt.MouseButton.NoButton) + elif event_type == QEvent.Type.MouseButtonPress: + self.pointer_button_down = True + elif event_type == QEvent.Type.MouseMove: + self.pointer_button_down = bool(event.buttons() != Qt.MouseButton.NoButton) + if not self.pointer_button_down: + self._queue_hover_from_qt_event(event) + return super().eventFilter(watched, event) + + def _build_vtk(self) -> None: + self.renderer = vtk.vtkRenderer() + self.renderer.SetBackground(0.11, 0.13, 0.15) + self.render_window = self.vtk_widget.GetRenderWindow() + self.render_window.AddRenderer(self.renderer) + + self.interactor = self.render_window.GetInteractor() + self.interactor.SetInteractorStyle(vtk.vtkInteractorStyleTrackballCamera()) + self.picker = vtk.vtkCellPicker() + self.picker.SetTolerance(0.003) + self.interactor.AddObserver("LeftButtonPressEvent", self.on_left_click) + self.interactor.AddObserver("LeftButtonReleaseEvent", self.on_pointer_button_release) + self.interactor.AddObserver("MiddleButtonPressEvent", self.on_pointer_button_press) + self.interactor.AddObserver("MiddleButtonReleaseEvent", self.on_pointer_button_release) + self.interactor.AddObserver("RightButtonPressEvent", self.on_pointer_button_press) + self.interactor.AddObserver("RightButtonReleaseEvent", self.on_pointer_button_release) + self.interactor.AddObserver("MouseMoveEvent", self.on_mouse_move) + + light = vtk.vtkLight() + light.SetLightTypeToSceneLight() + light.SetPosition(1, 1, 1) + light.SetIntensity(0.9) + self.renderer.AddLight(light) + + self.interactor.Initialize() + + def closeEvent(self, event) -> None: + if self.load_in_progress: + self.statusBar().showMessage("STEP background loading is still running.") + event.ignore() + return + if self.operation_in_progress: + self.statusBar().showMessage("后台编辑正在计算,请等待当前操作完成后再关闭窗口。") + event.ignore() + return + if self.scan_in_progress: + self.statusBar().showMessage("后台扫描正在进行,请等待扫描完成后再关闭窗口。") + event.ignore() + return + super().closeEvent(event) + + def load_step(self, path: str | Path, *, background: bool = True) -> None: + self._load_step_background_or_sync(path, background=background) + return + + @staticmethod + def _load_step_result( + path: Path, + deflection: float, + show_internal_edges: bool, + build_polydata: bool = True, + ) -> dict[str, object]: + new_model = StepModel.load(path) + stats = new_model.stats() + result = { + "path": path, + "model": new_model, + "stats": stats, + "deflection": deflection, + "show_internal_edges": show_internal_edges, + } + if build_polydata: + result["model_polydata"] = new_model.build_face_polydata(deflection=deflection) + result["edge_polydata"] = new_model.build_edge_polydata( + deflection=deflection, + show_same_domain_internal_edges=show_internal_edges, + ) + return result + + def _load_step_background_or_sync(self, path: str | Path, *, background: bool) -> None: + if self.load_in_progress: + self.statusBar().showMessage("STEP background loading is already running.") + return + new_path = Path(path) + if not background: + self.statusBar().showMessage(f"Loading {new_path.name}...") + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + QApplication.processEvents() + try: + result = self._load_step_result( + new_path, + deflection=0.8, + show_internal_edges=self._show_same_domain_internal_edges(), + ) + except Exception as exc: + QMessageBox.critical(self, "Load failed", str(exc)) + self.statusBar().showMessage("STEP load failed; current model was left unchanged.") + return + finally: + QApplication.restoreOverrideCursor() + self._apply_loaded_model_result(result, reset_camera=True) + self.statusBar().showMessage(f"Loaded {self.step_path.name}") + return + + self.load_in_progress = True + self.pending_load_path = new_path + self.statusBar().showMessage(f"Loading {new_path.name} in background...") + self._clear_hover(render=True) + self._update_action_states() + + def action(): + return self._load_step_result( + new_path, + deflection=self.initial_load_deflection, + show_internal_edges=True, + build_polydata=False, + ) + + thread = QThread(self) + worker = LoadWorker(action) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(self._finish_initial_load) + worker.failed.connect(self._fail_initial_load) + worker.finished.connect(thread.quit) + worker.failed.connect(thread.quit) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._forget_load_thread) + self.load_thread = thread + self.load_worker = worker + thread.start() + + def _apply_loaded_model_result(self, result: dict[str, object], *, reset_camera: bool) -> None: + new_path = Path(result["path"]) + stats = result["stats"] + self.model = result["model"] + self.step_path = new_path + self._clear_history() + if hasattr(self, "measure_text"): + self.clear_measurement() + self.path_label.setText(str(self.step_path)) + self._populate_part_tree() + self._reset_selection() + model_polydata = result.get("model_polydata") + edge_polydata = result.get("edge_polydata") + if model_polydata is None or edge_polydata is None: + deflection = float(result.get("deflection", 0.8)) + show_internal_edges = bool(result.get("show_internal_edges", self._show_same_domain_internal_edges())) + model_polydata = self.model.build_face_polydata(deflection=deflection) + edge_polydata = self.model.build_edge_polydata( + deflection=deflection, + show_same_domain_internal_edges=show_internal_edges, + ) + self._rebuild_scene_from_polydata( + model_polydata, + edge_polydata, + reset_camera=reset_camera, + ) + self._clear_editable_candidates() + self._clear_cylinder_candidates() + self.set_info( + { + "file": str(self.step_path), + "parts": stats.parts, + "solids": stats.solids, + "faces": stats.faces, + "edges": stats.edges, + "vertices": stats.vertices, + "display": "quick preview" if self.load_in_progress else "ready", + } + ) + self._update_action_states() + + def _finish_initial_load(self, result: object) -> None: + try: + if not isinstance(result, dict): + raise RuntimeError("Load task returned an unexpected result.") + self._apply_loaded_model_result(result, reset_camera=True) + self.statusBar().showMessage(f"Loaded {self.step_path.name}") + self._end_load_task() + except Exception as exc: + self._end_load_task() + QMessageBox.critical(self, "Load failed", str(exc)) + self.statusBar().showMessage("STEP load failed; current model was left unchanged.") + + def _fail_initial_load(self, message: str) -> None: + self._end_load_task() + QMessageBox.critical(self, "Load failed", message) + self.statusBar().showMessage("STEP load failed; current model was left unchanged.") + + def _start_load_refine(self, initial_result: dict[str, object]) -> None: + model = initial_result.get("model") + if model is None or model is not self.model: + self._end_load_task() + return + path = Path(initial_result["path"]) + stats = initial_result["stats"] + show_internal_edges = self._show_same_domain_internal_edges() + + def action(): + return { + "path": path, + "model": model, + "stats": stats, + "model_polydata": model.build_face_polydata(deflection=0.8), + "edge_polydata": model.build_edge_polydata( + deflection=0.8, + show_same_domain_internal_edges=show_internal_edges, + ), + "show_internal_edges": show_internal_edges, + } + + thread = QThread(self) + worker = LoadWorker(action) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(self._finish_load_refine) + worker.failed.connect(self._fail_load_refine) + worker.finished.connect(thread.quit) + worker.failed.connect(thread.quit) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._forget_load_refine_thread) + self.load_refine_thread = thread + self.load_refine_worker = worker + thread.start() + + def _finish_load_refine(self, result: object) -> None: + try: + if isinstance(result, dict) and result.get("model") is self.model: + self._rebuild_scene_from_polydata( + result["model_polydata"], + result["edge_polydata"], + reset_camera=False, + ) + stats = result["stats"] + self.set_info( + { + "file": str(self.step_path), + "parts": stats.parts, + "solids": stats.solids, + "faces": stats.faces, + "edges": stats.edges, + "vertices": stats.vertices, + "display": "ready", + } + ) + self.statusBar().showMessage(f"Loaded {self.step_path.name}") + finally: + self._end_load_task() + + def _fail_load_refine(self, message: str) -> None: + self._end_load_task() + self.statusBar().showMessage(f"Quick preview is available; display refinement failed: {message}") + + def _end_load_task(self) -> None: + self.load_in_progress = False + self.pending_load_path = None + self._update_action_states() + + def _forget_load_thread(self) -> None: + self.load_thread = None + self.load_worker = None + + def _forget_load_refine_thread(self) -> None: + self.load_refine_thread = None + self.load_refine_worker = None + + def open_step(self) -> None: + if self._edit_busy("请等待当前编辑完成后再打开文件。"): + return + path, _ = QFileDialog.getOpenFileName( + self, + "打开 STEP 文件", + str(self.step_path.parent if self.step_path else Path.cwd()), + "STEP 文件 (*.step *.stp);;所有文件 (*.*)", + ) + if path: + self.load_step(path) + + def reload_step(self) -> None: + if self._edit_busy("请等待当前编辑完成后再重新加载。"): + return + self.load_step(self.step_path) + + def _clear_history(self) -> None: + self.undo_stack.clear() + self.redo_stack.clear() + self.operation_history.clear() + self.redo_history.clear() + self.clear_diff_preview(render=False) + if hasattr(self, "history_list"): + self.history_list.clear() + + def _refresh_history_list(self) -> None: + was_blocked = self.history_list.blockSignals(True) + try: + self.history_list.clear() + for index, entry in enumerate(self.operation_history, start=1): + self.history_list.addItem(f"{index}. {entry.summary}") + finally: + 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) + diff_message = self._show_operation_diff(record) + detail = record.detail + if locate_message: + detail = f"{detail}\n\n{locate_message}" + if diff_message: + detail = f"{detail}\n\n{diff_message}" + self.set_plain_info(detail) + + 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, "未选择历史记录", "请先选择一条操作历史。") + return + record = self.operation_history[row] + self._ensure_record_diff_stats(record) + target, _ = QFileDialog.getSaveFileName( + self, + "导出差异报告", + str(self.step_path.parent / f"{self.step_path.stem}_diff_{row + 1}.txt"), + "文本文件 (*.txt);;所有文件 (*.*)", + ) + if not target: + return + report = self._diff_report_text(record, row + 1) + try: + Path(target).write_text(report, encoding="utf-8") + except Exception as exc: + QMessageBox.critical(self, "导出失败", str(exc)) + self.statusBar().showMessage("差异报告导出失败") + return + self.statusBar().showMessage(f"已导出差异报告 {Path(target).name}") + self.set_plain_info(f"已导出差异报告:{target}\n\n{report}") + + def export_operation_history(self) -> None: + if self.model is None: + return + if self._edit_busy("编辑计算中,暂时不能导出编辑历史。"): + return + if not self.operation_history: + QMessageBox.information(self, "没有编辑历史", "当前模型还没有可导出的编辑历史。") + return + target, _ = QFileDialog.getSaveFileName( + self, + "导出编辑历史", + str(self.step_path.parent / f"{self.step_path.stem}_operation_history.json"), + "JSON 文件 (*.json);;所有文件 (*.*)", + ) + if not target: + return + + def safe_value(value): + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, tuple): + return [safe_value(item) for item in value] + if isinstance(value, list): + return [safe_value(item) for item in value] + if isinstance(value, dict): + return {str(key): safe_value(item) for key, item in value.items()} + return str(value) + + records = [] + for index, record in enumerate(self.operation_history, start=1): + records.append( + { + "index": index, + "summary": record.summary, + "detail": record.detail, + "target_kind": record.target_kind, + "target_id": record.target_id, + "target_logical_id": record.target_logical_id, + "pick_position": safe_value(record.pick_position), + "diff_stats": safe_value(record.diff_stats), + "has_before_snapshot": record.before_snapshot is not None, + "has_after_snapshot": record.after_snapshot is not None, + } + ) + payload = { + "format": "step-editor-operation-history-v1", + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "source_file": str(self.step_path), + "record_count": len(records), + "records": records, + "note": "STEP 通常不包含原 CAD 参数化建模历史;这里导出的是本软件加载后执行的编辑记录。", + } + try: + Path(target).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception as exc: + QMessageBox.critical(self, "导出失败", str(exc)) + self.statusBar().showMessage("编辑历史导出失败") + return + self.statusBar().showMessage(f"已导出编辑历史 {Path(target).name}") + self.set_plain_info(f"已导出编辑历史:{target}\n\n记录数:{len(records)}") + + def _populate_part_tree(self) -> None: + was_blocked = self.part_tree.blockSignals(True) + try: + self.part_tree.clear() + if self.model is None: + return + inserted: dict[int, QTreeWidgetItem] = {} + for part in self.model.parts: + 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) + else: + parent.addChild(item) + inserted[part.id] = item + + for solid_id, (part_id, _solid) in enumerate(self.model.solids): + parent = inserted.get(part_id) + if parent is None: + continue + 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() + self.part_tree.resizeColumnToContents(0) + self.part_tree.resizeColumnToContents(1) + 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 _part_tree_kind_label(kind) + try: + info = self.model.part_info(part_id) + except Exception: + 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 "实体" + try: + info = self.model.solid_info(solid_id) + except Exception: + 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: + return + model_polydata = self.model.build_face_polydata() + edge_polydata = self.model.build_edge_polydata( + show_same_domain_internal_edges=self._show_same_domain_internal_edges() + ) + self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=reset_camera) + + def _show_same_domain_internal_edges(self) -> bool: + return bool(self.show_internal_edges_checkbox and self.show_internal_edges_checkbox.isChecked()) + + def _on_internal_edges_toggled(self, checked: bool) -> None: + if self.model is None or self.operation_in_progress or self.scan_in_progress or self.load_in_progress: + return + if self.scene_isolated and self.selected_kind is not None: + self.isolate_selected() + else: + self._rebuild_scene(reset_camera=False) + self._refresh_selection_highlight() + state = "显示" if checked else "隐藏" + self.statusBar().showMessage(f"已{state}同域内部拓扑边") + + 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.model.connected_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id] + edge_ids = self.model.face_region_boundary_edge_ids(self.selected_face_id) + 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, + show_same_domain_internal_edges=self._show_same_domain_internal_edges(), + ) + 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], + show_same_domain_internal_edges=self._show_same_domain_internal_edges(), + ) + 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_ids = self.model.connected_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id] + face_polydata = self.model.build_face_polydata(face_ids=face_ids) + 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: + face_ids = self.model.connected_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id] + self._highlight_faces(face_ids=face_ids) + elif self.selected_kind == "edge" and self.selected_edge_id is not None: + self._highlight_edge(self.selected_edge_id) + + def _clear_overlay_polydata_cache(self) -> None: + self.face_overlay_polydata_cache.clear() + self.edge_overlay_polydata_cache.clear() + + def _remember_overlay_cache_item(self, cache: dict, key: object, value: object) -> object: + if len(cache) >= self.overlay_cache_limit: + try: + cache.pop(next(iter(cache))) + except StopIteration: + pass + cache[key] = value + return value + + def _cached_face_overlay_polydata(self, face_ids=None, part_ids=None, smooth: bool = True): + if self.model is None or self.model_polydata is None: + return None + face_key = _int_tuple_or_none(face_ids) + part_key = _int_tuple_or_none(part_ids) + key = (face_key, part_key, bool(smooth)) + cached = self.face_overlay_polydata_cache.get(key) + if cached is not None: + return cached + polydata = self._extract_visible_face_polydata(face_key, part_key) + return self._remember_overlay_cache_item(self.face_overlay_polydata_cache, key, polydata) + + def _cached_edge_overlay_polydata(self, edge_id: int): + if self.model is None or self.edge_polydata is None: + return None + key = int(edge_id) + cached = self.edge_overlay_polydata_cache.get(key) + if cached is not None: + return cached + polydata = self._extract_visible_edge_polydata(key) + return self._remember_overlay_cache_item(self.edge_overlay_polydata_cache, key, polydata) + + def _extract_visible_face_polydata(self, face_ids=None, part_ids=None): + if self.model_polydata is None: + return None + face_set = set(face_ids) if face_ids is not None else None + part_set = set(part_ids) if part_ids is not None else None + face_arr = self.model_face_id_array + part_arr = self.model_part_id_array + if face_arr is None or part_arr is None: + return None + ids = vtk.vtkIdList() + for cell_id in range(self.model_polydata.GetNumberOfCells()): + if face_set is not None and int(face_arr.GetValue(cell_id)) not in face_set: + continue + if part_set is not None and int(part_arr.GetValue(cell_id)) not in part_set: + continue + ids.InsertNextId(cell_id) + return self._extract_cells_as_polydata(self.model_polydata, ids) + + def _extract_visible_edge_polydata(self, edge_id: int): + if self.edge_polydata is None or self.edge_id_array is None: + return None + ids = vtk.vtkIdList() + for cell_id in range(self.edge_polydata.GetNumberOfCells()): + if int(self.edge_id_array.GetValue(cell_id)) == edge_id: + ids.InsertNextId(cell_id) + return self._extract_cells_as_polydata(self.edge_polydata, ids) + + def _extract_cells_as_polydata(self, source, ids): + if ids.GetNumberOfIds() == 0: + return None + extract = vtk.vtkExtractCells() + extract.SetInputData(source) + extract.SetCellList(ids) + extract.Update() + geometry = vtk.vtkGeometryFilter() + geometry.SetInputConnection(extract.GetOutputPort()) + geometry.Update() + polydata = vtk.vtkPolyData() + polydata.ShallowCopy(geometry.GetOutput()) + return polydata + + def _rebuild_scene_from_polydata(self, model_polydata, edge_polydata, reset_camera: bool = False) -> None: + self._clear_overlay_polydata_cache() + self.renderer.RemoveAllViewProps() + self.renderer.SetBackground(0.11, 0.13, 0.15) + self.scene_isolated = False + + self.model_polydata = _smooth_surface_polydata(model_polydata) + self.model_face_id_array = self.model_polydata.GetCellData().GetArray("face_id") + self.model_part_id_array = self.model_polydata.GetCellData().GetArray("part_id") + self.model_solid_id_array = self.model_polydata.GetCellData().GetArray("solid_id") + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputData(self.model_polydata) + self.model_actor = vtk.vtkActor() + self.model_actor.SetMapper(mapper) + self.model_actor.GetProperty().SetColor(0.68, 0.72, 0.73) + self.model_actor.GetProperty().SetDiffuse(0.82) + self.model_actor.GetProperty().SetSpecular(0.25) + self.model_actor.GetProperty().SetSpecularPower(18) + self.model_actor.GetProperty().SetInterpolationToPhong() + self.renderer.AddActor(self.model_actor) + + self.edge_polydata = edge_polydata + self.edge_id_array = self.edge_polydata.GetCellData().GetArray("edge_id") + edge_mapper = vtk.vtkPolyDataMapper() + edge_mapper.SetInputData(self.edge_polydata) + self.edge_actor = vtk.vtkActor() + self.edge_actor.SetMapper(edge_mapper) + self.edge_actor.GetProperty().SetColor(0.08, 0.09, 0.1) + self.edge_actor.GetProperty().SetLineWidth(1.0) + self.renderer.AddActor(self.edge_actor) + + 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.last_hover_pick_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: + self.pointer_button_down = True + self.pending_hover_position = None + self.last_hover_pick_position = None + self.hover_timer.stop() + if self.load_in_progress: + self.statusBar().showMessage("STEP background loading is still running.") + return + if self.operation_in_progress: + self.statusBar().showMessage("编辑计算中,请等待当前操作完成") + return + if self.scan_in_progress: + self.statusBar().showMessage("后台扫描中,请等待扫描完成后再选择对象。") + return + if self.model is None: + return + mode = self.mode_combo.currentText() + x, y = self.interactor.GetEventPosition() + target = self._pick_selection_target(mode, x, y) + if target is None: + self.statusBar().showMessage("未选中对象") + return + + self._clear_hover(render=False) + self._select_pick_target(target) + + def on_pointer_button_press(self, _obj, _event) -> None: + self.pointer_button_down = True + self.pending_hover_position = None + self.last_hover_pick_position = None + self.hover_timer.stop() + self._clear_hover(render=True) + + def on_pointer_button_release(self, _obj, _event) -> None: + self.pointer_button_down = False + self.pending_hover_position = None + self.last_hover_pick_position = None + + def on_mouse_move(self, _obj, _event) -> None: + if QApplication.mouseButtons() != Qt.MouseButton.NoButton: + self.pointer_button_down = True + return + self.pointer_button_down = False + if ( + self.operation_in_progress + or self.scan_in_progress + or self.load_in_progress + or self.model is None + or self.model_actor is None + ): + self._clear_hover(render=True) + return + x, y = self.interactor.GetEventPosition() + self._queue_hover_position(int(x), int(y)) + + def _queue_hover_from_qt_event(self, event) -> None: + if ( + self.operation_in_progress + or self.scan_in_progress + or self.load_in_progress + or self.model is None + or self.model_actor is None + ): + self._clear_hover(render=True) + return + position = event.position() if hasattr(event, "position") else event.pos() + scale = self.vtk_widget._getPixelRatio() if hasattr(self.vtk_widget, "_getPixelRatio") else 1.0 + x = int(round(float(position.x()) * scale)) + y = int(round((float(self.vtk_widget.height()) - float(position.y()) - 1.0) * scale)) + self._queue_hover_position(x, y) + + def _queue_hover_position(self, x: int, y: int) -> None: + self.pending_hover_position = (int(x), int(y)) + if not self.hover_timer.isActive(): + self.hover_timer.start(self.hover_interval_ms) + + def _update_hover_target(self) -> None: + if ( + self.operation_in_progress + or self.scan_in_progress + or self.load_in_progress + or self.model is None + or self.model_actor is None + or self.pending_hover_position is None + ): + self._clear_hover(render=True) + return + x, y = self.pending_hover_position + self.last_hover_pick_position = (x, y) + target = self._pick_selection_target(self.mode_combo.currentText(), x, y) + 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": + 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_face_id_array + part_arr = self.model_part_id_array + solid_arr = self.model_solid_id_array + 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_id_array + 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(target_id, pick_position=pick_position) + + def _select_face_cell( + self, + cell_id: int, + mode: str, + pick_position: tuple[float, float, float] | None = None, + ) -> None: + if self.model is None or self.model_polydata is None: + return + 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") + face_id = int(face_arr.GetValue(cell_id)) + part_id = int(part_arr.GetValue(cell_id)) + solid_id = int(solid_arr.GetValue(cell_id)) + + if mode == "Part": + self.select_part(part_id, pick_position=pick_position) + elif mode == "Solid": + self.select_solid(solid_id, part_id, pick_position=pick_position) + elif mode == "Feature": + self.select_feature(face_id, pick_position=pick_position) + else: + self.select_face(face_id, pick_position=pick_position) + + def _select_edge_from_cell( + self, + cell_id: int, + pick_position: tuple[float, float, float] | None = None, + ) -> None: + if self.model is None or self.edge_polydata is None: + return + edge_arr = self.edge_polydata.GetCellData().GetArray("edge_id") + edge_id = int(edge_arr.GetValue(cell_id)) + 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) + target_id = current.data(0, PART_TREE_ID_ROLE) + if node_kind == "solid" and target_id is not None: + part_id = current.data(0, PART_TREE_PART_ID_ROLE) + if part_id is not None: + self.mode_combo.setCurrentText("Solid") + self.select_solid(int(target_id), int(part_id)) + return + if node_kind in {"part", "assembly"} and target_id is not None: + self.mode_combo.setCurrentText("Part") + self.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 + face_id = item.data(Qt.UserRole) + if face_id is None: + return + self.mode_combo.setCurrentText("Feature") + 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 + target_id = item.data(EDITABLE_TARGET_ID_ROLE) + target_kind = item.data(EDITABLE_TARGET_KIND_ROLE) + action = item.data(EDITABLE_ACTION_ROLE) + if target_id is None: + return + if action == "resize_cylinder": + self.mode_combo.setCurrentText("Feature") + 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 == "resize_slot_width": + 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(target_id)) + self.statusBar().showMessage(f"已选择可推拉平面 face {target_id}") + + def select_by_id(self, kind: str | None = None) -> None: + if self.model is None: + return + if self._edit_busy("编辑计算中,暂时不能切换选择对象。"): + return + try: + target_id = int(self.id_input.text()) + except ValueError: + QMessageBox.information(self, "ID 无效", "请输入整数 ID。Part ID 从 1 开始,Solid/Face/Edge ID 从 0 开始。") + return + + kind = kind or self.mode_combo.currentText() + try: + if kind == "Part": + if self.model.part_by_id(target_id) is None: + raise ValueError(f"不存在 Part ID {target_id}") + self.mode_combo.setCurrentText("Part") + self.select_part(target_id) + elif kind == "Solid": + if target_id < 0 or target_id >= len(self.model.solids): + raise ValueError(f"不存在 Solid ID {target_id}") + part_id = self.model.solids[target_id][0] + self.mode_combo.setCurrentText("Solid") + self.select_solid(target_id, part_id) + elif kind == "Face": + resolved_face_id = self.model.resolve_face_selection_id(target_id) + if resolved_face_id is None: + raise ValueError(f"不存在 Face/逻辑 Face ID {target_id}") + self.mode_combo.setCurrentText("Face") + self.select_face(resolved_face_id) + elif kind == "Feature": + resolved_face_id = self.model.resolve_face_selection_id(target_id) + if resolved_face_id is None: + raise ValueError(f"不存在 Feature 来源 Face/逻辑 Face ID {target_id}") + self.mode_combo.setCurrentText("Feature") + self.select_feature(resolved_face_id) + elif kind == "Edge": + if target_id < 0 or target_id >= len(self.model.edges): + raise ValueError(f"不存在 Edge ID {target_id}") + self.mode_combo.setCurrentText("Edge") + self.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" + self.selected_part_id = part_id + self.selected_pick_position = pick_position + self._highlight_faces(part_ids=[part_id]) + self._show_pick_marker(pick_position) + self._sync_id_picker("Part", part_id) + self.set_info(self._with_pick_info(info, 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, + solid_id: int, + part_id: int, + pick_position: tuple[float, float, float] | None = None, + ) -> 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" + self.selected_part_id = part_id + self.selected_solid_id = solid_id + self.selected_pick_position = pick_position + face_ids = [i for i, sid in enumerate(self.model.face_solid_ids) if sid == solid_id] + self._highlight_faces(face_ids=face_ids) + 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( + self, + face_id: int, + feature_mode: bool = False, + pick_position: tuple[float, float, float] | None = None, + ) -> 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 + self.selected_pick_position = pick_position + info = self.model.face_info(face_id) + if feature_mode: + info["feature_mode"] = "当前是几何候选判断,不等同于 CAD 历史特征" + highlight_face_ids = [face_id] + else: + highlight_face_ids = self.model.connected_same_domain_face_ids(face_id) + if len(highlight_face_ids) > 1: + info["same_domain_face_ids"] = tuple(highlight_face_ids) + info["same_domain_face_count"] = len(highlight_face_ids) + info["same_domain_note"] = "已高亮属于同一几何面且范围相接/重叠的连续 face 区域。" + logical_id = self.model.face_region_logical_id(face_id) + 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=highlight_face_ids or [face_id]) + self._show_pick_marker(pick_position) + self._sync_id_picker("Feature" if feature_mode else "Face", face_id if feature_mode else logical_id) + self.set_info(self._with_pick_info(info, pick_position)) + self._update_action_states() + suffix = f",同域区域 {len(highlight_face_ids)} 个 face" if not feature_mode and len(highlight_face_ids) > 1 else "" + raw_note = f"(拓扑 face {face_id})" if not feature_mode and logical_id != face_id else "" + message = f"已选择逻辑面区域 {logical_id}{raw_note}{suffix}" if not feature_mode else f"已选择 face {face_id}" + self.statusBar().showMessage(self._selection_status(message, 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() + if hasattr(self, "slot_width_input"): + self.slot_width_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() + if hasattr(self, "slot_width_input"): + self.slot_width_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() + if hasattr(self, "slot_width_input"): + slot_width = _float_or_none(info.get("slot_chord_width_estimate")) + if slot_width is not None and slot_width > 0: + self.slot_width_input.setText(_format_float(slot_width * 1.2)) + else: + self.slot_width_input.clear() + else: + self.hole_diameter_input.clear() + if hasattr(self, "slot_width_input"): + self.slot_width_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, "hole_bottom_face_input"): + bottom_face_ids = _int_values(info.get("feature_bottom_face_ids")) + if bottom_face_ids: + self.hole_bottom_face_input.setText(str(bottom_face_ids[0])) + else: + self.hole_bottom_face_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() + if hasattr(self, "slot_width_input"): + self.slot_width_input.clear() + self.boss_diameter_input.clear() + self.hole_depth_input.clear() + if hasattr(self, "hole_bottom_face_input"): + self.hole_bottom_face_input.clear() + if "length" in info: + length = float(info["length"]) + self.edge_target_length_input.setText(_format_float(length)) + else: + self.edge_target_length_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))) + else: + self.edge_fillet_radius_input.clear() + self.edge_chamfer_distance_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: + if self.model is None: + return + self._clear_highlight() + polydata = self._cached_face_overlay_polydata(face_ids=face_ids, part_ids=part_ids, smooth=True) + if polydata is None: + return + 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.82) + actor.GetProperty().SetAmbient(0.45) + actor.GetProperty().SetDiffuse(0.65) + actor.GetProperty().SetSpecular(0.35) + actor.GetProperty().SetInterpolationToPhong() + actor.GetProperty().SetLineWidth(2) + self._offset_overlay_actor_toward_camera(actor, scale=0.00035) + self.highlight_actor = actor + self.renderer.AddActor(actor) + self.render_window.Render() + + def _highlight_edge(self, edge_id: int) -> None: + if self.model is None: + return + self._clear_highlight() + polydata = self._cached_edge_overlay_polydata(edge_id) + if polydata is None: + return + mapper = vtk.vtkDataSetMapper() + 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 _hover_signature_for_target(self, target: dict[str, object] | None) -> tuple[str, int] | None: + if self.model is None or target is None: + return None + kind = str(target["kind"]) + target_id = int(target["target_id"]) + if kind in {"face", "feature"} and 0 <= target_id < len(self.model.faces): + try: + return (kind, self.model.face_region_logical_id(target_id)) + except Exception: + return (kind, target_id) + return (kind, target_id) + + def _show_hover_target(self, target: dict[str, object] | None) -> None: + if self.model is None: + self._clear_hover(render=True) + return + signature = self._hover_signature_for_target(target) + 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: + face_ids = self.model.connected_same_domain_face_ids(target_id) + self._highlight_hover_faces(face_ids=face_ids or [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._cached_face_overlay_polydata(face_ids=face_ids, part_ids=part_ids, smooth=False) + if polydata is None: + return + 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().SetInterpolationToPhong() + actor.GetProperty().LightingOff() + actor.GetProperty().SetLineWidth(2) + self._offset_overlay_actor_toward_camera(actor, scale=0.00055) + self.hover_face_actor = actor + self.renderer.AddActor(actor) + + def _offset_overlay_actor_toward_camera(self, actor, scale: float = 0.0005) -> None: + bounds = self.model_actor.GetBounds() if self.model_actor is not None else None + camera = self.renderer.GetActiveCamera() if hasattr(self, "renderer") else None + if bounds is None or camera is None: + return + dx = float(bounds[1] - bounds[0]) + dy = float(bounds[3] - bounds[2]) + dz = float(bounds[5] - bounds[4]) + diagonal = math.sqrt(dx * dx + dy * dy + dz * dz) + if diagonal <= 1e-9: + return + direction = camera.GetDirectionOfProjection() + amount = diagonal * float(scale) + actor.SetPosition( + -float(direction[0]) * amount, + -float(direction[1]) * amount, + -float(direction[2]) * amount, + ) + + def _highlight_hover_edge(self, edge_id: int) -> None: + if self.model is None: + return + polydata = self._cached_edge_overlay_polydata(edge_id) + if polydata is None: + return + 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) + self.highlight_actor = None + if self.edge_highlight_actor is not None: + self.renderer.RemoveActor(self.edge_highlight_actor) + self.edge_highlight_actor = None + if self.pick_marker_actor is not None: + self.renderer.RemoveActor(self.pick_marker_actor) + self.pick_marker_actor = None + + def clear_edit_preview(self, render: bool = True) -> None: + if self.edit_preview_timer is not None: + self.edit_preview_timer.stop() + 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 + self.clear_edit_preview(render=False) + try: + polydata = self.model.push_pull_preview_polydata(face_id, distance) + except Exception as exc: + self.statusBar().showMessage(f"推拉预览不可用:{exc}") + return + if distance >= 0: + color = (0.0, 0.86, 0.34) + else: + 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, + bottom_face_id: int | None = None, + ) -> 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, + bottom_face_id=bottom_face_id, + ) + 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, anchor_mode: str = "auto") -> 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, anchor_mode=anchor_mode) + except Exception as exc: + self.statusBar().showMessage(f"边长直接修改预览不可用:{exc}") + return + if isinstance(polydata, list): + for preview in polydata: + role = str(preview.get("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) + else: + 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_ids=[edge_id]) + 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() + + def _start_edit_preview_pulse(self) -> None: + if self.edit_preview_timer is None: + self.edit_preview_timer = QTimer(self) + self.edit_preview_timer.timeout.connect(self._pulse_edit_preview) + self.edit_preview_phase = 0.0 + self.edit_preview_timer.start(120) + + def _pulse_edit_preview(self) -> 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) + for actor in self.edit_preview_actors: + actor.GetProperty().SetOpacity(opacity) + self.render_window.Render() + + def clear_diff_preview(self, render: bool = True) -> None: + if not hasattr(self, "renderer"): + self.diff_actors.clear() + return + for actor in self.diff_actors: + self.renderer.RemoveViewProp(actor) + self.diff_actors.clear() + if render and hasattr(self, "render_window"): + self.render_window.Render() + self.statusBar().showMessage("已清除差异预览") + + def _show_operation_diff(self, record: OperationRecord) -> str: + if self.model is None: + return "" + self.clear_diff_preview(render=False) + if record.before_snapshot is None or record.after_snapshot is None: + return "差异预览: 这条历史记录没有可显示的前后模型快照。" + + before_polydata = self.model.build_snapshot_polydata(record.before_snapshot) + after_polydata = self.model.build_snapshot_polydata(record.after_snapshot) + before_actor = self._make_diff_actor(before_polydata, color=(1.0, 0.16, 0.08), opacity=0.24) + after_actor = self._make_diff_actor(after_polydata, color=(0.0, 0.9, 0.28), opacity=0.16) + heatmap_actor, scalar_bar, heatmap_stats = self._make_distance_heatmap_props(before_polydata, after_polydata) + self.diff_actors = [before_actor, after_actor] + if heatmap_actor is not None: + self.diff_actors.append(heatmap_actor) + if scalar_bar is not None: + self.diff_actors.append(scalar_bar) + for actor in self.diff_actors: + self.renderer.AddViewProp(actor) + self.render_window.Render() + if heatmap_stats: + record.diff_stats = heatmap_stats + return ( + "差异预览: 红色半透明为编辑前,绿色半透明为编辑后;" + "热力图覆盖在编辑后模型上,蓝色接近无变化,黄色/红色表示变化更大。\n" + f"热力图统计: max_distance={_format_value(heatmap_stats['max_distance'])}, " + f"mean_distance={_format_value(heatmap_stats['mean_distance'])}, " + f"changed_points={heatmap_stats['changed_points']}/{heatmap_stats['points']} " + f"({_format_value(heatmap_stats['changed_ratio'] * 100.0)}%)." + ) + return "差异预览: 红色半透明为编辑前,绿色半透明为编辑后;热力图无法生成。" + + def _ensure_record_diff_stats(self, record: OperationRecord) -> dict[str, object]: + if record.diff_stats is not None: + return record.diff_stats + if self.model is None or record.before_snapshot is None or record.after_snapshot is None: + record.diff_stats = {} + return record.diff_stats + before_polydata = self.model.build_snapshot_polydata(record.before_snapshot) + after_polydata = self.model.build_snapshot_polydata(record.after_snapshot) + _heat_polydata, stats = self._build_distance_heatmap_polydata(before_polydata, after_polydata) + record.diff_stats = stats + return stats + + def _diff_report_text(self, record: OperationRecord, history_index: int) -> str: + lines = [ + "STEP 编辑差异报告", + "", + f"generated_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + f"source_file: {self.step_path}", + f"history_index: {history_index}", + f"summary: {record.summary}", + "", + "操作详情:", + record.detail, + "", + "距离热力图统计:", + ] + stats = record.diff_stats or {} + if stats: + lines.extend( + [ + f" points: {stats.get('points', '')}", + f" max_distance: {_format_value(stats.get('max_distance', ''))}", + f" mean_distance: {_format_value(stats.get('mean_distance', ''))}", + f" changed_points: {stats.get('changed_points', '')}", + f" changed_ratio: {_format_value(float(stats.get('changed_ratio', 0.0)) * 100.0)}%", + f" changed_threshold: {_format_value(stats.get('changed_threshold', ''))}", + ] + ) + else: + lines.append(" unavailable") + lines.extend( + [ + "", + "说明:", + " 热力图使用“编辑后模型顶点到编辑前模型表面”的距离估算。", + " 它适合做可视化诊断和修改留档,不等同于完整 CAD 公差报告。", + ] + ) + return "\n".join(lines) + + def _make_diff_actor(self, polydata, color: tuple[float, float, float], opacity: float): + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputData(polydata) + actor = vtk.vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetColor(*color) + actor.GetProperty().SetOpacity(opacity) + actor.GetProperty().SetSpecular(0.15) + actor.GetProperty().SetLineWidth(1) + return actor + + def _make_distance_heatmap_props(self, before_polydata, after_polydata): + heat_polydata, stats = self._build_distance_heatmap_polydata(before_polydata, after_polydata) + if heat_polydata is None: + return None, None, {} + max_distance = float(stats["max_distance"]) + + lut = self._make_heatmap_lookup_table(max_distance) + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputData(heat_polydata) + mapper.SetLookupTable(lut) + mapper.SetScalarRange(0.0, max(max_distance, 1e-9)) + mapper.SetScalarModeToUsePointData() + mapper.ScalarVisibilityOn() + + actor = vtk.vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetOpacity(0.82) + actor.GetProperty().SetSpecular(0.22) + actor.GetProperty().SetSpecularPower(16) + + scalar_bar = vtk.vtkScalarBarActor() + scalar_bar.SetLookupTable(lut) + scalar_bar.SetTitle("distance") + scalar_bar.SetNumberOfLabels(4) + scalar_bar.SetWidth(0.08) + scalar_bar.SetHeight(0.32) + scalar_bar.SetPosition(0.89, 0.05) + scalar_bar.GetTitleTextProperty().SetColor(1.0, 1.0, 1.0) + scalar_bar.GetLabelTextProperty().SetColor(1.0, 1.0, 1.0) + + return actor, scalar_bar, stats + + def _build_distance_heatmap_polydata(self, before_polydata, after_polydata): + point_count = after_polydata.GetNumberOfPoints() + if before_polydata.GetNumberOfPoints() == 0 or point_count == 0: + return None, {} + heat_polydata = vtk.vtkPolyData() + heat_polydata.DeepCopy(after_polydata) + distance = vtk.vtkImplicitPolyDataDistance() + distance.SetInput(before_polydata) + + values = vtk.vtkFloatArray() + values.SetName("edit_distance") + values.SetNumberOfValues(point_count) + max_distance = 0.0 + total_distance = 0.0 + raw_values: list[float] = [] + for point_id in range(point_count): + point = heat_polydata.GetPoint(point_id) + value = abs(float(distance.EvaluateFunction(point))) + raw_values.append(value) + values.SetValue(point_id, value) + total_distance += value + max_distance = max(max_distance, value) + + heat_polydata.GetPointData().SetScalars(values) + changed_threshold = max(max_distance * 0.01, 1e-6) + changed_points = sum(1 for value in raw_values if value > changed_threshold) + stats = { + "points": point_count, + "max_distance": max_distance, + "mean_distance": total_distance / point_count, + "changed_points": changed_points, + "changed_ratio": changed_points / point_count, + "changed_threshold": changed_threshold, + } + return heat_polydata, stats + + def _make_heatmap_lookup_table(self, max_distance: float): + lut = vtk.vtkLookupTable() + lut.SetNumberOfTableValues(256) + lut.SetRange(0.0, max(max_distance, 1e-9)) + lut.Build() + stops = [ + (0.0, (0.08, 0.16, 0.85)), + (0.35, (0.0, 0.72, 1.0)), + (0.7, (1.0, 0.9, 0.08)), + (1.0, (1.0, 0.08, 0.02)), + ] + for index in range(256): + t = index / 255.0 + left = stops[0] + right = stops[-1] + for stop_index in range(len(stops) - 1): + if stops[stop_index][0] <= t <= stops[stop_index + 1][0]: + left = stops[stop_index] + right = stops[stop_index + 1] + break + span = max(right[0] - left[0], 1e-9) + local_t = (t - left[0]) / span + color = tuple(left[1][axis] + (right[1][axis] - left[1][axis]) * local_t for axis in range(3)) + lut.SetTableValue(index, color[0], color[1], color[2], 1.0) + return lut + diff --git a/step_editor/window_state.py b/step_editor/window_state.py new file mode 100644 index 0000000..9ca985f --- /dev/null +++ b/step_editor/window_state.py @@ -0,0 +1,465 @@ +from __future__ import annotations + +from datetime import datetime +import math +from pathlib import Path + +import vtk +from PySide6.QtCore import Qt, QThread, QTimer, Slot +from PySide6.QtWidgets import ( + QApplication, + QFileDialog, + QMessageBox, + QTableWidgetItem, + QTreeWidgetItem, +) + +from .model import StepModel +from .records import OperationRecord +from .ui_helpers import * # noqa: F403 +from .workers import EditWorker, LoadWorker, ScanWorker + + +class WindowStateMixin: + def _reset_selection(self, clear_highlight: bool = True) -> None: + self.selected_kind = None + self.selected_part_id = None + self.selected_solid_id = None + self.selected_face_id = None + self.selected_edge_id = None + 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: + return + radius = self._pick_marker_radius() + sphere = vtk.vtkSphereSource() + sphere.SetCenter(*pick_position) + sphere.SetRadius(radius) + sphere.SetThetaResolution(20) + sphere.SetPhiResolution(12) + + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputConnection(sphere.GetOutputPort()) + actor = vtk.vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetColor(0.1, 0.95, 0.95) + actor.GetProperty().SetSpecular(0.4) + actor.GetProperty().SetSpecularPower(18) + self.pick_marker_actor = actor + self.renderer.AddActor(actor) + self.render_window.Render() + + def _pick_marker_radius(self) -> float: + bounds = self.model_actor.GetBounds() if self.model_actor is not None else None + if bounds is None: + return 1.0 + dx = bounds[1] - bounds[0] + dy = bounds[3] - bounds[2] + dz = bounds[5] - bounds[4] + diagonal = math.sqrt(dx * dx + dy * dy + dz * dz) + return max(diagonal * 0.004, 0.1) + + def _with_pick_info( + self, + info: dict[str, object], + pick_position: tuple[float, float, float] | None, + ) -> dict[str, object]: + if pick_position is None: + return info + enriched = dict(info) + enriched["pick_position"] = pick_position + return enriched + + def _selection_status(self, message: str, pick_position: tuple[float, float, float] | None) -> str: + if pick_position is None: + return message + return f"{message},拾取点 {_format_value(pick_position)}" + + def _edit_busy(self, message: str = "编辑计算中,请等待当前操作完成。") -> bool: + if self.load_in_progress: + self.statusBar().showMessage("STEP background loading is still running.") + return True + if self.operation_in_progress: + self.statusBar().showMessage(message) + return True + if self.scan_in_progress: + self.statusBar().showMessage("后台扫描中,请等待扫描完成后再执行该操作。") + return True + return False + + def _sync_id_picker(self, kind: str, target_id: int) -> None: + 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 + and not self.scan_in_progress + and not self.load_in_progress + ) + selected_kind = self.selected_kind + self.export_all_button.setEnabled(has_model) + self.export_check_button.setEnabled(has_model) + if hasattr(self, "repair_model_button"): + self._set_control_state( + self.repair_model_button, + has_model, + "对当前完整模型执行 ShapeFix 和同域面/边合并,并写入撤销历史。", + "请先加载 STEP 文件,或等待当前后台任务完成。", + ) + self._set_control_state( + self.repair_selected_button, + has_model and (self.selected_solid_id is not None or self.selected_part_id is not None), + "优先修复当前选中对象所属 solid;没有 solid 时修复所属零件,并写入撤销历史。", + "请先选择 part、solid、face 或 edge,或等待当前后台任务完成。", + ) + 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() if has_model else {} + 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_slot_or_half_hole = ( + is_hole_or_groove + and angular_span is not None + and angular_span < math.tau * 0.92 + and _float_or_none(action_info.get("slot_chord_width_estimate")) is not None + ) + is_blind = action_info.get("cylinder_end_type") == "blind" + has_bottom = bool(_int_values(action_info.get("feature_bottom_face_ids"))) + has_manual_bottom = bool( + hasattr(self, "hole_bottom_face_input") + and self.hole_bottom_face_input.text().strip() + ) + 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。", + ) + if hasattr(self, "resize_slot_button"): + self._set_control_state( + self.resize_slot_button, + has_model and is_slot_or_half_hole, + "按目标槽宽调整当前槽/半孔候选;第一版会换算为对应圆柱直径后执行。", + "请先选择被识别为槽/半孔的部分圆柱 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) or has_manual_bottom), + "调整当前盲孔/盲槽深度;自动底面不稳定时可手动填写底面 Face ID。", + "请先选择孔/槽圆柱面;如果没有自动识别到底面,请填写底面 Face ID。", + ) + 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 has_edge, + "直接修改当前 edge 的长度;直线优先端面推拉,其他 edge 使用高风险几何 fallback。", + "请先选择一条 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 文件。", + ) + if hasattr(self, "editable_refresh_button"): + self._set_control_state( + self.editable_refresh_button, + has_model, + "扫描第一版可编辑对象。", + "请先加载 STEP 文件,或等待当前后台任务完成。", + ) + self._set_control_state( + self.editable_deep_scan_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) + tip = enabled_tip if enabled else disabled_tip + if hasattr(self, "_set_help_tip"): + self._set_help_tip(widget, tip) + else: + widget.setToolTip(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 "" + self._reset_selection(clear_highlight=True) + located = False + locator_note = "" + + if record.target_kind in {"face", "feature"} and record.target_id is not None: + lookup_id = record.target_logical_id if record.target_logical_id is not None else record.target_id + resolved_face_id = self.model.resolve_face_selection_id(lookup_id) + if resolved_face_id is not None: + info = self.model.feature_info(resolved_face_id) if record.target_kind == "feature" else self.model.face_info(resolved_face_id) + self.selected_kind = "feature" if record.target_kind == "feature" else "face" + self.selected_face_id = resolved_face_id + self.selected_part_id = int(info["part_id"]) + self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None + self.selected_pick_position = record.pick_position + self.mode_combo.setCurrentText("Feature" if record.target_kind == "feature" else "Face") + current_logical_id = self.model.face_region_logical_id(resolved_face_id) + self._sync_id_picker("Feature" if record.target_kind == "feature" else "Face", current_logical_id) + face_ids = ( + _int_values(info.get("feature_highlight_face_ids")) + if record.target_kind == "feature" + else self.model.face_region_ids(resolved_face_id) + ) + if not face_ids: + face_ids = self.model.face_region_ids(resolved_face_id) + self._highlight_faces(face_ids=face_ids) + located = True + region_note = f";同域面区域 {len(face_ids)} 个 face" if len(face_ids) > 1 else "" + id_note = ( + f"逻辑 Face ID {current_logical_id}" + if record.target_logical_id is not None + else f"face {record.target_id}" + ) + if resolved_face_id != record.target_id: + id_note += f"(当前拓扑 face {resolved_face_id})" + locator_note = ( + f"定位: 已尝试高亮当前模型中的 {id_note}{region_note}。" + "布尔编辑后 face ID 可能发生语义变化,请结合拾取点确认。" + ) + else: + if record.target_logical_id is not None: + locator_note = f"定位: 原目标逻辑 Face ID {record.target_logical_id} 在当前模型索引中已经不存在。" + 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: + locator_note = "定位: 已显示当时记录的拾取点。" + elif located: + locator_note += f"\n拾取点: {_format_value(record.pick_position)}" + else: + locator_note += f"\n已显示当时记录的拾取点: {_format_value(record.pick_position)}" + + 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[-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._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[-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._refresh_history_list() + self._update_action_states() + self.statusBar().showMessage(f"已重做:{redone.summary}") + + def _restore_snapshot(self, snapshot: dict[int, object]) -> None: + if self.model is None: + return + 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() + stats = self.model.stats() + self.set_info( + { + "parts": stats.parts, + "solids": stats.solids, + "faces": stats.faces, + "edges": stats.edges, + "vertices": stats.vertices, + } + ) + + 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 "模型已恢复到操作前状态,历史记录未移动。" + diff --git a/step_editor/workers.py b/step_editor/workers.py new file mode 100644 index 0000000..e9d921a --- /dev/null +++ b/step_editor/workers.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from PySide6.QtCore import QObject, Signal, Slot + + +class EditWorker(QObject): + finished = Signal(object) + failed = Signal(str) + + def __init__(self, action): + super().__init__() + self.action = action + + @Slot() + def run(self) -> None: + try: + self.finished.emit(self.action()) + except Exception as exc: + self.failed.emit(str(exc)) + + +class ScanWorker(QObject): + finished = Signal(object) + failed = Signal(str) + + def __init__(self, action): + super().__init__() + self.action = action + + @Slot() + def run(self) -> None: + try: + self.finished.emit(self.action()) + except Exception as exc: + self.failed.emit(str(exc)) + + +class LoadWorker(QObject): + finished = Signal(object) + failed = Signal(str) + + def __init__(self, action): + super().__init__() + self.action = action + + @Slot() + def run(self) -> None: + try: + self.finished.emit(self.action()) + except Exception as exc: + self.failed.emit(str(exc)) diff --git a/step_model.py b/step_model.py deleted file mode 100644 index c58b456..0000000 --- a/step_model.py +++ /dev/null @@ -1,4616 +0,0 @@ -from __future__ import annotations - -import math -import re -from dataclasses import dataclass -from pathlib import Path -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_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 -from OCC.Core.Bnd import Bnd_Box -from OCC.Core.GeomAbs import ( - GeomAbs_BSplineCurve, - GeomAbs_BSplineSurface, - GeomAbs_BezierCurve, - GeomAbs_BezierSurface, - GeomAbs_Circle, - GeomAbs_Cone, - GeomAbs_Cylinder, - GeomAbs_Ellipse, - GeomAbs_Hyperbola, - GeomAbs_Line, - GeomAbs_OffsetSurface, - GeomAbs_OtherCurve, - GeomAbs_OtherSurface, - GeomAbs_Parabola, - GeomAbs_Plane, - GeomAbs_Sphere, - GeomAbs_SurfaceOfExtrusion, - GeomAbs_SurfaceOfRevolution, - GeomAbs_Torus, -) -from OCC.Core.GProp import GProp_GProps -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 ( - TopAbs_EDGE, - TopAbs_EXTERNAL, - TopAbs_FACE, - TopAbs_FORWARD, - TopAbs_IN, - TopAbs_INTERNAL, - TopAbs_OUT, - TopAbs_REVERSED, - TopAbs_SOLID, -) -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_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec -from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge - - -SURFACE_TYPES = { - GeomAbs_Plane: "plane", - GeomAbs_Cylinder: "cylinder", - GeomAbs_Cone: "cone", - GeomAbs_Sphere: "sphere", - GeomAbs_Torus: "torus", - GeomAbs_BezierSurface: "bezier surface", - GeomAbs_BSplineSurface: "b-spline surface", - GeomAbs_SurfaceOfRevolution: "surface of revolution", - GeomAbs_SurfaceOfExtrusion: "surface of extrusion", - GeomAbs_OffsetSurface: "offset surface", - GeomAbs_OtherSurface: "other surface", -} - -CURVE_TYPES = { - GeomAbs_Line: "line", - GeomAbs_Circle: "circle", - GeomAbs_Ellipse: "ellipse", - GeomAbs_Hyperbola: "hyperbola", - GeomAbs_Parabola: "parabola", - GeomAbs_BezierCurve: "bezier curve", - GeomAbs_BSplineCurve: "b-spline curve", - GeomAbs_OtherCurve: "other curve", -} - -ORIENTATION_TYPES = { - TopAbs_FORWARD: "forward", - TopAbs_REVERSED: "reversed", - TopAbs_INTERNAL: "internal", - TopAbs_EXTERNAL: "external", -} - - -@dataclass -class PartNode: - id: int - name: str - kind: str - shape: TopoDS_Shape - parent_id: int | None = None - depth: int = 0 - path: str = "" - - -@dataclass -class TopologyStats: - parts: int - solids: int - faces: int - edges: int - vertices: int - - -class StepModel: - def __init__(self, filename: Path, parts: list[PartNode], shape: TopoDS_Shape): - self.filename = filename - self.parts = parts - self.shape = shape - self.faces: list[TopoDS_Shape] = [] - self.face_part_ids: list[int] = [] - 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 - def load(cls, filename: str | Path) -> "StepModel": - path = Path(filename) - if not path.exists(): - raise FileNotFoundError(path) - - product_names = _parse_product_names(path) - parts, whole_shape = _load_with_xcaf(path, product_names) - if not parts or whole_shape.IsNull(): - whole_shape = _load_plain_step(path) - fallback_name = product_names[0] if product_names else path.stem - parts = [PartNode(1, fallback_name, "part", whole_shape, path=fallback_name)] - return cls(path, parts, whole_shape) - - def display_parts(self) -> list[PartNode]: - leaf_parts = [p for p in self.parts if p.kind == "part" and not p.shape.IsNull()] - if leaf_parts: - return leaf_parts - return [p for p in self.parts if not p.shape.IsNull()] - - def stats(self) -> TopologyStats: - topo = TopologyExplorer(self.shape, ignore_orientation=True) - return TopologyStats( - parts=len(self.display_parts()), - solids=len(list(topo.solids())), - faces=len(list(topo.faces())), - edges=len(list(topo.edges())), - 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) - info: dict[str, object] = { - "surface_area": surface_props.Mass(), - "surface_center": _point_tuple(surface_props.CentreOfMass()), - } - info.update(_shape_bounds_info(self.shape)) - info.update(_shape_volume_info(self.shape)) - return info - - def refresh_topology(self) -> None: - self.shape = _compound_from_shapes([p.shape for p in self.display_parts()]) - self.faces.clear() - self.face_part_ids.clear() - 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(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) - - 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) - - def snapshot(self) -> dict[int, TopoDS_Shape]: - return {part.id: part.shape for part in self.parts} - - def restore_snapshot(self, snapshot: dict[int, TopoDS_Shape]) -> None: - for part in self.parts: - if part.id in snapshot: - part.shape = snapshot[part.id] - 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) - - surf = BRepAdaptor_Surface(face) - surface_type = surf.GetType() - boundary_edges = len(list(TopologyExplorer(face, ignore_orientation=True).edges())) - info: dict[str, object] = { - "kind": "face", - "face_id": face_id, - "part_id": self.face_part_ids[face_id], - "solid_id": self.face_solid_ids[face_id], - "orientation": _orientation_name(face.Orientation()), - "surface": SURFACE_TYPES.get(surface_type, f"type {surface_type}"), - "area": props.Mass(), - "area_center": _point_tuple(props.CentreOfMass()), - "u_range": (surf.FirstUParameter(), surf.LastUParameter()), - "v_range": (surf.FirstVParameter(), surf.LastVParameter()), - "boundary_edges": boundary_edges, - } - info.update(_shape_bounds_info(face)) - if surface_type == GeomAbs_Plane: - plane = surf.Plane() - direction = plane.Axis().Direction() - push_pull_direction = self._plane_push_pull_direction(face_id, surf) - info["plane_origin"] = _point_tuple(plane.Location()) - info["normal"] = _dir_tuple(direction) - info["oriented_normal"] = _oriented_dir_tuple(direction, face) - info["push_pull_outward_direction"] = push_pull_direction["outward_direction"] - info["push_pull_inward_direction"] = push_pull_direction["inward_direction"] - info["push_pull_plus_side"] = push_pull_direction["plus_side_state"] - info["push_pull_minus_side"] = push_pull_direction["minus_side_state"] - info["push_pull_confidence"] = push_pull_direction["confidence"] - info["push_pull_note"] = push_pull_direction["note"] - elif surface_type == GeomAbs_Cylinder: - cyl = surf.Cylinder() - axis = cyl.Axis() - radius = cyl.Radius() - u_span = abs(surf.LastUParameter() - surf.FirstUParameter()) - swept_area = max(radius * max(u_span, 1e-9), 1e-9) - classification = self._classify_cylindrical_face(face_id, surf, detailed=True) - info["radius"] = cyl.Radius() - info["diameter"] = cyl.Radius() * 2.0 - info["axis_point"] = _point_tuple(axis.Location()) - info["axis"] = _dir_tuple(axis.Direction()) - info["angular_span"] = u_span - info["is_full_cylinder"] = u_span >= math.tau * 0.98 - info["height_estimate"] = props.Mass() / swept_area - info["feature_guess"] = classification["feature_guess"] - info["confidence"] = classification["confidence"] - info["material_toward_axis"] = classification["toward_axis"] - info["material_away_axis"] = classification["away_axis"] - info["material_vote_summary"] = classification["vote_summary"] - info["material_sample_count"] = classification["sample_count"] - 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()) - info["axis"] = _dir_tuple(cone.Axis().Direction()) - info["reference_radius"] = cone.RefRadius() - info["semi_angle"] = cone.SemiAngle() - elif surface_type == GeomAbs_Sphere: - sphere = surf.Sphere() - info["center"] = _point_tuple(sphere.Location()) - info["radius"] = sphere.Radius() - info["diameter"] = sphere.Radius() * 2.0 - elif surface_type == GeomAbs_Torus: - torus = surf.Torus() - info["center"] = _point_tuple(torus.Location()) - info["axis"] = _dir_tuple(torus.Axis().Direction()) - info["major_radius"] = torus.MajorRadius() - info["minor_radius"] = torus.MinorRadius() - 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) - - curve = BRepAdaptor_Curve(edge) - curve_type = curve.GetType() - info: dict[str, object] = { - "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(), - "length_center": _point_tuple(props.CentreOfMass()), - "first_parameter": curve.FirstParameter(), - "last_parameter": curve.LastParameter(), - "start_point": _point_tuple(curve.Value(curve.FirstParameter())), - "end_point": _point_tuple(curve.Value(curve.LastParameter())), - } - info.update(_shape_bounds_info(edge)) - if curve_type == GeomAbs_Line: - line = curve.Line() - info["line_origin"] = _point_tuple(line.Location()) - info["direction"] = _dir_tuple(line.Direction()) - if curve_type == GeomAbs_Circle: - circle = curve.Circle() - info["center"] = _point_tuple(circle.Location()) - info["axis"] = _dir_tuple(circle.Axis().Direction()) - info["radius"] = circle.Radius() - info["diameter"] = circle.Radius() * 2.0 - 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): - raise ValueError(f"Unknown solid id {solid_id}") - part_id, solid = self.solids[solid_id] - topo = TopologyExplorer(solid, ignore_orientation=True) - surface_props = GProp_GProps() - brepgprop.SurfaceProperties(solid, surface_props) - info: dict[str, object] = { - "kind": "solid", - "solid_id": solid_id, - "part_id": part_id, - "faces": len(list(topo.faces())), - "edges": len(list(topo.edges())), - "vertices": len(list(topo.vertices())), - "surface_area": surface_props.Mass(), - "surface_center": _point_tuple(surface_props.CentreOfMass()), - } - info.update(_shape_bounds_info(solid)) - info.update(_shape_volume_info(solid)) - return info - - def part_info(self, part_id: int) -> dict[str, object]: - 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) - info: dict[str, object] = { - "kind": part.kind, - "part_id": part.id, - "name": part.name, - "path": part.path, - "parent_id": part.parent_id if part.parent_id is not None else "", - "depth": part.depth, - "solids": len(list(topo.solids())), - "faces": len(list(topo.faces())), - "edges": len(list(topo.edges())), - "vertices": len(list(topo.vertices())), - } - info.update(_shape_bounds_info(part.shape)) - info.update(_shape_volume_info(part.shape)) - return info - - def editable_feature_candidates( - self, - limit: int = 160, - detailed: bool = False, - progress_callback: Callable[[], None] | None = None, - ) -> list[dict[str, object]]: - 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=cylinder_scan_limit, - include_end_info=True, - progress_callback=progress_callback, - ): - 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): - if progress_callback is not None and face_id % 30 == 0: - progress_callback() - if plane_count >= per_type_limit: - break - surf = BRepAdaptor_Surface(face) - if surf.GetType() != GeomAbs_Plane: - continue - props = GProp_GProps() - brepgprop.SurfaceProperties(face, props) - if detailed: - direction_info = self._plane_push_pull_direction(face_id, surf) - confidence = str(direction_info["confidence"]) - risk = "low" if confidence == "high" else "medium" - status = "ready" if confidence == "high" else "caution" - note = str(direction_info["note"]) - else: - confidence = "pending" - risk = "medium" - status = "caution" - note = "快速扫描:推拉方向会在选中 face 或执行编辑前再详细判断。" - candidates.append( - { - "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], - "surface": "plane", - "feature_guess": "planar push/pull candidate", - "current_value": props.Mass(), - "current_value_label": "area", - "status": status, - "risk": risk, - "confidence": confidence, - "note": note, - } - ) - 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, - "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.get("target_id", item.get("face_id", item.get("edge_id", -1)))), - ) - ) - return candidates[:limit] - - def cylindrical_feature_candidates( - self, - limit: int = 100, - include_end_info: bool = False, - progress_callback: Callable[[], None] | None = None, - ) -> list[dict[str, object]]: - candidates: list[dict[str, object]] = [] - for face_id, face in enumerate(self.faces): - if progress_callback is not None and face_id % 30 == 0: - progress_callback() - surf = BRepAdaptor_Surface(face) - if surf.GetType() != GeomAbs_Cylinder: - continue - cyl = surf.Cylinder() - props = GProp_GProps() - brepgprop.SurfaceProperties(face, props) - radius = cyl.Radius() - u_span = abs(surf.LastUParameter() - surf.FirstUParameter()) - v_span = abs(surf.LastVParameter() - surf.FirstVParameter()) - swept_area = max(radius * max(u_span, 1e-9), 1e-9) - height_estimate = props.Mass() / swept_area - boundary_edges = len(list(TopologyExplorer(face, ignore_orientation=True).edges())) - classification = self._classify_cylindrical_face(face_id, surf) - candidate = { - "face_id": face_id, - "part_id": self.face_part_ids[face_id], - "solid_id": self.face_solid_ids[face_id], - "radius": radius, - "diameter": radius * 2.0, - "axis": _dir_tuple(cyl.Axis().Direction()), - "area": props.Mass(), - "angular_span": u_span, - "height_estimate": height_estimate, - "param_height": v_span, - "boundary_edges": boundary_edges, - "feature_guess": classification["feature_guess"], - "material_toward_axis": classification["toward_axis"], - "material_away_axis": classification["away_axis"], - "material_vote_summary": classification["vote_summary"], - "material_sample_count": classification["sample_count"], - "confidence": classification["confidence"], - "note": classification["note"], - } - 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 - return candidates - - def cylindrical_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_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) - 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"], - "risk": readiness["resize_risk"], - "message": readiness["resize_note"], - "warnings": readiness["resize_warnings"], - "blockers": readiness["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_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"), - "height_estimate": info.get("height_estimate"), - "material_vote_summary": info.get("material_vote_summary"), - "material_sample_count": info.get("material_sample_count"), - **cutter_plan, - **fill_plan, - } - - 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: - return { - "cutter_strategy": "unavailable", - "cutter_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, 0.0) - end_info = self._cylinder_end_opening_info(face_id, surf) - base_margin = min(max(new_radius * 0.05, abs(new_radius - old_radius) * 0.5, 0.02), max(span * 0.05, 0.2)) - 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) - axis = cyl.Axis() - direction = axis.Direction() - axis_point = axis.Location() - start = gp_Pnt( - axis_point.X() + direction.X() * start_parameter, - axis_point.Y() + direction.Y() * start_parameter, - axis_point.Z() + direction.Z() * start_parameter, - ) - return { - "cutter_strategy": "bounded-to-selected-cylinder-v-range", - "cutter_note": "有限长度切削:按选中圆柱面的 V 参数范围生成 cutter,减少贯穿整个零件的误切风险。", - "cutter_start_parameter": start_parameter, - "cutter_end_parameter": end_parameter, - "cutter_height": height, - "cutter_margin": base_margin, - "cutter_start_margin": start_margin, - "cutter_end_margin": end_margin, - "cutter_radius": new_radius, - "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, - } - - def _bounded_cylinder_fill_plan(self, face_id: int) -> dict[str, object]: - face = self.faces[face_id] - surf = BRepAdaptor_Surface(face) - if surf.GetType() != GeomAbs_Cylinder: - return { - "fill_strategy": "unavailable", - "fill_note": "selected face is not cylindrical", - } - - cyl = surf.Cylinder() - radius = cyl.Radius() - v1 = surf.FirstVParameter() - v2 = surf.LastVParameter() - v_min = min(v1, v2) - v_max = max(v1, v2) - height = max(v_max - v_min, 1e-6) - overlap = min(max(radius * 0.001, 0.001), 0.05) - axis = cyl.Axis() - direction = axis.Direction() - axis_point = axis.Location() - start = _point_on_axis(axis_point, direction, v_min) - return { - "fill_strategy": "bounded-fill-then-recut", - "fill_note": "缩小孔径实验策略:先在原圆柱面范围内补料,再按目标直径重切。补料不向开口端外伸。", - "fill_start_parameter": v_min, - "fill_end_parameter": v_max, - "fill_height": height, - "fill_radius": radius + overlap, - "fill_radius_overlap": overlap, - "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 = { - "cylinder_end_type": "unknown", - "hole_depth_estimate": abs(surf.LastVParameter() - surf.FirstVParameter()), - "start_end_state": "unknown", - "end_end_state": "unknown", - "start_end_open": False, - "end_end_open": False, - "open_end_count": 0, - "closed_end_count": 0, - "end_sample_offset": "", - "end_sample_note": "no owning solid was found", - } - if solid_id < 0 or solid_id >= len(self.solids): - return fallback - - solid = self.solids[solid_id][1] - cyl = surf.Cylinder() - axis = cyl.Axis() - axis_point = axis.Location() - direction = axis.Direction() - v1 = surf.FirstVParameter() - v2 = surf.LastVParameter() - v_min = min(v1, v2) - v_max = max(v1, v2) - span = max(v_max - v_min, 0.0) - radius = cyl.Radius() - offset = min(max(radius * 0.08, span * 0.02, 0.05), max(span * 0.25, 0.2)) - - start_probe = _point_on_axis(axis_point, direction, v_min - offset) - end_probe = _point_on_axis(axis_point, direction, v_max + offset) - start_state = _solid_state(solid, start_probe) - end_state = _solid_state(solid, end_probe) - start_open = start_state == "outside" - end_open = end_state == "outside" - start_closed = start_state == "inside" - end_closed = end_state == "inside" - open_count = int(start_open) + int(end_open) - closed_count = int(start_closed) + int(end_closed) - - if open_count == 2: - end_type = "through/open-ended" - note = "both axis-end probes are outside material" - elif open_count == 1 and closed_count == 1: - end_type = "blind" - note = "one axis-end probe is outside material and the other is inside material" - elif closed_count == 2: - end_type = "closed/internal" - note = "both axis-end probes are inside material" - else: - end_type = "unclear" - note = "axis-end probes did not produce a clear open/closed pattern" - - return { - "cylinder_end_type": end_type, - "hole_depth_estimate": span, - "start_end_state": start_state, - "end_end_state": end_state, - "start_end_open": start_open, - "end_end_open": end_open, - "open_end_count": open_count, - "closed_end_count": closed_count, - "end_sample_offset": offset, - "end_sample_note": note, - } - - def _classify_cylindrical_face( - self, - face_id: int, - surf: BRepAdaptor_Surface, - detailed: bool = False, - ) -> dict[str, object]: - solid_id = self.face_solid_ids[face_id] - if solid_id < 0 or solid_id >= len(self.solids): - return { - "feature_guess": "cylindrical face", - "toward_axis": "unknown", - "away_axis": "unknown", - "vote_summary": "hole=0, boss=0, unclear=0", - "sample_count": 0, - "confidence": "low", - "note": "no owning solid was found", - } - - solid = self.solids[solid_id][1] - radius = surf.Cylinder().Radius() - angular_span = abs(surf.LastUParameter() - surf.FirstUParameter()) - boundary_edges = len(list(TopologyExplorer(self.faces[face_id], ignore_orientation=True).edges())) - 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: - return { - "feature_guess": "cylindrical face", - "toward_axis": "unknown", - "away_axis": "unknown", - "vote_summary": "hole=0, boss=0, unclear=0", - "sample_count": 0, - "confidence": "low", - "note": "could not sample cylinder material sides", - } - - toward_states = [sample["toward"] for sample in samples] - away_states = [sample["away"] for sample in samples] - hole_votes = sum(1 for sample in samples if sample["toward"] == "outside" and sample["away"] == "inside") - boss_votes = sum(1 for sample in samples if sample["toward"] == "inside" and sample["away"] == "outside") - unclear_votes = sample_count - hole_votes - boss_votes - vote_summary = f"hole={hole_votes}, boss={boss_votes}, unclear={unclear_votes}" - threshold = max(1, math.ceil(sample_count * 0.6)) - base = { - "toward_axis": _state_summary(toward_states), - "away_axis": _state_summary(away_states), - "vote_summary": vote_summary, - "sample_count": sample_count, - } - - if hole_votes >= threshold: - confidence = "high" if hole_votes == sample_count and not is_partial_cylinder else "medium" - return { - "feature_guess": "hole/groove candidate", - "confidence": confidence, - "note": "axis side is mostly empty and outer side is mostly material", - **base, - } - - 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", - "note": "partial small-radius cylinder; may be a fillet or blend", - **base, - } - - if boss_votes >= threshold: - return { - "feature_guess": "boss/outer-round candidate", - "confidence": "high" if boss_votes == sample_count and not is_partial_cylinder else "medium", - "note": "axis side is mostly material and outer side is mostly empty", - **base, - } - - return { - "feature_guess": "cylindrical face", - "confidence": "low", - "note": "material sampling did not produce a clear inside/outside pattern", - **base, - } - - def _sample_cylinder_material_states( - self, - surf: BRepAdaptor_Surface, - solid: TopoDS_Shape, - detailed: bool = False, - ) -> list[dict[str, str]]: - cyl = surf.Cylinder() - axis = cyl.Axis() - axis_point = axis.Location() - axis_dir = axis.Direction() - radius = cyl.Radius() - u_first = surf.FirstUParameter() - u_last = surf.LastUParameter() - v = (surf.FirstVParameter() + surf.LastVParameter()) / 2.0 - u_span = u_last - u_first - fractions = [0.5] - if detailed and abs(u_span) > 0.2: - fractions = [0.25, 0.5, 0.75] - - samples: list[dict[str, str]] = [] - for fraction in fractions: - u = u_first + u_span * fraction - point = surf.Value(u, v) - axis_to_point = _vec_from_points(axis_point, point) - projection = _dot(axis_to_point, axis_dir) - center = gp_Pnt( - axis_point.X() + axis_dir.X() * projection, - axis_point.Y() + axis_dir.Y() * projection, - axis_point.Z() + axis_dir.Z() * projection, - ) - radial = _vec_from_points(center, point) - radial_len = radial.Magnitude() - if radial_len <= 1e-9: - continue - - unit = gp_Vec(radial.X() / radial_len, radial.Y() / radial_len, radial.Z() / radial_len) - epsilon = min(max(radius * 0.03, 0.05), 1.0) - toward_point = gp_Pnt( - point.X() - unit.X() * epsilon, - point.Y() - unit.Y() * epsilon, - point.Z() - unit.Z() * epsilon, - ) - away_point = gp_Pnt( - point.X() + unit.X() * epsilon, - point.Y() + unit.Y() * epsilon, - point.Z() + unit.Z() * epsilon, - ) - samples.append( - { - "toward": _solid_state(solid, toward_point), - "away": _solid_state(solid, away_point), - } - ) - return samples - - def _plane_push_pull_direction(self, face_id: int, surf: BRepAdaptor_Surface) -> dict[str, object]: - face = self.faces[face_id] - direction = surf.Plane().Axis().Direction() - axis_tuple = _dir_tuple(direction) - oriented_tuple = _oriented_dir_tuple(direction, face) - fallback = { - "outward_direction": oriented_tuple, - "inward_direction": _neg_tuple(oriented_tuple), - "plus_side_state": "unknown", - "minus_side_state": "unknown", - "confidence": "low", - "note": "falling back to topology-oriented plane normal", - } - - solid_id = self.face_solid_ids[face_id] - if solid_id < 0 or solid_id >= len(self.solids): - fallback["note"] = "no owning solid was found; using topology-oriented plane normal" - return fallback - - solid = self.solids[solid_id][1] - props = GProp_GProps() - brepgprop.SurfaceProperties(face, props) - sample = props.CentreOfMass() - diagonal = _shape_diagonal(solid) - epsilon = min(max(diagonal * 1e-4, 0.05), 1.0) - plus_point = gp_Pnt( - sample.X() + direction.X() * epsilon, - sample.Y() + direction.Y() * epsilon, - sample.Z() + direction.Z() * epsilon, - ) - minus_point = gp_Pnt( - sample.X() - direction.X() * epsilon, - sample.Y() - direction.Y() * epsilon, - sample.Z() - direction.Z() * epsilon, - ) - plus_state = _solid_state(solid, plus_point) - minus_state = _solid_state(solid, minus_point) - - if plus_state == "outside" and minus_state == "inside": - return { - "outward_direction": axis_tuple, - "inward_direction": _neg_tuple(axis_tuple), - "plus_side_state": plus_state, - "minus_side_state": minus_state, - "confidence": "high", - "note": "positive plane normal side is outside material", - } - if plus_state == "inside" and minus_state == "outside": - return { - "outward_direction": _neg_tuple(axis_tuple), - "inward_direction": axis_tuple, - "plus_side_state": plus_state, - "minus_side_state": minus_state, - "confidence": "high", - "note": "negative plane normal side is outside material", - } - - fallback["plus_side_state"] = plus_state - fallback["minus_side_state"] = minus_state - fallback["note"] = "inside/outside sampling was unclear; using topology-oriented plane normal" - return fallback - - def export_all(self, filename: str | Path) -> None: - 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(_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(_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 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.") - - outward = plan["outward_direction"] - vec = gp_Vec( - float(outward[0]) * distance, - float(outward[1]) * distance, - float(outward[2]) * distance, - ) - 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.") - - 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) - 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={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: - return self.resize_cylindrical_hole(face_id, new_diameter) - - def resize_cylindrical_hole(self, face_id: int, new_diameter: float) -> str: - 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("Hole resize currently supports cylindrical faces only.") - - cyl = surf.Cylinder() - old_radius = cyl.Radius() - new_radius = new_diameter / 2.0 - if new_radius <= 0: - raise ValueError("Target diameter must be greater than 0.") - - 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}") - - direction = cyl.Axis().Direction() - source_shape = part.shape - if plan["resize_mode"] == "shrink": - 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) - 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) - 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" - return ( - f"Cylindrical resize completed: diameter {old_radius * 2.0:g} -> {new_diameter:g}, " - f"mode={plan['resize_mode']}, action={action}, " - f"risk={plan['risk']}, feature={plan['feature_guess']}, " - f"cutter={plan['cutter_strategy']}, height={float(plan['cutter_height']):g}." - ) - - 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, - part_ids: Iterable[int] | None = None, - deflection: float = 0.8, - ): - import vtk - - selected_faces = set(face_ids) if face_ids is not None else None - selected_parts = set(part_ids) if part_ids is not None else None - BRepMesh_IncrementalMesh(self.shape, deflection) - - points = vtk.vtkPoints() - polys = vtk.vtkCellArray() - face_arr = vtk.vtkIntArray() - face_arr.SetName("face_id") - part_arr = vtk.vtkIntArray() - part_arr.SetName("part_id") - solid_arr = vtk.vtkIntArray() - solid_arr.SetName("solid_id") - - for face_id, face in enumerate(self.faces): - part_id = self.face_part_ids[face_id] - if selected_faces is not None and face_id not in selected_faces: - continue - if selected_parts is not None and part_id not in selected_parts: - continue - - loc = TopLoc_Location() - tri = BRep_Tool.Triangulation(topods.Face(face), loc) - if tri is None: - continue - transform = loc.Transformation() - node_offset = points.GetNumberOfPoints() - for node_index in range(1, tri.NbNodes() + 1): - pnt = tri.Node(node_index).Transformed(transform) - points.InsertNextPoint(pnt.X(), pnt.Y(), pnt.Z()) - - reversed_face = face.Orientation() == TopAbs_REVERSED - for tri_index in range(1, tri.NbTriangles() + 1): - n1, n2, n3 = tri.Triangle(tri_index).Get() - if reversed_face: - n2, n3 = n3, n2 - vtk_tri = vtk.vtkTriangle() - vtk_tri.GetPointIds().SetId(0, node_offset + n1 - 1) - vtk_tri.GetPointIds().SetId(1, node_offset + n2 - 1) - vtk_tri.GetPointIds().SetId(2, node_offset + n3 - 1) - polys.InsertNextCell(vtk_tri) - face_arr.InsertNextValue(face_id) - part_arr.InsertNextValue(part_id) - solid_arr.InsertNextValue(self.face_solid_ids[face_id]) - - poly = vtk.vtkPolyData() - poly.SetPoints(points) - poly.SetPolys(polys) - poly.GetCellData().AddArray(face_arr) - poly.GetCellData().AddArray(part_arr) - poly.GetCellData().AddArray(solid_arr) - return poly - - def build_snapshot_polydata(self, snapshot: dict[int, TopoDS_Shape], deflection: float = 0.8): - shape = _compound_from_shapes(snapshot.values()) - BRepMesh_IncrementalMesh(shape, deflection) - return _shape_faces_polydata(shape) - - 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() - edge_arr.SetName("edge_id") - part_arr = vtk.vtkIntArray() - 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 - polyline = vtk.vtkPolyLine() - polyline.GetPointIds().SetNumberOfIds(len(samples)) - for i, coords in enumerate(samples): - point_id = points.InsertNextPoint(float(coords[0]), float(coords[1]), float(coords[2])) - polyline.GetPointIds().SetId(i, point_id) - lines.InsertNextCell(polyline) - edge_arr.InsertNextValue(edge_id) - part_arr.InsertNextValue(part_id) - - poly = vtk.vtkPolyData() - poly.SetPoints(points) - poly.SetLines(lines) - poly.GetCellData().AddArray(edge_arr) - poly.GetCellData().AddArray(part_arr) - return poly - - -def _load_with_xcaf(path: Path, product_names: list[str]) -> tuple[list[PartNode], TopoDS_Shape]: - doc = TDocStd_Document("pythonocc-step-document") - shape_tool = XCAFDoc_DocumentTool.ShapeTool(doc.Main()) - - reader = STEPCAFControl_Reader() - reader.SetColorMode(True) - reader.SetLayerMode(True) - reader.SetNameMode(True) - reader.SetMatMode(True) - reader.SetGDTMode(True) - status = reader.ReadFile(str(path)) - if status != IFSelect_RetDone: - raise ValueError(f"Could not read STEP file: {path}") - if not reader.Transfer(doc): - raise ValueError(f"Could not transfer STEP document: {path}") - - parts: list[PartNode] = [] - free_shapes = TDF_LabelSequence() - shape_tool.GetFreeShapes(free_shapes) - - def next_name(label: TDF_Label, index: int) -> str: - label_name = str(label.GetLabelName()).strip() - if label_name: - return label_name - if index - 1 < len(product_names): - return product_names[index - 1] - return f"Part {index}" - - def add_node( - name: str, - kind: str, - shape: TopoDS_Shape, - parent_id: int | None, - depth: int, - path_text: str, - ) -> PartNode: - node = PartNode(len(parts) + 1, name, kind, shape, parent_id, depth, path_text) - parts.append(node) - return node - - def transformed_shape(label: TDF_Label, locations: list[TopLoc_Location]) -> TopoDS_Shape: - shape = shape_tool.GetShape(label) - if shape.IsNull() or not locations: - return shape - location = TopLoc_Location() - for loc in locations: - location = location.Multiplied(loc) - return BRepBuilderAPI_Transform(shape, location.Transformation()).Shape() - - def walk(label: TDF_Label, parent_id: int | None, depth: int, locations: list[TopLoc_Location], path_names: list[str]): - name = next_name(label, len(parts) + 1) - label_path = " / ".join(path_names + [name]) - - if shape_tool.IsAssembly(label): - node = add_node(name, "assembly", transformed_shape(label, locations), parent_id, depth, label_path) - components = TDF_LabelSequence() - shape_tool.GetComponents(label, components) - for i in range(1, components.Length() + 1): - component = components.Value(i) - if shape_tool.IsReference(component): - referred = TDF_Label() - shape_tool.GetReferredShape(component, referred) - loc = shape_tool.GetLocation(component) - walk(referred, node.id, depth + 1, locations + [loc], path_names + [name]) - else: - walk(component, node.id, depth + 1, locations, path_names + [name]) - return - - if shape_tool.IsSimpleShape(label) or shape_tool.IsShape(label): - add_node(name, "part", transformed_shape(label, locations), parent_id, depth, label_path) - - for i in range(1, free_shapes.Length() + 1): - walk(free_shapes.Value(i), None, 0, [], []) - - display_shapes = [p.shape for p in parts if p.kind == "part" and not p.shape.IsNull()] - if not display_shapes: - display_shapes = [p.shape for p in parts if not p.shape.IsNull()] - return parts, _compound_from_shapes(display_shapes) - - -def _load_plain_step(path: Path) -> TopoDS_Shape: - reader = STEPControl_Reader() - status = reader.ReadFile(str(path)) - if status != IFSelect_RetDone: - raise ValueError(f"Could not read STEP file: {path}") - if not reader.TransferRoots(): - raise ValueError(f"Could not transfer STEP roots: {path}") - return reader.Shape() - - -def _parse_product_names(path: Path) -> list[str]: - text = path.read_text(errors="ignore") - names = re.findall(r"PRODUCT\('((?:''|[^'])*)'", text) - return [name.replace("''", "'") for name in names if name.strip()] - - -def _write_step(shape: TopoDS_Shape, filename: Path) -> None: - if shape.IsNull(): - raise ValueError("Cannot export a null shape.") - filename.parent.mkdir(parents=True, exist_ok=True) - Interface_Static.SetCVal("write.step.schema", "AP214IS") - writer = STEPControl_Writer() - writer.Transfer(shape, STEPControl_AsIs) - status = writer.Write(str(filename)) - if status != IFSelect_RetDone: - 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 - - valid_shapes = [shape for shape in shapes if not shape.IsNull()] - if len(valid_shapes) == 1: - return valid_shapes[0] - - compound = TopoDS_Compound() - builder = BRep_Builder() - builder.MakeCompound(compound) - for shape in valid_shapes: - builder.Add(compound, shape) - return compound - - -def _explore(shape: TopoDS_Shape, shape_type: int) -> list[TopoDS_Shape]: - items: list[TopoDS_Shape] = [] - explorer = TopExp_Explorer(shape, shape_type) - while explorer.More(): - current = explorer.Current() - if shape_type == TopAbs_FACE: - items.append(topods.Face(current)) - elif shape_type == TopAbs_EDGE: - items.append(topods.Edge(current)) - elif shape_type == TopAbs_SOLID: - items.append(topods.Solid(current)) - else: - items.append(current) - explorer.Next() - 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 - - points = vtk.vtkPoints() - polys = vtk.vtkCellArray() - for face in _explore(shape, TopAbs_FACE): - loc = TopLoc_Location() - tri = BRep_Tool.Triangulation(topods.Face(face), loc) - if tri is None: - continue - transform = loc.Transformation() - node_offset = points.GetNumberOfPoints() - for node_index in range(1, tri.NbNodes() + 1): - pnt = tri.Node(node_index).Transformed(transform) - points.InsertNextPoint(pnt.X(), pnt.Y(), pnt.Z()) - - reversed_face = face.Orientation() == TopAbs_REVERSED - for tri_index in range(1, tri.NbTriangles() + 1): - n1, n2, n3 = tri.Triangle(tri_index).Get() - if reversed_face: - n2, n3 = n3, n2 - vtk_tri = vtk.vtkTriangle() - vtk_tri.GetPointIds().SetId(0, node_offset + n1 - 1) - vtk_tri.GetPointIds().SetId(1, node_offset + n2 - 1) - vtk_tri.GetPointIds().SetId(2, node_offset + n3 - 1) - polys.InsertNextCell(vtk_tri) - - poly = vtk.vtkPolyData() - poly.SetPoints(points) - poly.SetPolys(polys) - return poly - - -def _shape_bounds(shape: TopoDS_Shape) -> tuple[float, float, float, float, float, float]: - box = Bnd_Box() - brepbndlib.Add(shape, box) - return box.Get() - - -def _shape_bounds_info(shape: TopoDS_Shape) -> dict[str, object]: - xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(shape) - dx = xmax - xmin - dy = ymax - ymin - dz = zmax - zmin - return { - "bbox_min": (xmin, ymin, zmin), - "bbox_max": (xmax, ymax, zmax), - "bbox_size": (dx, dy, dz), - "bbox_diagonal": math.sqrt(dx * dx + dy * dy + dz * dz), - } - - -def _shape_volume_info(shape: TopoDS_Shape) -> dict[str, object]: - props = GProp_GProps() - try: - brepgprop.VolumeProperties(shape, props) - except Exception: - return {"volume": "unavailable"} - volume = props.Mass() - info: dict[str, object] = {"volume": volume} - if abs(volume) > 1e-9: - info["center_of_mass"] = _point_tuple(props.CentreOfMass()) - return info - - -def _shape_diagonal(shape: TopoDS_Shape) -> float: - xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(shape) - 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.") - analyzer = BRepCheck_Analyzer(shape) - if not analyzer.IsValid(): - raise RuntimeError("Operation returned an invalid B-Rep shape.") - - -def _solid_state(solid: TopoDS_Shape, point: gp_Pnt) -> str: - classifier = BRepClass3d_SolidClassifier(solid, point, 1e-6) - state = classifier.State() - if state == TopAbs_IN: - return "inside" - if state == TopAbs_OUT: - return "outside" - return "on/unknown" - - -def _state_summary(states: list[str]) -> str: - if not states: - return "unknown" - counts: dict[str, int] = {} - for state in states: - counts[state] = counts.get(state, 0) + 1 - if len(counts) == 1: - return states[0] - return ", ".join(f"{state}:{count}" for state, count in sorted(counts.items())) - - -def _cylinder_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)) - - if guess == "round/fillet candidate": - risk = "high" - warnings.append("当前圆柱面更像圆角/倒圆,调整圆柱孔径很可能误切圆角。") - elif guess == "boss/outer-round candidate": - risk = "high" - warnings.append("当前圆柱面更像凸柱或外圆,调整圆柱孔径可能切掉外部结构。") - elif guess != "hole/groove candidate": - risk = "high" - warnings.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 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" - blockers.append("目标直径必须大于 0。") - elif abs(new_diameter - current_diameter) <= max(current_diameter * 1e-5, 1e-6): - status = "blocked" - risk = "blocked" - blockers.append("目标直径与当前直径几乎相同,不需要修改。") - 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("目标直径变化超过当前直径的 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" - if not warnings and not blockers: - note = "可以尝试调整圆柱孔径。" - else: - note = " ".join(blockers + warnings) - return { - "resize_status": status, - "resize_risk": risk, - "resize_warnings": ";".join(warnings), - "resize_blockers": ";".join(blockers), - "resize_note": note, - } - - -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 - - -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()) - - -def _oriented_dir_tuple(direction, shape: TopoDS_Shape) -> tuple[float, float, float]: - values = _dir_tuple(direction) - if shape.Orientation() == TopAbs_REVERSED: - return (-values[0], -values[1], -values[2]) - return values - - -def _neg_tuple(values: tuple[float, float, float]) -> tuple[float, float, float]: - return (-values[0], -values[1], -values[2]) - - -def _point_tuple(point) -> tuple[float, float, float]: - return (point.X(), point.Y(), point.Z()) - - -def _point_on_axis(axis_point: gp_Pnt, direction, parameter: float) -> gp_Pnt: - return gp_Pnt( - axis_point.X() + direction.X() * parameter, - axis_point.Y() + direction.Y() * parameter, - axis_point.Z() + direction.Z() * parameter, - ) - - -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}") - - -def _format_tuple(values: tuple[float, float, float]) -> str: - return "(" + ", ".join(f"{float(value):.6g}" for value in values) + ")" - - -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()