feat: 拆分 STEP 编辑器并完善最小系统
将原来的 main.py/step_model.py 拆分为 step_editor 包,补充测量、同域高亮、模型修复、历史导出、槽宽/凸台/边长等 MVP 编辑能力,并更新 README 和忽略规则。
This commit is contained in:
@@ -17,7 +17,10 @@ dist/
|
|||||||
*_edited.step
|
*_edited.step
|
||||||
*_export.step
|
*_export.step
|
||||||
*_diff_*.txt
|
*_diff_*.txt
|
||||||
|
*_operation_history.json
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
docs/screenshots/
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -12,8 +12,9 @@
|
|||||||
- 展示三维模型,支持旋转、缩放、平移。
|
- 展示三维模型,支持旋转、缩放、平移。
|
||||||
- 读取 STEP 中的零件、装配、solid 等结构。
|
- 读取 STEP 中的零件、装配、solid 等结构。
|
||||||
- 用户可以选择零件、solid、面、边和局部几何特征。
|
- 用户可以选择零件、solid、面、边和局部几何特征。
|
||||||
|
- 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。
|
||||||
- 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。
|
- 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。
|
||||||
- 用户可以修改可控的局部特征,比如平面推拉、孔径调整、盲孔深度调整、凸台直径调整、边倒圆、边倒角和受限直线边长度调整。
|
- 用户可以修改可控的局部特征,比如平面推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、凸台直径调整、边倒圆、边倒角和第一版直接边长调整。
|
||||||
- 用户可以撤销/重做修改,避免实验性编辑一步做坏。
|
- 用户可以撤销/重做修改,避免实验性编辑一步做坏。
|
||||||
- 用户可以导出修改后的完整模型。
|
- 用户可以导出修改后的完整模型。
|
||||||
- 如果 STEP 文件里存在多个零件,用户可以只导出选中的某个零件。
|
- 如果 STEP 文件里存在多个零件,用户可以只导出选中的某个零件。
|
||||||
@@ -55,14 +56,19 @@
|
|||||||
- Edge
|
- Edge
|
||||||
- Feature
|
- 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。
|
- 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界 edge。
|
||||||
- 支持按当前选择模式输入 ID 直接选择 Part / Solid / Face / Edge / Feature。
|
- 支持按当前选择模式输入 ID 直接选择 Part / Solid / Face / Edge / Feature。
|
||||||
|
- Face / Feature 按 ID 选择会优先解析 `逻辑 Face ID`。例如推拉前侧壁是 558,推拉后当前拓扑里变成 1556 和 1802,只要它们被绑定到逻辑 558,输入 558 就会选中这片面区域。
|
||||||
- `鼠标选择模式` 下拉框不会响应鼠标滚轮,避免鼠标经过时误切换模式;需要点击后手动选择。
|
- `鼠标选择模式` 下拉框不会响应鼠标滚轮,避免鼠标经过时误切换模式;需要点击后手动选择。
|
||||||
- `按 ID 选择` 会在输入框和 `选择` 按钮之间同步显示当前鼠标选择模式。
|
- `按 ID 选择` 会在输入框和 `选择` 按钮之间同步显示当前鼠标选择模式。
|
||||||
- `Feature` 模式现在会把点到的 face 解释为局部几何特征候选:
|
- `Feature` 模式现在会把点到的 face 解释为局部几何特征候选:
|
||||||
- 平面会识别为可推拉平面候选。
|
- 平面会识别为可推拉平面候选。
|
||||||
|
- 平面会尝试查找同一 solid 内投影重叠的相对平面,用于估算薄壁/壳体局部区域厚度。
|
||||||
- 圆柱面会识别为圆柱孔候选、槽/半孔候选、圆角/倒圆候选、凸台/外圆候选或未明确圆柱特征。
|
- 圆柱面会识别为圆柱孔候选、槽/半孔候选、圆角/倒圆候选、凸台/外圆候选或未明确圆柱特征。
|
||||||
- 圆柱特征会尝试找出边界 edge、相邻 face、端面 face、开口端相邻 face 和疑似底面 face,并一起高亮相关局部区域。
|
- 圆柱特征会先把同一实体内同轴、同半径且轴向连续/重叠的完整圆柱 face 当作同一侧壁区域,再尝试找出边界 edge、相邻 face、端面 face、开口端相邻 face 和疑似底面 face,并一起高亮相关局部区域。
|
||||||
- 槽/半孔候选会额外显示槽宽、圆弧长度、槽深估算和槽边界相邻 face。
|
- 槽/半孔候选会额外显示槽宽、圆弧长度、槽深估算和槽边界相邻 face。
|
||||||
- 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑 face。
|
- 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑 face。
|
||||||
- 支持显示选中对象信息,并提供 `属性表` / `原始文本` 两种查看方式:
|
- 支持显示选中对象信息,并提供 `属性表` / `原始文本` 两种查看方式:
|
||||||
@@ -87,16 +93,22 @@
|
|||||||
- 直线 edge 的方向,圆弧 edge 的圆心、轴线、半径和直径。
|
- 直线 edge 的方向,圆弧 edge 的圆心、轴线、半径和直径。
|
||||||
- 属性面板支持复制当前对象 ID、拾取坐标和完整信息。
|
- 属性面板支持复制当前对象 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。
|
- 支持导出当前完整模型为 STEP。
|
||||||
- 支持导出选中的 part 为 STEP。
|
- 支持导出选中的 part 为 STEP。
|
||||||
- 支持导出选中的 solid 为 STEP。
|
- 支持导出选中的 solid 为 STEP。
|
||||||
- 支持导出选中的 face 为 STEP。
|
- 支持导出选中的 face / 同域面区域为 STEP;如果当前 face 与共面或同轴同半径的相邻面一起高亮,导出也会包含整片黄色区域。
|
||||||
- 支持导出选中特征区域为 STEP。
|
- 支持导出选中特征区域为 STEP。
|
||||||
- 支持导出选中的 edge 为 STEP。
|
- 支持导出选中的 edge 为 STEP。
|
||||||
- 支持导出质量检查:导出前会检查 B-Rep 有效性、solid / face / edge 数量、体积和包围盒;如果发现风险,会先弹窗确认。
|
- 支持导出质量检查:导出前会检查 B-Rep 有效性、solid / face / edge 数量、体积和包围盒;如果发现风险,会先弹窗确认。
|
||||||
|
- 支持一键修复当前模型或选中零件/solid:执行 ShapeFix 和同域面/边合并,修复会进入后台编辑流程,并支持撤销/重做。
|
||||||
- 导出完整模型、part 或 solid 前,会先尝试执行 ShapeFix 和同域面/边合并,减少外部 CAD 打开时出现碎面、坏体或明显拼接痕迹的概率。
|
- 导出完整模型、part 或 solid 前,会先尝试执行 ShapeFix 和同域面/边合并,减少外部 CAD 打开时出现碎面、坏体或明显拼接痕迹的概率。
|
||||||
- 实验性编辑按钮会根据当前选中对象自动启用或禁用,并通过悬停提示说明为什么当前操作不可用,减少 face、edge、孔、凸台、盲孔等对象混用导致的误操作。
|
- 实验性编辑按钮会根据当前选中对象自动启用或禁用,并通过悬停提示说明为什么当前操作不可用,减少 face、edge、孔、凸台、盲孔等对象混用导致的误操作。
|
||||||
- 支持扫描第一版可编辑对象:
|
- 支持扫描第一版可编辑对象:
|
||||||
@@ -104,13 +116,14 @@
|
|||||||
- `深度扫描` 会尝试列出更多候选,适合默认列表里没有找到目标对象时使用。
|
- `深度扫描` 会尝试列出更多候选,适合默认列表里没有找到目标对象时使用。
|
||||||
- 可推拉的平面 face。
|
- 可推拉的平面 face。
|
||||||
- 可调整孔径的圆柱 face 候选。
|
- 可调整孔径的圆柱 face 候选。
|
||||||
|
- 可调整槽/半孔宽度的局部圆柱孔/槽候选。
|
||||||
- 可调整盲孔深度的圆柱 face 候选。
|
- 可调整盲孔深度的圆柱 face 候选。
|
||||||
- 可添加新圆角的直线 edge 候选。
|
- 可添加新圆角的直线 edge 候选。
|
||||||
- 可添加倒角的直线 edge 候选。
|
- 可添加倒角的直线 edge 候选。
|
||||||
- 可尝试通过端面推拉调整长度的直线 edge 候选。
|
- 可尝试通过端面推拉调整长度的直线 edge 候选。
|
||||||
- 表格会显示当前值、状态、风险、置信度和说明。
|
- 表格会显示当前值、状态、风险、置信度和说明。
|
||||||
- 盲孔深度候选会在列表阶段先确认疑似底面,列表里的深度值优先使用底面 face 轴向位置计算。
|
- 盲孔深度候选会在列表阶段先确认疑似底面,列表里的深度值优先使用底面 face 轴向位置计算。
|
||||||
- 点击表格行会直接选中对应 face 或 edge,方便继续执行推拉、孔径调整、盲孔深度调整、边倒圆、边倒角或受限直线边长度调整。
|
- 点击表格行会直接选中对应 face 或 edge,方便继续执行推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、边倒圆、边倒角或直接边长调整。
|
||||||
- 支持列出圆柱面候选特征。
|
- 支持列出圆柱面候选特征。
|
||||||
- 圆柱候选会显示在表格中,点击候选行可以直接选中并高亮对应 face。
|
- 圆柱候选会显示在表格中,点击候选行可以直接选中并高亮对应 face。
|
||||||
- 圆柱候选可以按类型筛选:全部、孔/槽候选、圆角候选、凸柱/外圆候选、未明确分类。
|
- 圆柱候选可以按类型筛选:全部、孔/槽候选、圆角候选、凸柱/外圆候选、未明确分类。
|
||||||
@@ -126,13 +139,15 @@
|
|||||||
- 实验性支持平面 face 推拉。
|
- 实验性支持平面 face 推拉。
|
||||||
- 平面推拉会自动判断面两侧的 inside / outside,输入正数表示向外加料,输入负数表示向内切削。
|
- 平面推拉会自动判断面两侧的 inside / outside,输入正数表示向外加料,输入负数表示向内切削。
|
||||||
- 推拉平面会先显示半透明预览体,表示推拉方向和大致范围,然后在后台执行真实 OCCT 布尔计算。
|
- 推拉平面会先显示半透明预览体,表示推拉方向和大致范围,然后在后台执行真实 OCCT 布尔计算。
|
||||||
- 推拉平面现在会从选中 face 出发,自动查找共享边且共面的相邻 face,并把它们作为同一片平面区域一起推拉。
|
- 推拉平面现在会从选中 face 出发,自动查找共面且相接/重叠的相邻 face,并把它们作为同一片平面区域一起推拉。
|
||||||
- 这可以减少 STEP 碎面导致的“只推了一小块,导出后像贴上去一块,中间有伤疤”的情况。
|
- 这可以减少 STEP 碎面导致的“只推了一小块,导出后像贴上去一块,中间有伤疤”的情况。
|
||||||
|
- 如果选中的平面被识别为完整圆柱端盖,并且是向外拉长,程序会优先使用“完整旧段 + 新段圆柱”的专用 Fuse 路线,而不是只把一个短圆柱 prism 拼到端面上。简单圆柱场景下,这会让拉长结果保持为一个连续圆柱侧面,减少新旧圆柱段拼接痕迹。
|
||||||
- 推拉加料时,工具体会向原实体内部保留一个很小的重叠量,再执行 Fuse,避免只靠两个面贴合导致导出后像“原模型 + 新增块”叠在一起。
|
- 推拉加料时,工具体会向原实体内部保留一个很小的重叠量,再执行 Fuse,避免只靠两个面贴合导致导出后像“原模型 + 新增块”叠在一起。
|
||||||
- Fuse/Cut 完成后会执行更强的推拉结果清理:按模型尺寸设置小容差,显式禁止保留 internal edges,并多轮执行 OCCT 同域面/边合并,减少第三方 CAD 中出现不必要共面分割线的概率。
|
- Fuse/Cut 完成后会执行更强的推拉结果清理:按模型尺寸设置小容差,显式禁止保留 internal edges,并多轮执行 OCCT 同域面/边合并,减少第三方 CAD 中出现不必要共面分割线的概率。
|
||||||
|
- 布尔计算前会启用 OCCT glue/fuzzy 选项,布尔后会调用 `SimplifyResult` 和同域合并;但某些 CAD/OCCT 场景仍可能保留拓扑分割边。当前程序会先在选择层把同域平面/圆柱区域作为整体高亮,并在默认边线渲染中隐藏同域内部拓扑边和几何重复拼接边;真正从 STEP/B-Rep 拓扑里消除这类分割边,还需要后续做更专门的局部圆柱/侧壁重建。
|
||||||
- 如果推拉本身产生了真实台阶边界,第三方 CAD 仍会显示这条几何边;清理只能减少不必要的共面残留边,不能消除真实形状边界。
|
- 如果推拉本身产生了真实台阶边界,第三方 CAD 仍会显示这条几何边;清理只能减少不必要的共面残留边,不能消除真实形状边界。
|
||||||
- 推拉平面会先生成操作计划:检查距离是否为 0、方向置信度和相对 face 尺寸的风险;高风险会先弹窗确认。
|
- 推拉平面会先生成操作计划:检查距离是否为 0、方向置信度和相对 face 尺寸的风险;能推拉时直接执行,不能推拉时会提示阻止原因。
|
||||||
- 调整圆柱孔径也会先显示半透明预览体:红色表示切削 cutter,绿色表示缩小孔径时的补料范围。
|
- 调整圆柱孔径也会先显示半透明预览体:红色表示切削 cutter,绿色表示缩小孔径时的补料范围;如果圆柱侧壁被拆成多段同域 face,工具范围会优先覆盖整段同域圆柱。
|
||||||
- 支持第一版盲孔/盲槽深度调整:
|
- 支持第一版盲孔/盲槽深度调整:
|
||||||
- 只对 `hole/groove candidate` 且端部类型为 `blind`、并识别到疑似底面 face 的圆柱候选开放。
|
- 只对 `hole/groove candidate` 且端部类型为 `blind`、并识别到疑似底面 face 的圆柱候选开放。
|
||||||
- 目标深度大于当前估算深度时,会沿开口到疑似底面方向用有限长度 cutter 加深切削。
|
- 目标深度大于当前估算深度时,会沿开口到疑似底面方向用有限长度 cutter 加深切削。
|
||||||
@@ -145,6 +160,10 @@
|
|||||||
- 会估算槽宽、圆弧长度和槽深。
|
- 会估算槽宽、圆弧长度和槽深。
|
||||||
- 会识别槽圆柱 face 和槽边界相邻 face,并在 Feature 模式下一起高亮。
|
- 会识别槽圆柱 face 和槽边界相邻 face,并在 Feature 模式下一起高亮。
|
||||||
- 这些仍是 B-Rep 几何估算,不是 CAD 历史里的槽特征参数。
|
- 这些仍是 B-Rep 几何估算,不是 CAD 历史里的槽特征参数。
|
||||||
|
- 支持第一版槽/半孔宽度调整:
|
||||||
|
- 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。
|
||||||
|
- 输入目标槽宽后,会按当前圆弧角度把槽宽换算为目标圆柱直径,再复用圆柱孔/槽重建路线执行。
|
||||||
|
- 这不是完整 CAD 槽参数编辑;复杂槽、非圆柱槽或圆弧角度不稳定时仍可能失败并回滚。
|
||||||
- 支持第一版已有圆角/倒圆候选结构化识别:
|
- 支持第一版已有圆角/倒圆候选结构化识别:
|
||||||
- 对局部小半径圆柱面,会标记为 `round/fillet candidate`。
|
- 对局部小半径圆柱面,会标记为 `round/fillet candidate`。
|
||||||
- 会估算已有圆角半径、圆弧角度和圆弧长度。
|
- 会估算已有圆角半径、圆弧角度和圆弧长度。
|
||||||
@@ -161,9 +180,9 @@
|
|||||||
- 支持第一版圆柱凸台直径调整:
|
- 支持第一版圆柱凸台直径调整:
|
||||||
- 只对 `boss/outer-round candidate` 且角度跨度接近完整圆柱的凸台开放。
|
- 只对 `boss/outer-round candidate` 且角度跨度接近完整圆柱的凸台开放。
|
||||||
- 不处理局部外圆角、圆角面或未明确圆柱面,避免把圆角当凸台修改。
|
- 不处理局部外圆角、圆角面或未明确圆柱面,避免把圆角当凸台修改。
|
||||||
- 目标直径变大时,会生成目标直径圆柱并 Fuse。
|
- 目标直径变大时,会在同域圆柱侧壁范围内生成目标直径圆柱并 Fuse。
|
||||||
- 目标直径变小时,会生成环形 cutter 并 Cut。
|
- 目标直径变小时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建,避免环形 cutter 在部分场景下把实体切空。
|
||||||
- 会先显示半透明预览:绿色表示扩大补料范围,红色表示缩小切削范围。
|
- 会先显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。
|
||||||
- 当前默认模型可能没有明确凸台候选;包含完整圆柱凸台的 STEP 文件可以使用这个能力。
|
- 当前默认模型可能没有明确凸台候选;包含完整圆柱凸台的 STEP 文件可以使用这个能力。
|
||||||
- 支持第一版直线边添加圆角:
|
- 支持第一版直线边添加圆角:
|
||||||
- 只对直线 edge 开放。
|
- 只对直线 edge 开放。
|
||||||
@@ -178,13 +197,15 @@
|
|||||||
- 橙色半透明预览表示目标 edge 和输入倒角距离的大致范围。
|
- 橙色半透明预览表示目标 edge 和输入倒角距离的大致范围。
|
||||||
- 真实编辑使用 OCCT `BRepFilletAPI_MakeChamfer` 在后台执行。
|
- 真实编辑使用 OCCT `BRepFilletAPI_MakeChamfer` 在后台执行。
|
||||||
- STEP 没有建模历史,某些边仍可能被 OCCT 拒绝;失败时会自动尝试恢复编辑前状态。
|
- STEP 没有建模历史,某些边仍可能被 OCCT 拒绝;失败时会自动尝试恢复编辑前状态。
|
||||||
- 支持第一版受限直线边长度调整:
|
- 支持第一版直接边长调整:
|
||||||
- 只对直线 edge 开放。
|
- 选中 edge 后,`边目标长度` 会自动填入当前边长。
|
||||||
- 选中直线 edge 后,`边目标长度` 会自动填入当前边长。
|
- 可以选择边长基准:自动、中心、固定起点、固定终点。
|
||||||
- 输入目标长度后,程序会尝试寻找这条边端点附近、方向匹配的平面端面。
|
- 直线 edge 会优先尝试寻找端点附近、方向匹配的平面端面,并把“改边长”转换成该端面的平面推拉操作。
|
||||||
- 找到端面后,会把“改边长”转换成该端面的平面推拉操作,并显示蓝绿色半透明预览。
|
- `自动` 会优先选择可局部推拉的端面;`固定起点` / `固定终点` 会优先尝试移动另一端端面。
|
||||||
- 如果找不到可靠端面,或者目标长度无效,会直接阻止,不会强行修改。
|
- 圆形/圆弧 edge 会优先检查相邻圆柱面;如果能复用孔/槽或凸台直径编辑,就把目标边长按比例换算成目标圆柱直径再执行局部编辑。
|
||||||
- 这不是通用参数化边长编辑;复杂拓扑、斜面过渡、圆角链或没有明确端面的边仍可能不能改。
|
- `中心`、没有明确端面、或圆边没有可复用圆柱面时,会尝试整体/轴向几何缩放 fallback,以覆盖更多任意 edge 的最小可用场景。
|
||||||
|
- 该 fallback 会影响所属 part/solid 的其它尺寸,因此会按高风险确认,并依赖失败回滚/撤销兜底。
|
||||||
|
- 这仍不是通用参数化边长编辑;复杂拓扑、斜面过渡、圆角链或布尔失败场景仍可能不能稳定修改。
|
||||||
- 支持 part / solid 级平移:
|
- 支持 part / solid 级平移:
|
||||||
- 输入 X/Y/Z 平移量后,可以平移当前选中零件。
|
- 输入 X/Y/Z 平移量后,可以平移当前选中零件。
|
||||||
- 选中 solid、或选中属于某个 solid 的 face/edge 后,可以平移当前 solid。
|
- 选中 solid、或选中属于某个 solid 的 face/edge 后,可以平移当前 solid。
|
||||||
@@ -195,7 +216,8 @@
|
|||||||
- 第一版旋转中心取目标对象的包围盒中心。
|
- 第一版旋转中心取目标对象的包围盒中心。
|
||||||
- 单 solid 零件中旋转 solid 实际等同于旋转整个 part shape,程序会按中风险提示。
|
- 单 solid 零件中旋转 solid 实际等同于旋转整个 part shape,程序会按中风险提示。
|
||||||
- 旋转操作会写入历史记录,并支持撤销/重做。
|
- 旋转操作会写入历史记录,并支持撤销/重做。
|
||||||
- 后台编辑计算时会显示进度提示,主界面会暂时置灰,并阻止选择、扫描、导出、撤销/重做、历史详情查看或关闭窗口。
|
- 后台编辑计算时不再弹出模态进度框,也不再把主界面整体置灰;UI 线程只负责渲染和响应基础视图交互,真实 B-Rep 编辑在后台线程执行。
|
||||||
|
- 编辑计算期间,半透明预览会继续在 3D 视图里渲染,用户可以旋转或缩放查看;会改变模型状态的操作仍会暂时禁用,避免同一模型被多个后台编辑同时修改。
|
||||||
- 真实 B-Rep 结果会在布尔计算完成后一次性刷新;半透明预览不等于最终几何结果。
|
- 真实 B-Rep 结果会在布尔计算完成后一次性刷新;半透明预览不等于最终几何结果。
|
||||||
- 如果后台编辑失败,程序会自动尝试恢复到编辑前快照,避免出现“提示失败但模型已经被部分改动”的状态。
|
- 如果后台编辑失败,程序会自动尝试恢复到编辑前快照,避免出现“提示失败但模型已经被部分改动”的状态。
|
||||||
- 实验性支持圆柱孔/圆柱面扩大切削。
|
- 实验性支持圆柱孔/圆柱面扩大切削。
|
||||||
@@ -233,6 +255,7 @@
|
|||||||
- 支持导出选中操作历史的差异报告,报告会包含操作参数、拓扑变化、几何变化和热力图统计。
|
- 支持导出选中操作历史的差异报告,报告会包含操作参数、拓扑变化、几何变化和热力图统计。
|
||||||
- 鼠标拾取模型时会显示三维拾取点坐标。
|
- 鼠标拾取模型时会显示三维拾取点坐标。
|
||||||
- 实验性编辑历史会记录目标对象和当时的拾取点。
|
- 实验性编辑历史会记录目标对象和当时的拾取点。
|
||||||
|
- 支持把本软件加载后的编辑历史导出为 JSON;这不是 STEP 原始 CAD 建模历史,而是后续操作留档。
|
||||||
- 点击操作历史记录时,程序会尝试重新高亮目标对象,并显示当时的拾取点标记。
|
- 点击操作历史记录时,程序会尝试重新高亮目标对象,并显示当时的拾取点标记。
|
||||||
- 对非 `hole/groove candidate` 的圆柱切削会先弹出确认,避免误切外圆角或凸柱。
|
- 对非 `hole/groove candidate` 的圆柱切削会先弹出确认,避免误切外圆角或凸柱。
|
||||||
- 提供 `--smoke-test` 启动检查,用于验证 Qt + VTK 窗口组件能正常初始化。
|
- 提供 `--smoke-test` 启动检查,用于验证 Qt + VTK 窗口组件能正常初始化。
|
||||||
@@ -258,28 +281,27 @@ vertices: 3262
|
|||||||
|
|
||||||
- 复杂特征识别:已经有平面、圆柱、边、孔/槽候选、槽/半孔候选、已有圆角候选、圆柱凸台候选等基础候选列表,但还不是完整 CAD 语义级识别。
|
- 复杂特征识别:已经有平面、圆柱、边、孔/槽候选、槽/半孔候选、已有圆角候选、圆柱凸台候选等基础候选列表,但还不是完整 CAD 语义级识别。
|
||||||
- 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面 face 的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。
|
- 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面 face 的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。
|
||||||
- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、完整圆柱凸台直径修改,复杂槽、复杂凸台和壳体局部区域仍需要继续做。
|
- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度调整、完整圆柱凸台直径修改和平面薄壁/壳体相对面厚度估算;复杂槽、复杂凸台和完整壳体区域仍需要继续做。
|
||||||
- 已有圆角/倒圆面半径:已经有第一版受限修改实现,使用 defeature + refillet 路线,但复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。
|
- 已有圆角/倒圆面半径:已经有第一版受限修改实现,使用 defeature + refillet 路线,但复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。
|
||||||
- 局部偏差分析:已经有编辑前后体积、面积、包围盒、距离热力图和差异报告,但还不是工程公差级的局部区域偏差报告。
|
- 局部偏差分析:已经有编辑前后体积、面积、包围盒、距离热力图和差异报告,但还不是工程公差级的局部区域偏差报告。
|
||||||
- 错误恢复和模型修复:已经有编辑前快照、失败回滚、基础 B-Rep 校验、同域面合并和布尔结果自动修复,但还不是完整的模型修复流水线。
|
- 错误恢复和模型修复:已经有编辑前快照、失败回滚、基础 B-Rep 校验、同域面合并、布尔结果自动修复,以及一键修复当前模型或选中零件/solid,但还不是完整的模型修复流水线。
|
||||||
- 多零件装配:已经有装配/零件树和单独导出入口,但还没有做大规模、多来源 STEP 装配测试。
|
- 多零件装配:已经有装配/零件树和单独导出入口,但还没有做大规模、多来源 STEP 装配测试。
|
||||||
|
|
||||||
仍基本没有实现,或者 STEP 本身不一定能可靠支持:
|
仍基本没有实现,或者 STEP 本身不一定能可靠支持:
|
||||||
|
|
||||||
- 通用任意边长直接修改。当前只有第一版受限直线边长度调整,会尝试通过端面推拉来改变长度;没有明确可推拉端面的边仍不能改。
|
- 参数化建模历史恢复。STEP 通常只保存结果几何/拓扑,不能稳定还原原 CAD 软件里的建模历史;当前只能在本程序里记录并导出后续编辑历史,或做近似的特征语义识别。
|
||||||
- 参数化建模历史恢复。STEP 通常只保存结果几何/拓扑,不能稳定还原原 CAD 软件里的建模历史,只能在本程序里记录后续编辑历史,或做近似的特征语义识别。
|
|
||||||
- 所有场景都稳定的复杂特征识别。
|
- 所有场景都稳定的复杂特征识别。
|
||||||
|
|
||||||
## 下一步要实现什么
|
## 下一步要实现什么
|
||||||
|
|
||||||
建议下一步按这个顺序推进:
|
建议下一步按这个顺序推进:
|
||||||
|
|
||||||
1. 继续加固最小可用编辑闭环:选择、预览、执行、校验、修复、回滚、导出。
|
1. 继续补齐更多最小可用特征入口,优先覆盖复杂槽、复杂凸台、局部薄壁/壳体区域和装配用例。
|
||||||
2. 继续验证平面推拉、孔径调整、盲孔/盲槽深度调整和圆柱凸台直径调整,记录哪些对象稳定、哪些对象会失败。
|
2. 继续验证平面推拉、孔径调整、槽/半孔宽度调整、盲孔/盲槽深度调整、圆柱凸台直径调整和直接边长调整,记录哪些对象稳定、哪些对象会失败。
|
||||||
3. 改进盲孔/槽底面识别,减少“看起来像 blind 但找不到可靠底面 face”的候选。
|
3. 改进盲孔/槽底面识别,减少“看起来像 blind 但找不到可靠底面 face”的候选。
|
||||||
4. 继续加固已有圆角半径修改:扩大可成功场景,记录哪些圆角能 defeature + refillet,哪些需要更复杂的重建策略。
|
4. 继续加固已有圆角半径修改:扩大可成功场景,记录哪些圆角能 defeature + refillet,哪些需要更复杂的重建策略。
|
||||||
5. 继续增加复杂槽、复杂凸台、局部区域识别。
|
5. 完善本软件编辑历史的导入/复放能力,让后续编辑不只是导出 JSON 留档。
|
||||||
6. 优化导出策略,例如导出时保留更多名称、颜色和层级信息。
|
6. 最后集中做性能和稳定性优化,包括首次加载后台精细化、扫描节流和高风险 OCC 操作隔离。
|
||||||
|
|
||||||
## 怎么运行
|
## 怎么运行
|
||||||
|
|
||||||
@@ -372,11 +394,24 @@ python main.py --smoke-test
|
|||||||
左侧 `显示` 用来临时缩小三维视图范围:
|
左侧 `显示` 用来临时缩小三维视图范围:
|
||||||
|
|
||||||
- `只显示选中`:只显示当前选中的 part、solid、face、edge 或 feature 区域。
|
- `只显示选中`:只显示当前选中的 part、solid、face、edge 或 feature 区域。
|
||||||
|
- Face 模式下 `只显示选中` 会隔离当前黄色同域面区域,并显示这片区域的外边界 edge。
|
||||||
- `对准选中`:不改变显示范围,只把相机对准当前选中的 part、solid、face、edge 或 feature,适合快速查看小孔、小边和局部特征。
|
- `对准选中`:不改变显示范围,只把相机对准当前选中的 part、solid、face、edge 或 feature,适合快速查看小孔、小边和局部特征。
|
||||||
- `显示全部`:恢复显示完整模型。
|
- `显示全部`:恢复显示完整模型。
|
||||||
|
- `显示同域内部边`:默认关闭,用来隐藏同一平面/同一圆柱面内部的拓扑分割边和几何重复拼接边;打开后可以查看完整 B-Rep 拓扑边线,适合调试布尔推拉后的分面情况。
|
||||||
|
- `导出选中面区域` 会导出当前 Face 模式黄色高亮的同域面区域,而不是只导出鼠标点到的单个拓扑 face。
|
||||||
- solid / edge 的显示查询已经使用拓扑索引,避免每次隔离或高亮时反复全模型查找。
|
- solid / edge 的显示查询已经使用拓扑索引,避免每次隔离或高亮时反复全模型查找。
|
||||||
- 这是视图操作,不会修改 STEP 几何,也不会写入操作历史。
|
- 这是视图操作,不会修改 STEP 几何,也不会写入操作历史。
|
||||||
|
|
||||||
|
左侧 `测量` 用来做最基础的点到点距离检查:
|
||||||
|
|
||||||
|
- 先在模型中点击一个对象或具体位置,然后点击 `设为 A`。
|
||||||
|
- 再点击另一个对象或位置,然后点击 `设为 B`。
|
||||||
|
- 如果选择来自鼠标点击,测量会优先使用实际拾取点;如果是从结构树或 ID 选择,程序会使用对象中心、面积中心、长度中心、重心或包围盒中心。
|
||||||
|
- 设置 A/B 后会显示两点距离和 `ΔX/ΔY/ΔZ`。
|
||||||
|
- 3D 视图里会显示一条青色测量线,方便确认测量方向。
|
||||||
|
- `复制测量` 会复制当前测量结果;`清除测量` 会清空 A/B 和测量线。
|
||||||
|
- 测量不会修改模型,也不会写入操作历史。
|
||||||
|
|
||||||
左侧 `第一版可编辑对象` 是当前 MVP 的主要入口:
|
左侧 `第一版可编辑对象` 是当前 MVP 的主要入口:
|
||||||
|
|
||||||
- 点击 `扫描可编辑对象` 会列出当前第一版能尝试编辑的对象。
|
- 点击 `扫描可编辑对象` 会列出当前第一版能尝试编辑的对象。
|
||||||
@@ -385,6 +420,7 @@ python main.py --smoke-test
|
|||||||
- 当前默认显示一批代表性的可编辑候选,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。
|
- 当前默认显示一批代表性的可编辑候选,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。
|
||||||
- `推拉平面` 行表示这个 face 是平面,可以配合 `面偏移` 和 `推拉平面` 使用。
|
- `推拉平面` 行表示这个 face 是平面,可以配合 `面偏移` 和 `推拉平面` 使用。
|
||||||
- `调整圆柱孔径` 行表示这个 face 是圆柱候选,可以配合 `孔直径` 和 `调整圆柱孔径` 使用。
|
- `调整圆柱孔径` 行表示这个 face 是圆柱候选,可以配合 `孔直径` 和 `调整圆柱孔径` 使用。
|
||||||
|
- `调整槽/半孔宽度` 行表示这个 face 是槽/半孔候选,可以配合 `槽/半孔宽度` 和 `调整槽/半孔宽度` 使用。
|
||||||
- `调整圆柱凸台直径` 行表示这个 face 是较明确的完整圆柱凸台候选,可以配合 `凸台直径` 和 `调整圆柱凸台直径` 使用。
|
- `调整圆柱凸台直径` 行表示这个 face 是较明确的完整圆柱凸台候选,可以配合 `凸台直径` 和 `调整圆柱凸台直径` 使用。
|
||||||
- `封堵圆柱孔` 行表示这个 face 是接近完整圆柱的孔候选,可以点击后直接使用 `封堵圆柱孔`。
|
- `封堵圆柱孔` 行表示这个 face 是接近完整圆柱的孔候选,可以点击后直接使用 `封堵圆柱孔`。
|
||||||
- `调整盲孔深度` 行表示这个 face 是较明确的盲孔/盲槽候选,可以配合 `孔深度` 和 `调整盲孔深度` 使用。
|
- `调整盲孔深度` 行表示这个 face 是较明确的盲孔/盲槽候选,可以配合 `孔深度` 和 `调整盲孔深度` 使用。
|
||||||
@@ -398,6 +434,7 @@ python main.py --smoke-test
|
|||||||
选择对象后:
|
选择对象后:
|
||||||
|
|
||||||
- 鼠标经过对象时会出现红色预高亮;被选对象会显示为黄色高亮。
|
- 鼠标经过对象时会出现红色预高亮;被选对象会显示为黄色高亮。
|
||||||
|
- 在 `Face` 模式下,如果相邻 face 属于同一几何面,并且投影范围或轴向范围相接/重叠,程序会把它们作为同域区域一起红色预高亮和黄色选中高亮;这主要用于缓解平面/圆柱推拉后视觉上被拓扑边分成多段的问题。
|
||||||
- 如果处于 `Feature` 模式,程序会尽量高亮这个特征相关的局部区域,例如圆柱侧壁、相邻端面和槽边界相邻面。
|
- 如果处于 `Feature` 模式,程序会尽量高亮这个特征相关的局部区域,例如圆柱侧壁、相邻端面和槽边界相邻面。
|
||||||
- 对槽/半孔候选,属性表会显示 `槽宽估算`、`槽圆弧长度估算`、`槽深估算` 和 `槽边界相邻 Face`。
|
- 对槽/半孔候选,属性表会显示 `槽宽估算`、`槽圆弧长度估算`、`槽深估算` 和 `槽边界相邻 Face`。
|
||||||
- 对圆角/倒圆候选,属性表会显示 `已有圆角半径估算`、`已有圆角圆弧角度`、`已有圆角圆弧长度估算` 和 `已有圆角支撑 Face`。
|
- 对圆角/倒圆候选,属性表会显示 `已有圆角半径估算`、`已有圆角圆弧角度`、`已有圆角圆弧长度估算` 和 `已有圆角支撑 Face`。
|
||||||
@@ -438,6 +475,8 @@ python main.py --smoke-test
|
|||||||
- `导出选中特征区域`:先切换到 `Feature` 选择模式并选中局部特征,再导出当前特征高亮的 face 集合。
|
- `导出选中特征区域`:先切换到 `Feature` 选择模式并选中局部特征,再导出当前特征高亮的 face 集合。
|
||||||
- `导出选中 edge`:先切换到 `Edge` 选择模式并选中一个 edge,再导出该 edge。
|
- `导出选中 edge`:先切换到 `Edge` 选择模式并选中一个 edge,再导出该 edge。
|
||||||
- `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。
|
- `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。
|
||||||
|
- `修复当前模型`:对当前完整模型执行 ShapeFix 和同域面/边合并,完成后会刷新显示并写入操作历史;如果修复失败,会尝试回滚到修复前状态。
|
||||||
|
- `修复选中零件/solid`:优先修复当前选中对象所属 solid;如果当前对象没有 solid,则修复所属零件。face 或 edge 被选中时,也会按其所属 solid/零件执行局部修复。
|
||||||
- 导出按钮会根据当前选中对象自动启用或禁用,避免选中 face 时误点 `导出选中 edge` 这类不匹配的操作。
|
- 导出按钮会根据当前选中对象自动启用或禁用,避免选中 face 时误点 `导出选中 edge` 这类不匹配的操作。
|
||||||
- 所有导出按钮都会先做质量检查。发现 B-Rep 无效、没有 solid、零件包含多个 solid、体积接近 0 等风险时,会先弹窗确认是否继续导出。
|
- 所有导出按钮都会先做质量检查。发现 B-Rep 无效、没有 solid、零件包含多个 solid、体积接近 0 等风险时,会先弹窗确认是否继续导出。
|
||||||
|
|
||||||
@@ -451,7 +490,7 @@ python main.py --smoke-test
|
|||||||
- 负数表示沿内侧方向切削。
|
- 负数表示沿内侧方向切削。
|
||||||
- 点击 `推拉平面` 后,会先出现半透明预览体;绿色表示向外加料方向,红色表示向内切削方向。
|
- 点击 `推拉平面` 后,会先出现半透明预览体;绿色表示向外加料方向,红色表示向内切削方向。
|
||||||
- 如果距离为 0 会直接阻止;如果方向置信度低或距离相对 face 尺寸偏大,会先弹窗确认。
|
- 如果距离为 0 会直接阻止;如果方向置信度低或距离相对 face 尺寸偏大,会先弹窗确认。
|
||||||
- 如果选中 face 周围有共享边且共面的碎面,程序会自动把这些 face 合成同一片推拉区域,属性表和历史记录会显示 `推拉共面区域 Face 数`。
|
- 如果选中 face 周围有共面且相接/重叠的碎面,程序会自动把这些 face 合成同一片推拉区域,属性表和历史记录会显示 `推拉共面区域 Face 数`。
|
||||||
- 加料时程序会让拉伸体和原实体产生极小重叠,并在布尔后尝试合并同域面/边,使导出的 STEP 更像一个整体实体。
|
- 加料时程序会让拉伸体和原实体产生极小重叠,并在布尔后尝试合并同域面/边,使导出的 STEP 更像一个整体实体。
|
||||||
- 程序随后在后台执行真实布尔运算,完成后再刷新为真正修改后的模型。
|
- 程序随后在后台执行真实布尔运算,完成后再刷新为真正修改后的模型。
|
||||||
- 选中平面 face 后,可以在 `属性表` 的方向/轴线分组里查看 `推拉向外方向`、`推拉向内方向` 和 `推拉方向置信度`。
|
- 选中平面 face 后,可以在 `属性表` 的方向/轴线分组里查看 `推拉向外方向`、`推拉向内方向` 和 `推拉方向置信度`。
|
||||||
@@ -476,12 +515,18 @@ python main.py --smoke-test
|
|||||||
- 缩小孔径时,操作历史会额外记录补料策略、补料半径和补料高度。
|
- 缩小孔径时,操作历史会额外记录补料策略、补料半径和补料高度。
|
||||||
- 端部类型是通过轴线端部 inside / outside 采样得到的估算,不等于 CAD 原始建模历史里的“孔深”参数。
|
- 端部类型是通过轴线端部 inside / outside 采样得到的估算,不等于 CAD 原始建模历史里的“孔深”参数。
|
||||||
- 如果选到的圆柱面不是孔,而是柱或圆角,结果可能不是你想要的,所以它目前仍是实验功能。
|
- 如果选到的圆柱面不是孔,而是柱或圆角,结果可能不是你想要的,所以它目前仍是实验功能。
|
||||||
|
- `槽/半孔宽度` + `调整槽/半孔宽度`:
|
||||||
|
- 先切换到 `Feature` 选择模式并选择一个槽/半孔候选 face,或从圆柱候选表中选择局部圆柱形 `hole/groove candidate`。
|
||||||
|
- 选中符合条件的槽/半孔后,`槽/半孔宽度` 会自动填入一个比当前槽宽估算略大的建议值。
|
||||||
|
- 输入目标槽宽后点击 `调整槽/半孔宽度`。
|
||||||
|
- 程序会用当前圆弧角度把目标槽宽换算成目标圆柱直径,再复用圆柱孔/槽调整流程。
|
||||||
|
- 这一步仍是 B-Rep 几何近似;复杂槽、非圆柱槽或圆弧角度不稳定时可能失败并回滚。
|
||||||
- `凸台直径` + `调整圆柱凸台直径`:
|
- `凸台直径` + `调整圆柱凸台直径`:
|
||||||
- 先选择一个完整圆柱凸台 face,建议从 `第一版可编辑对象` 中点击 `调整圆柱凸台直径` 行进入。
|
- 先选择一个完整圆柱凸台 face,建议从 `第一版可编辑对象` 中点击 `调整圆柱凸台直径` 行进入。
|
||||||
- 只有接近完整圆柱的 `boss/outer-round candidate` 会放行;局部外圆角、圆角面、孔/槽和未明确圆柱面会被阻止。
|
- 只有接近完整圆柱的 `boss/outer-round candidate` 会放行;局部外圆角、圆角面、孔/槽和未明确圆柱面会被阻止。
|
||||||
- 目标直径大于当前直径时,会在选中圆柱面的轴向范围内生成目标直径圆柱并 Fuse。
|
- 目标直径大于当前直径时,会在同域圆柱侧壁范围内生成目标直径圆柱并 Fuse。
|
||||||
- 目标直径小于当前直径时,会生成环形 cutter,把目标直径外侧到原直径附近的材料 Cut 掉。
|
- 目标直径小于当前直径时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建。
|
||||||
- 点击后会显示半透明预览:绿色表示扩大补料范围,红色表示缩小切削范围。
|
- 点击后会显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。
|
||||||
- 这不是 CAD 历史里的“修改凸台特征参数”,而是基于当前 B-Rep 的受限几何布尔编辑。
|
- 这不是 CAD 历史里的“修改凸台特征参数”,而是基于当前 B-Rep 的受限几何布尔编辑。
|
||||||
- `封堵圆柱孔`:
|
- `封堵圆柱孔`:
|
||||||
- 先选择一个完整圆柱孔 face,建议从 `第一版可编辑对象` 中点击 `封堵圆柱孔` 行进入。
|
- 先选择一个完整圆柱孔 face,建议从 `第一版可编辑对象` 中点击 `封堵圆柱孔` 行进入。
|
||||||
@@ -527,14 +572,18 @@ python main.py --smoke-test
|
|||||||
- 真实编辑会在后台调用 OCCT 倒角 API,完成后刷新模型。
|
- 真实编辑会在后台调用 OCCT 倒角 API,完成后刷新模型。
|
||||||
- 当前只支持对称距离倒角,不支持两侧不同距离或距离+角度倒角。
|
- 当前只支持对称距离倒角,不支持两侧不同距离或距离+角度倒角。
|
||||||
- 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒角;失败时程序会提示并尝试恢复编辑前状态。
|
- 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒角;失败时程序会提示并尝试恢复编辑前状态。
|
||||||
- `边目标长度` + `调整直线边长度`:
|
- `边目标长度` + `直接修改边长`:
|
||||||
- 先切换到 `Edge` 选择模式并选择一条直线 edge,或从 `第一版可编辑对象` 中点击 `调整直线边长度` 行进入。
|
- 先切换到 `Edge` 选择模式并选择一条 edge,或从 `第一版可编辑对象` 中点击边长相关行进入。
|
||||||
- 选中直线 edge 后,`边目标长度` 会自动填入当前边长。
|
- 选中 edge 后,`边目标长度` 会自动填入当前边长。
|
||||||
- 把它改成目标长度后点击 `调整直线边长度`。
|
- 右侧边长基准下拉框可以选择 `自动`、`中心`、`固定起点` 或 `固定终点`。
|
||||||
- 程序会先查找该边端点附近是否有可推拉的平面端面,并计算需要移动的端面 face 和推拉距离。
|
- 把它改成目标长度后点击 `直接修改边长`。
|
||||||
- 通过检查后,会显示蓝绿色半透明预览,表示将要移动的端面推拉范围。
|
- 直线 edge 会优先查找端点附近是否有可推拉的平面端面,并计算需要移动的端面 face 和推拉距离。
|
||||||
- 真实编辑复用平面推拉的布尔流程,执行失败时会回滚到编辑前状态。
|
- 圆形/圆弧 edge 会优先尝试复用相邻圆柱面的孔/槽或凸台直径编辑,并在确认窗口显示换算出来的目标直径。
|
||||||
- 如果这条边没有明确端面,或端面方向与边长变化不匹配,会被阻止。这是受限功能,不是任意边参数化改长。
|
- `固定起点` 会尽量让起点不动、移动终点侧;`固定终点` 相反;`中心` 会让缩放 fallback 以 edge 中心为基准。
|
||||||
|
- 如果没有明确端面或可复用圆柱面,程序会按所选基准尝试整体/轴向几何缩放 fallback;这会影响所属 part/solid 的其它尺寸,因此会弹出高风险确认。
|
||||||
|
- 通过检查后,会显示蓝绿色半透明预览,表示将要移动或缩放的范围。
|
||||||
|
- 真实编辑在后台执行,失败时会回滚到编辑前状态。
|
||||||
|
- 这是第一版 B-Rep 结果几何修改,不是任意边参数化改长。
|
||||||
- `平移 X/Y/Z` + `平移选中零件` / `平移选中 solid`:
|
- `平移 X/Y/Z` + `平移选中零件` / `平移选中 solid`:
|
||||||
- 输入 X、Y、Z 三个方向的平移量。
|
- 输入 X、Y、Z 三个方向的平移量。
|
||||||
- 选择 `Part` 后点击 `平移选中零件`,会移动整个 part。
|
- 选择 `Part` 后点击 `平移选中零件`,会移动整个 part。
|
||||||
@@ -564,20 +613,59 @@ python main.py --smoke-test
|
|||||||
- 历史详情会显示热力图统计,包括最大距离、平均距离和发生明显变化的采样点比例。
|
- 历史详情会显示热力图统计,包括最大距离、平均距离和发生明显变化的采样点比例。
|
||||||
- `清除差异预览` 可以关闭红/绿叠加显示。
|
- `清除差异预览` 可以关闭红/绿叠加显示。
|
||||||
- `导出差异报告` 会把当前选中的历史记录导出为 `.txt`,里面包含操作详情、拓扑变化、几何变化和热力图统计。
|
- `导出差异报告` 会把当前选中的历史记录导出为 `.txt`,里面包含操作详情、拓扑变化、几何变化和热力图统计。
|
||||||
|
- `导出编辑历史` 会把当前会话中的本软件编辑记录导出为 `.json`,用于留档和后续复盘。
|
||||||
- 当前热力图是“编辑后模型顶点到编辑前模型表面”的距离估算,不是完整 CAD 公差报告。
|
- 当前热力图是“编辑后模型顶点到编辑前模型表面”的距离估算,不是完整 CAD 公差报告。
|
||||||
- 点击某条历史记录时,程序也会尝试重新高亮当时编辑的目标 face,并用青色小点标出当时的拾取位置。
|
- 点击某条历史记录时,程序也会尝试重新高亮当时编辑的目标 face,并用青色小点标出当时的拾取位置。
|
||||||
- 由于布尔编辑后拓扑 ID 可能重新分配,历史里的 face ID 只能作为定位参考,不能视为稳定的 CAD 建模历史 ID。
|
- 由于布尔编辑后拓扑 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 文件。
|
- `.gitignore`:Git 忽略规则,避免提交 Python 缓存、临时文件和导出的 STEP 文件。
|
||||||
- `geom_extract.step`:当前默认测试模型。
|
- `*_edited.step`、`*_export.step`、`*_diff_*.txt`、`*_operation_history.json`:运行或导出时产生的结果文件,默认不提交。
|
||||||
- `README.md`:项目说明和使用说明。
|
- `docs/screenshots/`:调试截图和问题截图,例如第三方软件显示效果、模型结构树截图等。
|
||||||
|
|
||||||
## 当前版本边界
|
## 当前版本边界
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .app import StepEditorWindow, main
|
||||||
|
from .model import StepModel
|
||||||
|
|
||||||
|
__all__ = ["StepEditorWindow", "StepModel", "main"]
|
||||||
@@ -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())
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 ""
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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']}."
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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("__")]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QComboBox
|
||||||
|
|
||||||
|
|
||||||
|
class NoWheelComboBox(QComboBox):
|
||||||
|
def wheelEvent(self, event) -> None:
|
||||||
|
event.accept()
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 "模型已恢复到操作前状态,历史记录未移动。"
|
||||||
|
|
||||||
@@ -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))
|
||||||
-4616
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user