feat: 完善 STEP 编辑器最小系统和稳定性

This commit is contained in:
2026-07-28 14:05:14 +08:00
parent 26216064e0
commit 7c263d1e96
19 changed files with 3173 additions and 931 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ dist/
*_operation_history.json
*.log
docs/screenshots/
assets/screenshots/
.DS_Store
Thumbs.db
+392 -228
View File
@@ -2,7 +2,15 @@
这是一个基于 Python、pythonocc-core/OCCT、VTK 和 PySide6/Qt 的 STEP 模型查看、选择、局部编辑与导出原型。
当前项目目录下的 `geom_extract.step` 是默认测试模型。程序入口是 `main.py`
仓库里的 `assets/models/geom_extract.step` 是默认测试模型。程序入口是 `main.py`
## 快速读懂
- 这是一个 STEP B-Rep 结果几何查看和受限局部编辑工具,不是完整参数化 CAD 内核。
- 主入口是 `python main.py`;默认模型是 `assets/models/geom_extract.step`;立方体测试模型是 `assets/models/cube_10mm.step`
- 左侧操作面板按工作流排列:STEP 文件、模型结构树、鼠标选择模式、按 ID 选择、编辑、导出、显示、测量、可编辑对象、圆柱面候选、操作历史和对象信息。
- 局部编辑会先做计划、风险提示、预览和后台执行;失败时会尽量回滚,成功后进入撤销/重做历史。
- 新开 Codex 聊天框继续开发时,只需要 Codex 阅读 README 末尾的“Codex 项目记忆”;普通开发者可以忽略那一节。
## 最终要实现什么
@@ -10,45 +18,45 @@
- 加载 `.step` / `.stp` 模型文件。
- 展示三维模型,支持旋转、缩放、平移。
- 读取 STEP 中的零件、装配、solid 等结构。
- 用户可以选择零件、solid、面、边和局部几何特征。
- 读取 STEP 中的零件、装配、Solid 等结构。
- 用户可以选择零件、Solid、Face、Edge 和局部几何特征。
- 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。
- 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。
- 用户可以修改可控的局部特征,比如平面推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、凸台直径调整、边倒圆、边倒角和第一版直接边长调整
- 用户可以撤销/重做修改,避免实验性编辑一步做坏。
- 用户可以修改可控的局部特征,比如平面推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、凸台直径调整、Edge倒圆、Edge倒角和修改Edge长度
- 用户可以撤销/重做修改,避免一步编辑做坏。
- 用户可以导出修改后的完整模型。
- 如果 STEP 文件里存在多个零件,用户可以只导出选中的某个零件。
需要注意:STEP 文件通常是 B-Rep 几何数据,不是带完整建模历史的参数化 CAD 原文件。所以“随便选一条边改长度”不一定能稳定实现,实际会转化成更可靠的操作,比如移动某个面、重切某个孔、调整某个圆角等。
## 第一版先实现什么
## 最小系统先实现什么
第一版目标是先把完整工作链路打通:
最小系统目标是先把完整工作链路打通:
- 能打开 STEP。
- 能显示模型。
- 能读取 STEP 中的零件/装配标签。
- 能选择 part、solid、face、edge。
- 能选择 Part、Solid、Face、Edge。
- 能显示选中对象的基本属性。
- 能高亮选中对象。
- 能导出整个当前模型。
- 能导出选中的零件。
- 能撤销/重做实验性编辑。
- 能撤销/重做编辑。
- 先实现少量可控编辑操作,用于验证后续特征编辑路线。
## 现在已经实现了什么
当前已经实现:
- 默认加载 `geom_extract.step`
- 默认加载 `assets/models/geom_extract.step`
- 支持打开其他 `.step` / `.stp` 文件。
- 打开 STEP 时会先在临时模型里完成读取和显示网格生成;失败时当前模型保持不变。
- 使用 XCAF 读取 STEP 中的 part / assembly 标签。
- 使用 XCAF 读取 STEP 中的 Part / Assembly 标签。
- 模型结构树会用中文显示 STEP 里的装配、零件和实体层级,并在对应零件下面列出实体子节点。
- 点击模型结构树中的实体子节点,可以直接选中、高亮、查看属性并用于导出该实体。
- 使用 OCCT 读取真实 B-Rep 拓扑。
- 使用 VTK 显示模型,并通过 PySide6/Qt 承载桌面界面。
- 左侧操作面板按功能模块分组显示,并使用四边同色的轻量彩色边框和标题层级区分文件、选择、显示、导出、编辑、候选、历史和属性信息。
- 左侧操作面板按功能模块分组显示,并使用四边同色的轻量彩色边框和标题层级区分文件、模型树、选择、编辑、导出、显示、测量、候选、历史和属性信息。
- 支持选择模式:
- Part
- Solid
@@ -56,26 +64,26 @@
- Edge
- Feature
- 鼠标悬停对象会以红色预高亮,真正选中后会以黄色高亮。
- `Face` 模式会把属于同一几何面的连续区域一起高亮;平面会按共面且投影范围相接/重叠查找,圆柱面会按同一实体内同轴、同半径且轴向连续/重叠查找,不再要求旧段和新段必须共享同一条拓扑 edge。例如推拉后侧壁被 OCCT 拆成上下多段 face,点击任意一段时都会把整片同域区域一起高亮。
- 推拉后程序会尽量保留侧壁面区域的 `逻辑 Face ID`:即使 OCCT 把原来的一个侧壁拓扑 face 重建成上下两段,新旧两段也会绑定回推拉前那片侧壁的逻辑 ID。属性表会同时显示 `逻辑 Face ID``拓扑 Face ID`;前者面向用户选择和按 ID 定位,后者用于调试当前 B-Rep 拓扑。
- 操作历史会记录 `target_logical_id` 和当时的拓扑 face id。点击历史记录定位 face / feature 时,会优先用逻辑 ID 找回当前模型中的整片面区域;撤销/重做快照也会保留这层逻辑 ID 映射。
- 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界 edge。
- `Face` 模式会把属于同一几何面的连续区域一起高亮;平面会按共面且投影范围相接/重叠查找,圆柱面会按同一实体内同轴、同半径且轴向连续/重叠查找,不再要求旧段和新段必须共享同一条拓扑Edge。例如推拉后侧壁被 OCCT 拆成上下多段Face,点击任意一段时都会把整片同域区域一起高亮。
- 推拉后程序会尽量保留侧壁面区域的 `逻辑 Face ID`:即使 OCCT 把原来的一个侧壁拓扑Face重建成上下两段,新旧两段也会绑定回推拉前那片侧壁的逻辑 ID。属性表会同时显示 `逻辑 Face ID``拓扑 Face ID`;前者面向用户选择和按 ID 定位,后者用于调试当前 B-Rep 拓扑。
- 操作历史会记录 `target_logical_id` 和当时的拓扑Face ID。点击历史记录定位Face / Feature时,会优先用逻辑 ID 找回当前模型中的整片面区域;撤销/重做快照也会保留这层逻辑 ID 映射。
- 鼠标选择会按当前模式做就近映射;例如 `Edge` 模式点到面时,会自动选择鼠标附近的边界Edge。
- 支持按当前选择模式输入 ID 直接选择 Part / Solid / Face / Edge / Feature。
- Face / Feature 按 ID 选择会优先解析 `逻辑 Face ID`。例如推拉前侧壁是 558,推拉后当前拓扑里变成 1556 和 1802,只要它们被绑定到逻辑 558,输入 558 就会选中这片面区域。
- `鼠标选择模式` 下拉框不会响应鼠标滚轮,避免鼠标经过时误切换模式;需要点击后手动选
- `鼠标选择模式` 下拉框关闭时会把鼠标滚轮交还给左侧滚动面板,避免鼠标经过时误切换模式;打开下拉列表后仍可用滚轮滚动选
- `按 ID 选择` 会在输入框和 `选择` 按钮之间同步显示当前鼠标选择模式。
- `Feature` 模式现在会把点到的 face 解释为局部几何特征候选:
- `Feature` 模式现在会把点到的Face解释为局部几何特征候选:
- 平面会识别为可推拉平面候选。
- 平面会尝试查找同一 solid 内投影重叠的相对平面,用于估算薄壁/壳体局部区域厚度。
- 平面会尝试查找同一Solid内投影重叠的相对平面,用于估算薄壁/壳体局部区域厚度。
- 圆柱面会识别为圆柱孔候选、槽/半孔候选、圆角/倒圆候选、凸台/外圆候选或未明确圆柱特征。
- 圆柱特征会先把同一实体内同轴、同半径且轴向连续/重叠的完整圆柱 face 当作同一侧壁区域,再尝试找出边界 edge、相邻 face、端面 face、开口端相邻 face 和疑似底面 face,并一起高亮相关局部区域。
- 槽/半孔候选会额外显示槽宽、圆弧长度、槽深估算和槽边界相邻 face。
- 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑 face。
- 圆柱特征会先把同一实体内同轴、同半径且轴向连续/重叠的完整圆柱Face当作同一侧壁区域,再尝试找出边界Edge、相邻Face、端面Face、开口端相邻Face和疑似底面Face,并一起高亮相关局部区域。
- 槽/半孔候选会额外显示槽宽、圆弧长度、槽深估算和槽边界相邻Face。
- 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑Face。
- 支持显示选中对象信息,并提供 `属性表` / `原始文本` 两种查看方式:
- 零件名称
- solid 数量
- face 数量
- edge 数量
- Solid 数量
- Face 数量
- Edge 数量
- 面类型
- 面面积
- 平面法向
@@ -84,163 +92,176 @@
- 边长度
- 圆边半径/直径
- 属性面板现在还会显示更多几何调试信息:
- part / solid 的包围盒、尺寸、体积、重心、表面积。
- face 的方向、面积中心、UV 参数范围、边界边数量、包围盒。
- 平面 face 的几何法向和按拓扑方向修正后的法向。
- 平面 face 的推拉向外方向、向内方向和方向判断置信度。
- 圆柱 face 的轴线点、轴方向、半径、直径、角度跨度、估算高度和是否接近完整圆柱。
- edge 的参数范围、起点、终点、长度中心、包围盒。
- 直线 edge 的方向,圆弧 edge 的圆心、轴线、半径和直径。
- Part / Solid 的包围盒、尺寸、体积、重心、表面积。
- Face 的方向、面积中心、UV 参数范围、边界边数量、包围盒。
- 平面Face的几何法向和按拓扑方向修正后的法向。
- 平面Face的推拉向外方向、向内方向和方向判断置信度。
- 圆柱Face的轴线点、轴方向、半径、直径、角度跨度、估算高度和是否接近完整圆柱。
- Edge 的参数范围、起点、终点、长度中心、包围盒。
- 直线Edge的方向,圆弧Edge的圆心、轴线、半径和直径。
- 属性面板支持复制当前对象 ID、拾取坐标和完整信息。
- 面/边属性查询带缓存;重复选择同一个对象会复用上次计算结果,模型编辑、撤销、重做或重新加载后缓存会自动清空。
- 支持高亮选中的零件、solid、面、边或局部特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face 选择会对共面平面和同轴同半径圆柱面做同域区域高亮。
- 默认模型边线渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔推拉后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部 edge,仍然可以单独高亮查看。
- 支持高亮选中的零件、Solid、Face、Edge或局部特征候选,并在鼠标悬停时用红色预览当前可选对象;普通 Face 选择会对共面平面和同轴同半径圆柱面做同域区域高亮。
- 默认模型边线渲染会隐藏同一平面或同一圆柱面内部的拓扑分割边,也会隐藏同域区域里几何位置重复的拼接边,减少布尔推拉后新旧侧壁交界处看起来像“两块拼起来”的视觉伤疤;`显示` 面板里的 `显示同域内部边` 可以临时打开完整拓扑边线。如果按 ID 或 Edge 模式专门选中这类内部Edge,仍然可以单独高亮查看。
- 支持只显示当前选中对象、对准当前选中对象,并可一键恢复显示完整模型。
- 支持两点测量:
- 可以把当前选中对象的拾取点设为 A 或 B;如果没有拾取点,会退回使用对象中心、面积中心、长度中心、重心或包围盒中心。
- 设置 A/B 后会显示两点距离和 X/Y/Z 方向差值。
- 3D 视图中会显示一条青色测量线;测量结果可以复制,加载新模型时会自动清除旧测量。
- Feature 模式会在属性表里显示特征来源 face、侧壁 face、同域圆柱范围、端面 face、疑似底面 face、开口端相邻 face、边界 edge 和当前可用操作。
- Feature 模式会在属性表里显示特征来源Face、侧壁Face、同域圆柱范围、端面Face、疑似底面Face、开口端相邻Face、边界Edge和当前可用操作。
- 支持导出当前完整模型为 STEP。
- 支持导出选中的 part 为 STEP。
- 支持导出选中的 solid 为 STEP。
- 支持导出选中的 face / 同域面区域为 STEP;如果当前 face 与共面或同轴同半径的相邻面一起高亮,导出也会包含整片黄色区域。
- 支持导出选中的Part为 STEP。
- 支持导出选中的Solid为 STEP。
- 支持导出选中的Face / 同域面区域为 STEP;如果当前Face与共面或同轴同半径的相邻面一起高亮,导出也会包含整片黄色区域。
- 支持导出选中特征区域为 STEP。
- 支持导出选中的 edge 为 STEP。
- 支持导出质量检查:导出前会检查 B-Rep 有效性、solid / face / edge 数量、体积和包围盒;如果发现风险,会先弹窗确认。
- 支持一键修复当前模型或选中零件/solid:执行 ShapeFix 和同域面/边合并,修复会进入后台编辑流程,并支持撤销/重做。
- 导出完整模型、part 或 solid 前,会先尝试执行 ShapeFix 和同域面/边合并,减少外部 CAD 打开时出现碎面、坏体或明显拼接痕迹的概率。
- 实验性编辑按钮会根据当前选中对象自动启用或禁用,并通过悬停提示说明为什么当前操作不可用,减少 face、edge、孔、凸台、盲孔等对象混用导致的误操作。
- 支持扫描第一版可编辑对象:
- 支持导出选中的Edge为 STEP。
- 支持导出质量检查:导出前会检查 B-Rep 有效性、Solid / Face / Edge 数量、体积和包围盒;如果发现风险,会先弹窗确认。
- 支持一键修复当前模型或选中零件/Solid:执行 ShapeFix 和同域面/边合并,修复会进入后台编辑流程,并支持撤销/重做。
- 导出完整模型、Part 或 Solid 前,会先尝试执行 ShapeFix 和同域面/边合并,减少外部 CAD 打开时出现碎面、坏体或明显拼接痕迹的概率。
- 编辑按钮会根据当前选中对象自动启用或禁用,并通过悬停提示说明为什么当前操作不可用,减少 Face、Edge、孔、凸台、盲孔等对象混用导致的误操作。
- 支持扫描可编辑对象:
- 默认扫描优先保证界面响应速度,会列出一组有代表性的候选。
- `深度扫描` 会尝试列出更多候选,适合默认列表里没有找到目标对象时使用。
- 可推拉的平面 face。
- 可调整孔径的圆柱 face 候选。
- 可推拉的平面Face。
- 可调整孔径的圆柱Face候选。
- 可调整槽/半孔宽度的局部圆柱孔/槽候选。
- 可调整盲孔深度的圆柱 face 候选。
- 可添加新圆角的直线 edge 候选。
- 可添加倒角的直线 edge 候选。
- 可尝试通过端面推拉调整长度的直线 edge 候选。
- 可调整盲孔深度的圆柱Face候选。
- 可添加新圆角的直线Edge候选。
- 可添加倒角的直线Edge候选。
- 可尝试直接调整长度的直线Edge候选,简单平面多面体会优先局部边形变
- 表格会显示当前值、状态、风险、置信度和说明。
- 盲孔深度候选会在列表阶段先确认疑似底面,列表里的深度值优先使用底面 face 轴向位置计算。
- 点击表格行会直接选中对应 face 或 edge,方便继续执行推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、边倒圆、边倒角或直接边长调整
- 盲孔深度候选会在列表阶段先确认疑似底面,列表里的深度值优先使用底面Face轴向位置计算。
- 点击表格行会直接选中对应Face或Edge,方便继续执行推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、Edge倒圆、Edge倒角或修改Edge长度
- 支持列出圆柱面候选特征。
- 圆柱候选会显示在表格中,点击候选行可以直接选中并高亮对应 face。
- 圆柱候选可以按类型筛选:全部、孔/槽候选、圆角候选、凸柱/外圆候选、未明确分类。
- 圆柱候选的类型下拉框不会响应鼠标滚轮,避免滚动左侧面板时误改筛选条件。
- 圆柱候选表格会显示初步分类:
- `hole/groove candidate`:孔/槽一类的凹向圆柱候选。
- 圆柱候选会显示在表格中,点击候选行可以直接选中并高亮对应 Face。
- 圆柱候选可以按类型筛选:全部、孔/槽候选、圆角候选、凸柱/外圆候选、未明确分类。
- 圆柱候选的类型下拉框关闭时会把鼠标滚轮交还给左侧滚动面板,避免滚动面板时误改筛选条件。
- 圆柱候选表格会显示初步分类:
- `hole/groove candidate`:孔/槽一类的凹向圆柱候选。
- `round/fillet candidate`:小半径局部圆柱面,可能是圆角/倒圆。
- `boss/outer-round candidate`:凸柱/外圆角一类的外向圆柱候选。
- `boss/outer-round candidate`:凸柱/外圆角一类的外向圆柱候选。
- `cylindrical face`:暂时无法明确分类的圆柱面。
- 圆柱候选表格会显示直径、角度跨度、估算高度和置信度。
- 圆柱候选表格会显示 `risk`,用于提示调整圆柱孔径的风险。
- 圆柱候选表默认列出前 60 个候选,以避免打开大模型时刷新过慢。
- 实验性支持平面 face 推拉。
- 圆柱候选表格会显示直径、角度跨度、估算高度和置信度。
- 圆柱候选表格会显示 `risk`,用于提示调整圆柱孔径的风险。
- 圆柱候选表默认列出前 60 个候选,以避免打开大模型时刷新过慢。
- 当前版本支持平面Face推拉。
- 平面推拉会自动判断面两侧的 inside / outside,输入正数表示向外加料,输入负数表示向内切削。
- 推拉平面会先显示半透明预览体,表示推拉方向和大致范围,然后在后台执行真实 OCCT 布尔计算。
- 推拉平面现在会从选中 face 出发,自动查找共面且相接/重叠的相邻 face,并把它们作为同一片平面区域一起推拉。
- 推拉平面现在会从选中Face出发,自动查找共面且相接/重叠的相邻Face,并把它们作为同一片平面区域一起推拉。
- 这可以减少 STEP 碎面导致的“只推了一小块,导出后像贴上去一块,中间有伤疤”的情况。
- 如果选中的平面被识别为完整圆柱端盖,并且是向外拉长,程序会优先使用“完整旧段 + 新段圆柱”的专用 Fuse 路线,而不是只把一个短圆柱 prism 拼到端面上。简单圆柱场景下,这会让拉长结果保持为一个连续圆柱侧面,减少新旧圆柱段拼接痕迹。
- 推拉加料时,工具体会向原实体内部保留一个很小的重叠量,再执行 Fuse,避免只靠两个面贴合导致导出后像“原模型 + 新增块”叠在一起。
- Fuse/Cut 完成后会执行更强的推拉结果清理:按模型尺寸设置小容差,显式禁止保留 internal edges,并多轮执行 OCCT 同域面/边合并,减少第三方 CAD 中出现不必要共面分割线的概率。
- 布尔计算前会启用 OCCT glue/fuzzy 选项,布尔后会调用 `SimplifyResult` 和同域合并;但某些 CAD/OCCT 场景仍可能保留拓扑分割边。当前程序会先在选择层把同域平面/圆柱区域作为整体高亮,并在默认边线渲染中隐藏同域内部拓扑边和几何重复拼接边;真正从 STEP/B-Rep 拓扑里消除这类分割边,还需要后续做更专门的局部圆柱/侧壁重建。
- 如果推拉本身产生了真实台阶边界,第三方 CAD 仍会显示这条几何边;清理只能减少不必要的共面残留边,不能消除真实形状边界。
- 推拉平面会先生成操作计划:检查距离是否为 0、方向置信度和相对 face 尺寸的风险;能推拉时直接执行,不能推拉时会提示阻止原因。
- 调整圆柱孔径也会先显示半透明预览体:红色表示切削 cutter,绿色表示缩小孔径时的补料范围;如果圆柱侧壁被拆成多段同域 face,工具范围会优先覆盖整段同域圆柱。
- 支持第一版盲孔/盲槽深度调整:
- 只对 `hole/groove candidate` 且端部类型为 `blind`、并识别到疑似底面 face 的圆柱候选开放。
- 推拉平面会先生成操作计划:检查距离是否为 0、方向置信度和相对Face尺寸的风险;能推拉时直接执行,不能推拉时会提示阻止原因。
- 调整圆柱孔径也会先显示半透明预览体:红色表示切削 cutter,绿色表示缩小孔径时的补料范围;如果圆柱侧壁被拆成多段同域Face,工具范围会优先覆盖整段同域圆柱。
- 当前版本支持盲孔/盲槽深度调整:
- 只对 `hole/groove candidate` 且端部类型为 `blind`、并识别到疑似底面 Face 的圆柱候选开放。
- 目标深度大于当前估算深度时,会沿开口到疑似底面方向用有限长度 cutter 加深切削。
- 目标深度小于当前估算深度时,会从目标新底面到旧底面方向补料,让孔变浅。
- 调整盲孔深度会先显示半透明预览体:红色表示加深切削范围,绿色表示变浅补料范围。
- 孔深调整计划会记录当前深度、目标深度、变化比例、疑似底面、开口端和工具参数。
- 当前深度会优先使用疑似底面 face 在圆柱轴线上的位置计算;如果相邻端面找不到底面,会额外扫描同一 solid 内轴线附近的平面圆盘候选。
- 支持第一版槽/半孔候选结构化识别:
- 当前深度会优先使用疑似底面Face在圆柱轴线上的位置计算;如果相邻端面找不到底面,会额外扫描同一Solid内轴线附近的平面圆盘候选。
- 当前版本支持槽/半孔候选结构化识别:
- 对局部圆柱形 `hole/groove candidate`,会标记为 `partial-cylindrical-groove`
- 会估算槽宽、圆弧长度和槽深。
- 会识别槽圆柱 face 和槽边界相邻 face,并在 Feature 模式下一起高亮。
- 会识别槽圆柱Face和槽边界相邻Face,并在 Feature 模式下一起高亮。
- 这些仍是 B-Rep 几何估算,不是 CAD 历史里的槽特征参数。
- 支持第一版槽/半孔宽度调整:
- 当前版本支持槽/半孔宽度调整:
- 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。
- 输入目标槽宽后,会按当前圆弧角度把槽宽换算为目标圆柱直径,再复用圆柱孔/槽重建路线执行。
- 这不是完整 CAD 槽参数编辑;复杂槽、非圆柱槽或圆弧角度不稳定时仍可能失败并回滚。
- 支持第一版已有圆角/倒圆候选结构化识别
- 当前版本支持薄壁/壳体局部厚度调整
- 对已找到投影重叠相对平面的平面候选开放。
- 输入目标厚度后,程序会把厚度变化换算成当前平面区域的推拉距离,保留相对平面不动。
- 会先显示半透明推拉预览,并记录当前厚度、目标厚度、相对平面Face、重叠率和推拉距离。
- 这是局部 B-Rep 平面推拉近似,不是 CAD 壳命令或完整壳体参数编辑。
- 当前版本支持已有圆角/倒圆候选结构化识别:
- 对局部小半径圆柱面,会标记为 `round/fillet candidate`
- 会估算已有圆角半径、圆弧角度和圆弧长度。
- 会识别已有圆角 face 和相邻支撑 face,并在 Feature 模式下一起高亮。
- `第一版可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径`,点击后会选中该圆角 face,并把圆角半径输入框预填为当前估算半径的参考目标值。
- 会识别已有圆角Face和相邻支撑Face,并在 Feature 模式下一起高亮。
- `可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径`,点击后会选中该圆角 Face,并把圆角半径输入框预填为当前估算半径的参考目标值。
- 选中已有圆角候选时,不会再误把它填入 `孔直径`,避免把圆角当孔径修改。
- 第一版已有圆角半径修改会先尝试移除圆角 face,再在恢复出的锐边上重新倒圆;只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。
- 支持第一版圆柱孔封堵:
- 当前版本的已有圆角半径修改会先尝试移除圆角Face,再在恢复出的锐边上重新倒圆;只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。
- 当前版本支持圆柱孔封堵:
- 只对 `hole/groove candidate` 且角度跨度接近完整圆柱的孔开放。
- 不对半孔/槽开放,避免用完整圆柱补料时把不该补的位置也补上。
- 封堵会用略带半径重叠的圆柱补料体执行 Fuse。
- 绿色半透明预览表示即将补上的材料范围。
- 当前默认模型可能不会在默认可编辑对象列表里显示封堵候选;其他包含完整圆柱孔的 STEP 文件可以使用这个能力。
- 支持第一版圆柱凸台直径调整:
- 当前版本支持圆柱凸台直径调整:
- 只对 `boss/outer-round candidate` 且角度跨度接近完整圆柱的凸台开放。
- 不处理局部外圆角、圆角面或未明确圆柱面,避免把圆角当凸台修改。
- 目标直径变大时,会在同域圆柱侧壁范围内生成目标直径圆柱并 Fuse。
- 目标直径变小时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建,避免环形 cutter 在部分场景下把实体切空。
- 会先显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。
- 当前默认模型可能没有明确凸台候选;包含完整圆柱凸台的 STEP 文件可以使用这个能力。
- 支持第一版直线边添加圆角:
- 只对直线 edge 开放。
- 会先检查边长、相邻 face 数量和目标半径相对边长的比例。
- 蓝色半透明预览表示目标 edge 和输入半径的大致范围。
- 当前版本支持直线Edge添加圆角:
- 只对直线Edge开放。
- 会先检查边长、相邻Face数量和目标半径相对边长的比例。
- 蓝色半透明预览表示目标Edge和输入半径的大致范围。
- 真实编辑使用 OCCT `BRepFilletAPI_MakeFillet` 在后台执行。
- 这一步是“给直线边添加新圆角”,还不是“修改已有圆角半径”。
- STEP 没有建模历史,某些看起来像直线边的对象仍可能被 OCCT 拒绝;失败时会自动尝试恢复编辑前状态。
- 支持第一版直线边添加倒角:
- 只对直线 edge 开放。
- 会先检查边长、相邻 face 数量和倒角距离相对边长的比例。
- 橙色半透明预览表示目标 edge 和输入倒角距离的大致范围。
- 当前版本支持直线Edge添加倒角:
- 只对直线Edge开放。
- 会先检查边长、相邻Face数量和倒角距离相对边长的比例。
- 橙色半透明预览表示目标Edge和输入倒角距离的大致范围。
- 真实编辑使用 OCCT `BRepFilletAPI_MakeChamfer` 在后台执行。
- STEP 没有建模历史,某些边仍可能被 OCCT 拒绝;失败时会自动尝试恢复编辑前状态。
- 支持第一版直接边长调整
- 选中 edge 后,`目标长度` 会自动填入当前边长
- 可以选择边长基准:自动、中心、固定起点、固定终点。
- 直线 edge 会优先尝试寻找端点附近、方向匹配的平面端面,并把“改边长”转换成该端面的平面推拉操作
- `自动` 会优先选择可局部推拉的端面;`固定起点` / `固定终点` 会优先尝试移动另一端端面。
- 圆形/圆弧 edge 会优先检查相邻圆柱面;如果能复用孔/槽或凸台直径编辑,就把目标边长按比例换算成目标圆柱直径再执行局部编辑
- `中心`、没有明确端面、或圆边没有可复用圆柱面时,会尝试整体/轴向几何缩放 fallback,以覆盖更多任意 edge 的最小可用场景
- 该 fallback 会影响所属 part/solid 的其它尺寸,因此会按高风险确认,并依赖失败回滚/撤销兜底
- 当前版本支持修改Edge长度
- 选中Edge后,`Edge目标长度` 会自动填入当前Edge长度
- 可以选择Edge长度基准:自动、中心、固定起点、固定终点。
- 对正方体这类全平面多面体,直线Edge会优先走“局部边形变”:只移动被选Edge的一个端点或两个端点,并重建周边平面
- 局部边形变不会把整个端面拉走,因此正方体不会默认变成长方体;相邻面会按被移动的边自然变成梯形或拆成三角面。
- `自动` 默认固定起点、移动终点;`固定起点` / `固定终点` 会明确控制哪一端不动;`中心` 会让两端各移动一半
- 如果模型不是简单全平面多面体,直线Edge会继续尝试寻找端点附近、方向匹配的平面端面,并把“改边长”转换成该端面的平面推拉操作
- 圆形/圆弧Edge会优先检查相邻圆柱面;如果能复用孔/槽或凸台直径编辑,就把Edge目标长度按比例换算成目标圆柱直径再执行局部编辑
- 圆边没有可复用圆柱面时,会优先围绕圆边轴线做径向缩放,尽量只改半径方向并保留轴向尺寸;这类一般仿射可能把解析圆/圆锥/圆柱几何转换为 B-spline。
- 椭圆边和可稳定采样为平面的 B-spline / Bezier 等非直线边,也会尝试围绕曲线所在平面法向做径向缩放;如果采样点不够平面,会退回更保守的缩放 fallback。
- 几何缩放 fallback 会先只预变换目标Edge来估算结果长度,并在必要时微调缩放比例,让实际边长更接近输入值。
- `中心`、没有明确端面、或非圆边没有更明确局部路径时,会尝试整体/轴向几何缩放 fallback,以覆盖更多任意Edge的最小可用场景。
- 该 fallback 会影响所属Part/Solid的其它尺寸,因此会按高风险确认,并依赖失败回滚/撤销兜底。
- 这仍不是通用参数化边长编辑;复杂拓扑、斜面过渡、圆角链或布尔失败场景仍可能不能稳定修改。
- 支持 part / solid 级平移:
- 支持 Part / Solid 级平移:
- 输入 X/Y/Z 平移量后,可以平移当前选中零件。
- 选中 solid、或选中属于某个 solid 的 face/edge 后,可以平移当前 solid。
- solid 零件中平移 solid 实际等同于移动整个 part shape,程序会按中风险提示。
- 选中Solid、或选中属于某个Solid的Face/Edge后,可以平移当前Solid。
-Solid零件中平移Solid实际等同于移动整个Part shape,程序会按中风险提示。
- 平移操作会写入历史记录,并支持撤销/重做。
- 支持 part / solid 级旋转:
- 选择 X/Y/Z 旋转轴并输入角度后,可以旋转当前选中零件或 solid。
- 第一版旋转中心取目标对象的包围盒中心。
- solid 零件中旋转 solid 实际等同于旋转整个 part shape,程序会按中风险提示。
- 支持 Part / Solid 级旋转:
- 选择 X/Y/Z 旋转轴并输入角度后,可以旋转当前选中零件或Solid。
- 当前版本的旋转中心取目标对象的包围盒中心。
-Solid零件中旋转Solid实际等同于旋转整个Part shape,程序会按中风险提示。
- 旋转操作会写入历史记录,并支持撤销/重做。
- 后台编辑计算时不再弹出模态进度框,也不再把主界面整体置灰;UI 线程只负责渲染和响应基础视图交互,真实 B-Rep 编辑在后台线程执行。
- 编辑计算期间,半透明预览会继续在 3D 视图里渲染,用户可以旋转或缩放查看;会改变模型状态的操作仍会暂时禁用,避免同一模型被多个后台编辑同时修改。
- 首次加载会先在后台生成粗显示数据并快速显示,随后再后台生成精细显示数据并替换到视图里。
- 鼠标悬停高亮做了节流和移动阈值,减少复杂模型上连续拾取造成的卡顿。
- 后台编辑成功后会尽量在后台一并生成刷新用的模型/边线显示数据,减少编辑完成瞬间的主线程冻结。
- 真实 B-Rep 结果会在布尔计算完成后一次性刷新;半透明预览不等于最终几何结果。
- 如果后台编辑失败,程序会自动尝试恢复到编辑前快照,避免出现“提示失败但模型已经被部分改动”的状态。
- 实验性支持圆柱孔/圆柱面扩大切削。
- 实验性支持圆柱孔/圆柱面缩小孔径:先补料,再用目标直径重切。
- 当前版本支持圆柱孔/圆柱面扩大切削。
- 当前版本支持圆柱孔/圆柱面缩小孔径:先补料,再用目标直径重切。
- 圆柱孔/圆柱面孔径调整现在会先生成切削计划:
- 目标直径必须大于 0;如果目标直径与当前直径几乎相同,会直接阻止。
- 会计算直径变化比例;变化超过当前直径 35% 会提高到中风险,超过 100% 会提高到高风险。
- 会比较目标直径和圆柱面估算高度;比例异常时会提高风险等级。
- 候选不是 `hole/groove candidate` 时会标为高风险并要求确认。
- 圆角/倒圆候选会标为高风险,避免误切圆角。
- 缩小孔径第一版只对 `hole/groove candidate` 开放,其他圆柱面会直接阻止。
- 选中 face 或实际切削计划会使用多点材料采样,比候选表里的快速分类更谨慎。
- 缩小孔径当前版本只对 `hole/groove candidate` 开放,其他圆柱面会直接阻止。
- 选中Face或实际切削计划会使用多点材料采样,比候选表里的快速分类更谨慎。
- 实际切削优先使用有限长度 cutter,按选中圆柱面的 V 参数范围加少量余量生成,不再默认贯穿整个零件。
- 会沿圆柱轴线采样两端材料状态,初步区分通孔/开口端、盲孔和封闭端。
- 会把圆柱相邻平面按轴线位置分成起点端/终点端,并结合端部采样标记疑似盲孔底面;相邻端面缺失时,会尝试扫描轴线附近的平面圆盘候选作为兜底。
- 孔径修改计划会记录疑似底面 face、开口端相邻 face 和 cutter 底面保护状态。
- 孔径修改计划会记录疑似底面Face、开口端相邻Face和 cutter 底面保护状态。
- 对盲孔/封闭端,有限长度 cutter 会使用更小的端部余量,降低切深变长的风险。
- 支持撤销/重做实验性编辑。
- 支持撤销/重做编辑。
- 撤销/重做会先确认模型恢复成功,再移动历史栈;如果恢复失败,会尽量回到操作前状态。
- 支持操作历史面板,显示当前已经成功执行的编辑。
- 操作历史详情会显示编辑类型、目标对象、参数、执行结果和拓扑数量变化。
- 操作历史详情会额外显示目标零件自身的 solid / face / edge 数量变化。
- 如果一次局部编辑导致目标零件 solid 数异常变化,历史详情会显示质量警告,状态栏也会提示先检查结果。
- 操作历史详情会额外显示目标零件自身的 Solid / Face / Edge 数量变化。
- 如果一次局部编辑导致目标零件 Solid 数异常变化,历史详情会显示质量警告,状态栏也会提示先检查结果。
- 操作历史详情会显示编辑前后的几何差异摘要:
- 体积变化。
- 表面积变化。
@@ -254,13 +275,13 @@
- 可以点击 `清除差异预览` 关闭叠加显示。
- 支持导出选中操作历史的差异报告,报告会包含操作参数、拓扑变化、几何变化和热力图统计。
- 鼠标拾取模型时会显示三维拾取点坐标。
- 实验性编辑历史会记录目标对象和当时的拾取点。
- 支持把本软件加载后的编辑历史导出为 JSON;这不是 STEP 原始 CAD 建模历史,而是后续操作留档
- 编辑历史会记录目标对象和当时的拾取点。
- 支持把本软件加载后的编辑历史导出为 JSON v2;除摘要和详情外,也会保存 `operation_name``target``parameters``result_message`,为后续安全复放做准备
- 点击操作历史记录时,程序会尝试重新高亮目标对象,并显示当时的拾取点标记。
- 对非 `hole/groove candidate` 的圆柱切削会先弹出确认,避免误切外圆角或凸柱。
- 提供 `--smoke-test` 启动检查,用于验证 Qt + VTK 窗口组件能正常初始化。
当前 `geom_extract.step` 的读取结果是:
当前 `assets/models/geom_extract.step` 的读取结果是:
```text
part: 2001-LOWER_HOUSING
@@ -277,12 +298,12 @@ vertices: 3262
这里分成两类看,不然容易误解为“完全没有推进”。
已经有第一版,但还需要继续稳定和完善:
已经有当前版本的初步实现,但还需要继续稳定和完善:
- 复杂特征识别:已经有平面、圆柱、边、孔/槽候选、槽/半孔候选、已有圆角候选、圆柱凸台候选等基础候选列表,但还不是完整 CAD 语义级识别。
- 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面 face 的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。
- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度调整、完整圆柱凸台直径修改平面薄壁/壳体相对面厚度估算;复杂槽、复杂凸台和完整壳体区域仍需要继续做。
- 已有圆角/倒圆面半径:已经有第一版受限修改实现,使用 defeature + refillet 路线,但复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。
- 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面Face的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。
- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度调整、完整圆柱凸台直径修改,以及平面薄壁/壳体相对面厚度估算和当前版本厚度调整;复杂槽、复杂凸台和完整壳体区域仍需要继续做。
- 已有圆角/倒圆面半径:已经有当前版本的受限修改实现,使用 defeature + refillet 路线,但复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。
- 局部偏差分析:已经有编辑前后体积、面积、包围盒、距离热力图和差异报告,但还不是工程公差级的局部区域偏差报告。
- 错误恢复和模型修复:已经有编辑前快照、失败回滚、基础 B-Rep 校验、同域面合并、布尔结果自动修复,以及一键修复当前模型或选中零件/solid,但还不是完整的模型修复流水线。
- 多零件装配:已经有装配/零件树和单独导出入口,但还没有做大规模、多来源 STEP 装配测试。
@@ -296,12 +317,12 @@ vertices: 3262
建议下一步按这个顺序推进:
1. 继续补齐更多最小可用特征入口,优先覆盖复杂槽、复杂凸台、局部薄壁/壳体区域和装配用例。
2. 继续验证平面推拉、孔径调整、槽/半孔宽度调整、盲孔/盲槽深度调整、圆柱凸台直径调整和直接边长调整,记录哪些对象稳定、哪些对象会失败。
3. 改进盲孔/槽底面识别,减少“看起来像 blind 但找不到可靠底面 face”的候选。
1. 继续补齐更多最小可用特征入口,优先覆盖复杂槽、复杂凸台、完整壳体区域和装配用例。
2. 继续验证平面推拉、孔径调整、槽/半孔宽度调整、薄壁厚度调整、盲孔/盲槽深度调整、圆柱凸台直径调整和直接边长调整,记录哪些对象稳定、哪些对象会失败。
3. 改进盲孔/槽底面识别,减少“看起来像 blind 但找不到可靠底面Face”的候选。
4. 继续加固已有圆角半径修改:扩大可成功场景,记录哪些圆角能 defeature + refillet,哪些需要更复杂的重建策略。
5. 完善本软件编辑历史的导入/复放能力,让后续编辑不只是导出 JSON 留档
6. 最后集中做性能和稳定性优化,包括首次加载后台精细化、扫描节流和高风险 OCC 操作隔离。
5. 完善本软件编辑历史的导入/复放能力;当前 JSON v2 已保存机器可读的动作名和参数,但自动复放还需要按操作类型逐步加安全规则
6. 继续做性能和稳定性优化,重点是扫描分批刷新、局部显示重建缓存,以及高风险 OCC 操作隔离。
## 怎么运行
@@ -336,6 +357,18 @@ python main.py
python main.py path\to\model.step
```
打开简单立方体测试模型:
```powershell
python main.py assets\models\cube_10mm.step
```
重新生成简单立方体测试模型:
```powershell
python scripts\generate_cube_step.py
```
如果当前 PowerShell 没识别 `conda`,可以直接使用完整路径运行:
```powershell
@@ -348,6 +381,12 @@ C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python main.py
python main.py --smoke-test
```
验证立方体上的“只改一条 Edge 长度”基线:
```powershell
python scripts\verify_edge_length_resize.py
```
## 怎么使用
打开程序后,右侧是 3D 模型窗口,左侧是操作面板。
@@ -358,7 +397,7 @@ python main.py --smoke-test
- 鼠标滚轮:缩放模型。
- 鼠标中键或组合拖动:平移模型,具体取决于 VTK 默认交互方式。
左侧操作面板按功能分区排列,每个模块都有四边同色的独立边框和颜色提示,方便快速区分文件、模型结构树、选择、显示、导出、编辑、候选列表、历史记录和对象信息。
左侧操作面板按功能分区排列,每个模块都有四边同色的独立边框和颜色提示,顺序大致按工作流排列:先打开和选择模型,再做编辑或导出,然后进行显示控制和测量,后面继续放可编辑对象、圆柱面候选、操作历史和对象信息。
左侧 `鼠标选择模式` 用来切换选择模式:
@@ -367,10 +406,10 @@ python main.py --smoke-test
- `Face`:选择面。
- `Edge`:选择边。
- `Feature`:按局部特征候选方式选择。平面会识别为可推拉平面候选;圆柱面会进一步识别为圆柱孔、槽/半孔、圆角/倒圆、凸台/外圆等候选。
圆柱候选会额外显示侧壁 face、端面 face、疑似底面 face、开口端相邻 face 和边界 edge。
圆柱候选会额外显示侧壁 Face、端面 Face、疑似底面 Face、开口端相邻 Face 和边界 Edge。
- 鼠标悬停时,当前模式下将要选中的对象会显示为红色;真正选中后会显示为黄色。
- 如果当前模式是 `Edge`,即使鼠标点到的是面,程序也会从这个面的边界里选取距离鼠标拾取点最近的 edge。
- `鼠标选择模式` 下拉框不响应鼠标滚轮,鼠标停在上面滚动时不会自动切换选项;需要点击下拉框后手动选
- 如果当前模式是 `Edge`,即使鼠标点到的是面,程序也会从这个面的边界里选取距离鼠标拾取点最近的Edge。
- `鼠标选择模式` 下拉框关闭时不会吃掉滚轮,鼠标停在上面滚动会继续滚动左侧面板;点击打开下拉列表后,滚轮才用于滚动选
左侧 `模型结构树` 会显示 STEP 里的装配、零件和实体层级;如果某个零件下有实体,会展开显示 `实体 0``实体 1` 这样的子节点。点击实体子节点会自动切换到 `Solid` 选择,并高亮对应实体。
@@ -388,18 +427,18 @@ python main.py --smoke-test
- 按 ID 选择会复用上方 `鼠标选择模式` 的当前类型;先切换模式,再输入 ID,点击 `选择`
- 输入框右侧会同步显示当前类型,例如 `Face``Edge``Solid`,用于确认这个 ID 会按哪类对象解释。
- `Part` 的 ID 从 1 开始。
- `Solid``Face``Edge``Feature` 的 ID 从 0 开始;`Feature` 使用来源 face 的 ID。
- 这个功能适合配合圆柱候选表格、操作历史和属性面板使用。
- `Solid``Face``Edge``Feature` 的 ID 从 0 开始;`Feature` 使用来源Face的 ID。
- 这个功能适合配合圆柱候选表格、操作历史和属性面板使用。
左侧 `显示` 用来临时缩小三维视图范围:
- `只显示选中`:只显示当前选中的 part、solid、face、edge 或 feature 区域。
- Face 模式下 `只显示选中` 会隔离当前黄色同域面区域,并显示这片区域的外边界 edge。
- `对准选中`:不改变显示范围,只把相机对准当前选中的 part、solid、face、edge 或 feature,适合快速查看小孔、小边和局部特征。
- `只显示选中`:只显示当前选中的 Part、Solid、Face、Edge 或 Feature 区域。
- Face 模式下 `只显示选中` 会隔离当前黄色同域面区域,并显示这片区域的外边界Edge。
- `对准选中`:不改变显示范围,只把相机对准当前选中的 Part、Solid、Face、Edge 或 Feature,适合快速查看小孔、小边和局部特征。
- `显示全部`:恢复显示完整模型。
- `显示同域内部边`:默认关闭,用来隐藏同一平面/同一圆柱面内部的拓扑分割边和几何重复拼接边;打开后可以查看完整 B-Rep 拓扑边线,适合调试布尔推拉后的分面情况。
- `导出选中面区域` 会导出当前 Face 模式黄色高亮的同域面区域,而不是只导出鼠标点到的单个拓扑 face。
- solid / edge 的显示查询已经使用拓扑索引,避免每次隔离或高亮时反复全模型查找。
- `导出选中面区域` 会导出当前 Face 模式黄色高亮的同域面区域,而不是只导出鼠标点到的单个拓扑Face。
- Solid / Edge 的显示查询已经使用拓扑索引,避免每次隔离或高亮时反复全模型查找。
- 这是视图操作,不会修改 STEP 几何,也不会写入操作历史。
左侧 `测量` 用来做最基础的点到点距离检查:
@@ -412,29 +451,29 @@ python main.py --smoke-test
- `复制测量` 会复制当前测量结果;`清除测量` 会清空 A/B 和测量线。
- 测量不会修改模型,也不会写入操作历史。
左侧 `第一版可编辑对象` 是当前 MVP 的主要入口:
左侧 `可编辑对象` 是当前 MVP 的主要入口:
- 点击 `扫描可编辑对象` 会列出当前第一版能尝试编辑的对象。
- 点击 `扫描对象` 会列出当前版能尝试编辑的对象。
- 点击 `深度扫描` 会用更高上限重新扫描,可能需要等待更久。
- 为了避免打开大模型时界面卡住,程序不会在加载 STEP 时自动扫描;需要编辑时再手动点击扫描。
- 当前默认显示一批代表性的可编辑候选,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。
- `推拉平面` 行表示这个 face 是平面,可以配合 `偏移``推拉平面` 使用。
- `调整圆柱孔径` 行表示这个 face 是圆柱候选,可以配合 `孔直径``调整圆柱孔径` 使用。
- `调整槽/半孔宽度` 行表示这个 face 是槽/半孔候选,可以配合 `槽/半孔宽度``调整槽/半孔宽度` 使用。
- `调整圆柱凸台直径` 行表示这个 face 是较明确的完整圆柱凸台候选,可以配合 `凸台直径``调整圆柱凸台直径` 使用。
- `封堵圆柱孔` 行表示这个 face 是接近完整圆柱的孔候选,可以点击后直接使用 `封堵圆柱孔`
- `调整盲孔深度` 行表示这个 face 是较明确的盲孔/盲槽候选,可以配合 `孔深度``调整盲孔深度` 使用。
- `给添加圆角` 行表示这个对象是直线 edge,可以配合 `圆角半径``给添加圆角` 使用。
- `给添加倒角` 行表示这个对象是直线 edge,可以配合 `倒角距离``给添加倒角` 使用。
- `修改已有圆角半径` 行表示这个 face 是已有圆角/倒圆候选,点击后会切到 `Feature` 模式并预填圆角半径参考值;真正执行需要再点击编辑区里的 `修改已有圆角半径` 按钮并确认。
- 当前默认显示一批代表性的可编辑对象,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。
- `推拉平面` 行表示这个Face是平面,可以配合 `Face偏移``推拉平面` 使用。
- `调整圆柱孔径` 行表示这个 Face 是圆柱候选,可以配合 `孔直径``调整圆柱孔径` 使用。
- `调整槽/半孔宽度` 行表示这个Face是槽/半孔候选,可以配合 `槽/半孔宽度``调整槽/半孔宽度` 使用。
- `调整圆柱凸台直径` 行表示这个Face是较明确的完整圆柱凸台候选,可以配合 `凸台直径``调整圆柱凸台直径` 使用。
- `封堵圆柱孔` 行表示这个Face是接近完整圆柱的孔候选,可以点击后直接使用 `封堵圆柱孔`
- `调整盲孔深度` 行表示这个Face是较明确的盲孔/盲槽候选,可以配合 `孔深度``调整盲孔深度` 使用。
- `给Edge添加圆角` 行表示这个对象是直线Edge,可以配合 `圆角半径``给Edge添加圆角` 使用。
- `给Edge添加倒角` 行表示这个对象是直线Edge,可以配合 `倒角距离``给Edge添加倒角` 使用。
- `修改已有圆角半径` 行表示这个Face是已有圆角/倒圆候选,点击后会切到 `Feature` 模式并预填圆角半径参考值;真正执行需要再点击编辑区里的 `修改已有圆角半径` 按钮并确认。
- `状态``ready` 表示比较适合尝试,`caution` 表示可以尝试但风险更高,`blocked` 表示当前参数或对象不适合执行。
- `风险` 越高越应该谨慎,尤其是圆角、凸柱或未明确圆柱面,不要直接当孔修改。
- 点击任意一行会自动选中并高亮对应 face 或 edge。
- 点击任意一行会自动选中并高亮对应Face或Edge。
选择对象后:
- 鼠标经过对象时会出现红色预高亮;被选对象会显示为黄色高亮。
-`Face` 模式下,如果相邻 face 属于同一几何面,并且投影范围或轴向范围相接/重叠,程序会把它们作为同域区域一起红色预高亮和黄色选中高亮;这主要用于缓解平面/圆柱推拉后视觉上被拓扑边分成多段的问题。
-`Face` 模式下,如果相邻Face属于同一几何面,并且投影范围或轴向范围相接/重叠,程序会把它们作为同域区域一起红色预高亮和黄色选中高亮;这主要用于缓解平面/圆柱推拉后视觉上被拓扑边分成多段的问题。
- 如果处于 `Feature` 模式,程序会尽量高亮这个特征相关的局部区域,例如圆柱侧壁、相邻端面和槽边界相邻面。
- 对槽/半孔候选,属性表会显示 `槽宽估算``槽圆弧长度估算``槽深估算``槽边界相邻 Face`
- 对圆角/倒圆候选,属性表会显示 `已有圆角半径估算``已有圆角圆弧角度``已有圆角圆弧长度估算``已有圆角支撑 Face`
@@ -447,54 +486,40 @@ python main.py --smoke-test
- `复制信息` 会复制当前信息面板里的完整文本。
- 用鼠标在模型上选择时,`选中对象信息` 和窗口底部状态栏会显示 `pick_position`,也就是拾取点的三维坐标。
- 如果选中圆柱面,会自动给 `Hole diameter` 填一个略大的建议直径。
- 如果选中直线 edge,会自动给 `圆角半径``倒角距离` 填一个保守的建议值,并把 `目标长度` 填成当前边长
- 如果选中直线Edge,会自动给 `圆角半径``倒角距离` 填一个保守的建议值,并把 `Edge目标长度` 填成当前Edge长度
圆柱候选:
圆柱候选:
- 点击 `列出圆柱候选` 会刷新候选表格。
- 圆柱候选也是手动扫描,避免加载模型和普通选择操作被候选识别拖慢。
- 点击 `扫描圆柱面` 会刷新候选表格。
- 圆柱候选也是手动扫描,避免加载模型和普通选择操作被候选识别拖慢。
- 可以通过候选表格上方的类型下拉框筛选候选;筛选只过滤已经扫描出的缓存结果,不会重新做几何识别。
- 类型下拉框不响应鼠标滚轮,避免鼠标经过时误切换筛选条件
- 当前默认显示前 60 个圆柱候选;如果模型很复杂,扫描仍可能需要等待一会儿,但扫描过程中界面会尽量保持响应。
- 类型下拉框关闭时不会吃掉滚轮,鼠标停在上面滚动会继续滚动左侧面板;点击打开下拉列表后,滚轮才用于滚动选项
- 当前默认显示前 60 个圆柱候选;如果模型很复杂,扫描仍可能需要等待一会儿,但扫描过程中界面会尽量保持响应。
- 表格里的每一行对应一个圆柱面候选。
- 点击候选行会自动切到 `Feature` 模式,并选中对应 face。
- 点击候选行会自动切到 `Feature` 模式,并选中对应Face。
- `guess` 列是程序通过几何采样得到的初步判断,不是 STEP 文件里自带的 CAD 历史特征。
- `round/fillet candidate` 是圆角/倒圆候选;部分规整圆柱圆角可以通过 `修改已有圆角半径` 进行第一版受限修改。
- `round/fillet candidate` 是圆角/倒圆候选;部分规整圆柱圆角可以通过 `修改已有圆角半径` 进行当前版本受限修改。
- `diameter` 是圆柱直径。
- `span` 是圆柱面的角度跨度,接近 `6.283` 表示接近完整圆柱,接近 `3.142` 表示半圆柱。
- `height` 是根据圆柱面积估算出来的高度。
- `confidence` 是当前猜测置信度。
- 这些候选可能是孔、柱、圆角或其他圆柱面,目前还不是稳定的“孔识别”。
导出
编辑
- `导出当前完整 STEP`:导出当前完整模型
- `导出选中零件`:导出当前选中的零件。
- `导出选中 solid`先选一个 solid,再导出该 solid
- `导出选中 face`:先切换到 `Face` 选择模式并选中一个 face,再导出该 face。
- `导出选中特征区域`:先切换到 `Feature` 选择模式并选中局部特征,再导出当前特征高亮的 face 集合。
- `导出选中 edge`:先切换到 `Edge` 选择模式并选中一个 edge,再导出该 edge。
- `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。
- `修复当前模型`:对当前完整模型执行 ShapeFix 和同域面/边合并,完成后会刷新显示并写入操作历史;如果修复失败,会尝试回滚到修复前状态。
- `修复选中零件/solid`:优先修复当前选中对象所属 solid;如果当前对象没有 solid,则修复所属零件。face 或 edge 被选中时,也会按其所属 solid/零件执行局部修复。
- 导出按钮会根据当前选中对象自动启用或禁用,避免选中 face 时误点 `导出选中 edge` 这类不匹配的操作。
- 所有导出按钮都会先做质量检查。发现 B-Rep 无效、没有 solid、零件包含多个 solid、体积接近 0 等风险时,会先弹窗确认是否继续导出。
实验性编辑:
- 编辑按钮会根据当前选择自动启用或禁用。例如未选中平面时不能点击 `推拉平面`,未选中直线 edge 时不能点击 `给边添加圆角` / `给边添加倒角`,未选中明确孔/凸台/盲孔候选时对应圆柱编辑按钮会禁用。
- `面偏移` + `推拉平面`
- 先选择一个平面 face。
- 编辑按钮会根据当前选择自动启用或禁用。例如未选中平面时不能点击 `推拉平面`,未选中直线Edge时不能点击 `给Edge添加圆角` / `给Edge添加倒角`,未选中明确孔/凸台/盲孔候选时对应圆柱编辑按钮会禁用
- `Face偏移` + `推拉平面`
- 先选一个平面Face
- 输入偏移距离。
- 正数表示沿程序判断的外法向加料。
- 负数表示沿内侧方向切削。
- 点击 `推拉平面` 后,会先出现半透明预览体;绿色表示向外加料方向,红色表示向内切削方向。
- 如果距离为 0 会直接阻止;如果方向置信度低或距离相对 face 尺寸偏大,会先弹窗确认。
- 如果选中 face 周围有共面且相接/重叠的碎面,程序会自动把这些 face 合成同一片推拉区域,属性表和历史记录会显示 `推拉共面区域 Face 数`
- 如果距离为 0 会直接阻止;如果方向置信度低或距离相对Face尺寸偏大,会先弹窗确认。
- 如果选中Face周围有共面且相接/重叠的碎面,程序会自动把这些Face合成同一片推拉区域,属性表和历史记录会显示 `推拉共面区域 Face 数`
- 加料时程序会让拉伸体和原实体产生极小重叠,并在布尔后尝试合并同域面/边,使导出的 STEP 更像一个整体实体。
- 程序随后在后台执行真实布尔运算,完成后再刷新为真正修改后的模型。
- 选中平面 face 后,可以在 `属性表` 的方向/轴线分组里查看 `推拉向外方向``推拉向内方向``推拉方向置信度`
- 当前方向判断通过 solid 的 inside / outside 采样得到;如果采样不明确,会退回到拓扑方向法向,并在属性里显示低置信度说明。
- 选中平面Face后,可以在 `属性表` 的方向/轴线分组里查看 `推拉向外方向``推拉向内方向``推拉方向置信度`
- 当前方向判断通过Solid的 inside / outside 采样得到;如果采样不明确,会退回到拓扑方向法向,并在属性里显示低置信度说明。
- 当前实现仍使用布尔加/减验证推拉路线。
- `孔直径` + `调整圆柱孔径`
- 先选择一个圆柱面。
@@ -505,38 +530,45 @@ python main.py --smoke-test
- 程序会先生成切削计划,检查当前直径、目标直径、候选类型、材料投票和风险。
- 切削计划会显示直径变化量、直径变化比例,以及目标直径相对圆柱面估算高度的比例。
- 切削计划会显示端部类型、深度估算、起点端状态和终点端状态。
- 切削计划会显示疑似底面 face、开口端相邻 face 和底面保护说明;这些信息会写入操作历史。
- 切削计划会显示疑似底面Face、开口端相邻Face和底面保护说明;这些信息会写入操作历史。
- 如果目标直径小于等于 0,或几乎等于当前直径,程序会直接阻止。
- 如果目标直径变化过大,或者目标直径相对圆柱估算高度明显异常,程序会提高风险并要求确认。
- 缩小孔径第一版只对 `hole/groove candidate` 开放;圆角、凸柱或未明确圆柱面会被直接阻止。
- 缩小孔径当前版本只对 `hole/groove candidate` 开放;圆角、凸柱或未明确圆柱面会被直接阻止。
- 缩小孔径属于高风险实验功能,因为它依赖“补料 + 重切”的布尔近似,不是 CAD 历史参数修改。
- 如果当前 face 的猜测类型不是 `hole/groove candidate`,或风险不是低风险,程序会先弹窗确认。
- 如果当前Face的猜测类型不是 `hole/groove candidate`,或风险不是低风险,程序会先弹窗确认。
- 切削计划会显示 cutter 策略、高度和余量;当前优先使用有限长度 cutter,减少误切其他区域。
- 缩小孔径时,操作历史会额外记录补料策略、补料半径和补料高度。
- 端部类型是通过轴线端部 inside / outside 采样得到的估算,不等于 CAD 原始建模历史里的“孔深”参数。
- 如果选到的圆柱面不是孔,而是柱或圆角,结果可能不是你想要的,所以它目前仍是实验功能。
- `槽/半孔宽度` + `调整槽/半孔宽度`
- 先切换到 `Feature` 选择模式并选择一个槽/半孔候选 face,或从圆柱候选表中选择局部圆柱形 `hole/groove candidate`
- 先切换到 `Feature` 选择模式并选择一个槽/半孔候选 Face,或从圆柱候选表中选择局部圆柱形 `hole/groove candidate`
- 选中符合条件的槽/半孔后,`槽/半孔宽度` 会自动填入一个比当前槽宽估算略大的建议值。
- 输入目标槽宽后点击 `调整槽/半孔宽度`
- 程序会用当前圆弧角度把目标槽宽换算成目标圆柱直径,再复用圆柱孔/槽调整流程。
- 这一步仍是 B-Rep 几何近似;复杂槽、非圆柱槽或圆弧角度不稳定时可能失败并回滚。
- `薄壁厚度` + `调整薄壁厚度`
- 先选择一个平面 Face,建议从 `可编辑对象` 中点击 `调整薄壁厚度` 行进入。
- 选中符合条件的薄壁/壳体平面后,`薄壁厚度` 会自动填入一个比当前估算厚度略大的建议值。
- 输入目标厚度后点击 `调整薄壁厚度`
- 程序会移动当前平面区域,让它和相对平面的距离接近目标厚度;相对平面保持不动。
- 如果相对平面重叠率低、厚度变化过大或推拉方向不稳定,会提高风险或阻止执行。
- 这一步仍是局部 B-Rep 平面推拉近似,不是完整 CAD 壳体/薄壁特征参数编辑。
- `凸台直径` + `调整圆柱凸台直径`
- 先选择一个完整圆柱凸台 face,建议从 `第一版可编辑对象` 中点击 `调整圆柱凸台直径` 行进入。
- 先选择一个完整圆柱凸台 Face,建议从 `可编辑对象` 中点击 `调整圆柱凸台直径` 行进入。
- 只有接近完整圆柱的 `boss/outer-round candidate` 会放行;局部外圆角、圆角面、孔/槽和未明确圆柱面会被阻止。
- 目标直径大于当前直径时,会在同域圆柱侧壁范围内生成目标直径圆柱并 Fuse。
- 目标直径小于当前直径时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建。
- 点击后会显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。
- 这不是 CAD 历史里的“修改凸台特征参数”,而是基于当前 B-Rep 的受限几何布尔编辑。
- `封堵圆柱孔`
- 先选择一个完整圆柱孔 face,建议从 `第一版可编辑对象` 中点击 `封堵圆柱孔` 行进入。
- 先选择一个完整圆柱孔 Face,建议从 `可编辑对象` 中点击 `封堵圆柱孔` 行进入。
- 点击 `封堵圆柱孔` 后,程序会先生成补料计划。
- 只有接近完整圆柱的 `hole/groove candidate` 会放行;半孔/槽、圆角、凸柱、未明确圆柱面会被阻止。
- 通过检查后,会显示绿色半透明补料体预览。
- 真实编辑会把补料体和当前零件 Fuse,并尝试合并同域面/边。
- 这个功能适合封堵通孔或盲孔,但仍是基于 B-Rep 几何的实验性补料,不是 CAD 历史里的“删除孔特征”。
- 这个功能适合封堵通孔或盲孔,但仍是基于 B-Rep 几何的受限补料,不是 CAD 历史里的“删除孔特征”。
- `孔深度` + `调整盲孔深度`
- 先选择一个盲孔或盲槽的圆柱面,建议从 `第一版可编辑对象` 中点击 `调整盲孔深度` 行进入。
- 先选择一个盲孔或盲槽的圆柱面,建议从 `可编辑对象` 中点击 `调整盲孔深度` 行进入。
- 选中符合条件的盲孔/盲槽后,`孔深度` 会自动填入一个比当前估算深度略大的建议值。
- 输入目标深度后点击 `调整盲孔深度`
- 目标深度大于当前估算深度时,程序会用有限长度 cutter 沿孔方向加深切削,预览显示为红色。
@@ -545,67 +577,73 @@ python main.py --smoke-test
- 当前只支持 `hole/groove candidate`、端部类型为 `blind`、并且已经识别到 `疑似底面 Face` 的对象。
- 通孔、封闭内腔、圆角、凸柱和未明确圆柱面会被阻止,避免误切。
- 这个深度来自圆柱面 V 参数范围和端部 inside / outside 采样,是几何估算,不是 CAD 历史参数。
- 操作历史会记录当前深度、目标深度、深度变化比例、底面 face、开口端 face 和工具参数。
- `圆角半径` + `给添加圆角`
- 先切换到 `Edge` 选择模式并选择一条直线 edge,或从 `第一版可编辑对象` 中点击 `给添加圆角` 行进入。
- 选中直线 edge 后,`圆角半径` 会自动填入一个较小的建议半径。
- 输入目标圆角半径后点击 `给添加圆角`
- 程序会先检查半径是否大于 0、是否相对边长过大,以及该 edge 是否检测到至少两个相邻 face。
- 操作历史会记录当前深度、目标深度、深度变化比例、底面Face、开口端Face和工具参数。
- `圆角半径` + `给Edge添加圆角`
- 先切换到 `Edge` 选择模式并选择一条直线Edge,或从 `可编辑对象` 中点击 `给Edge添加圆角` 行进入。
- 选中直线Edge后,`圆角半径` 会自动填入一个较小的建议半径。
- 输入目标圆角半径后点击 `给Edge添加圆角`
- 程序会先检查半径是否大于 0、是否相对边长过大,以及该Edge是否检测到至少两个相邻Face。
- 点击后会显示蓝色半透明圆管预览,用于提示目标边和半径范围。
- 真实编辑会在后台调用 OCCT 倒圆 API,完成后刷新模型。
- 这是“给直线边添加新圆角”,不是修改已有圆角面。
- 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒圆;失败时程序会提示并尝试恢复编辑前状态。
- `圆角半径` + `修改已有圆角半径`
- 先切换到 `Feature` 选择模式并选择一个已有圆角/倒圆候选 face,或从 `第一版可编辑对象` 中点击 `修改已有圆角半径` 行进入。
- 先切换到 `Feature` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。
- 选中已有圆角候选后,`圆角半径` 会自动填入一个基于当前估算半径的参考目标值。
- 输入目标半径后点击 `修改已有圆角半径`
- 程序会先检查该 face 是否为 `round/fillet candidate`、是否识别到至少两个支撑 face、目标半径是否有效,以及当前零件是否为单 solid。
- 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。
- 通过检查后,会显示蓝色半透明预览,表示将尝试移除并重建的已有圆角面。
- 真实编辑会先用 OCCT defeaturing 移除已有圆角面,再从恢复出的候选锐边中寻找可重新倒圆的边并调用 OCCT 倒圆 API。
- 这是 B-Rep 几何重建,不是 CAD 历史圆角参数编辑;复杂 blend、支撑面不明确或恢复锐边失败时会失败并回滚。
- `倒角距离` + `给添加倒角`
- 先切换到 `Edge` 选择模式并选择一条直线 edge,或从 `第一版可编辑对象` 中点击 `给添加倒角` 行进入。
- 选中直线 edge 后,`倒角距离` 会自动填入一个较小的建议距离。
- 输入目标倒角距离后点击 `给添加倒角`
- 程序会先检查倒角距离是否大于 0、是否相对边长过大,以及该 edge 是否检测到至少两个相邻 face。
- `倒角距离` + `给Edge添加倒角`
- 先切换到 `Edge` 选择模式并选择一条直线Edge,或从 `可编辑对象` 中点击 `给Edge添加倒角` 行进入。
- 选中直线Edge后,`倒角距离` 会自动填入一个较小的建议距离。
- 输入目标倒角距离后点击 `给Edge添加倒角`
- 程序会先检查倒角距离是否大于 0、是否相对边长过大,以及该Edge是否检测到至少两个相邻Face。
- 点击后会显示橙色半透明圆管预览,用于提示目标边和倒角距离范围。
- 真实编辑会在后台调用 OCCT 倒角 API,完成后刷新模型。
- 当前只支持对称距离倒角,不支持两侧不同距离或距离+角度倒角。
- 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒角;失败时程序会提示并尝试恢复编辑前状态。
- `目标长度` + `直接修改边长`
- 先切换到 `Edge` 选择模式并选择一条 edge,或从 `第一版可编辑对象` 中点击边长相关行进入。
- 选中 edge 后,`目标长度` 会自动填入当前边长
- 右侧边长基准下拉框可以选择 `自动``中心``固定起点``固定终点`
- 把它改成目标长度后点击 `直接修改边长`
- 直线 edge 会优先查找端点附近是否有可推拉的平面端面,并计算需要移动的端面 face 和推拉距离
- 圆形/圆弧 edge 会优先尝试复用相邻圆柱面的孔/槽或凸台直径编辑,并在确认窗口显示换算出来的目标直径
- `固定起点` 会尽量让起点不动、移动终点侧;`固定终点` 相反;`中心` 会让缩放 fallback 以 edge 中心为基准
- 如果没有明确端面或可复用圆柱面,程序会按所选基准尝试整体/轴向几何缩放 fallback;这会影响所属 part/solid 的其它尺寸,因此会弹出高风险确认
- `Edge目标长度` + `修改Edge长度`
- 先切换到 `Edge` 选择模式并选择一条Edge,或从 `可编辑对象` 中点击Edge长度相关行进入。
- 选中Edge后,`Edge目标长度` 会自动填入当前Edge长度
- 右侧Edge长度基准下拉框可以选择 `自动``中心``固定起点``固定终点`
- 把它改成目标长度后点击 `修改Edge长度`
- `assets/models/cube_10mm.step` 这类全平面多面体,直线Edge会优先只移动被选Edge的端点并重建周边平面
- 这时正方体不会默认变成长方体;相邻面会跟着被移动的边自然变成梯形,必要时非共面的面会拆成三角面
- 如果确认窗口里的策略是 `local-edge-only-deform`,说明走的就是“只变这条边”的局部形变路径
- 如果模型不是简单全平面多面体,直线Edge会继续尝试端面推拉后备路径,并计算需要移动的端面Face和推拉距离
- 圆形/圆弧Edge会优先尝试复用相邻圆柱面的孔/槽或凸台直径编辑,并在确认窗口显示换算出来的目标直径。
- 如果圆边没有可复用圆柱面,但能读取圆心和轴线,会走 `围绕圆边轴线径向缩放` 后备策略,尽量不改变轴向厚度/高度;执行后部分解析几何可能变成 B-spline。
- 椭圆边、平面 B-spline / Bezier 边会尝试走 `围绕平面曲线法向径向缩放`;确认窗口会显示平面采样偏差,偏差不稳定时会回退到更保守的缩放 fallback。
- `固定起点` 会让起点不动、移动终点;`固定终点` 相反;`中心` 会让两端各移动一半,只有进入缩放 fallback 时才会变成以Edge中心为基准缩放。
- 如果没有明确端面或可复用圆柱面,程序会按所选基准尝试整体/轴向几何缩放 fallback;这会影响所属Part/Solid的其它尺寸,因此会弹出高风险确认。
- 执行成功后会做后置边长校验:在刷新后的Part/Solid范围内寻找最接近目标长度的Edge,并把实际最近边长、误差和相对误差写入操作结果。
- 通过检查后,会显示蓝绿色半透明预览,表示将要移动或缩放的范围。
- 真实编辑在后台执行,失败时会回滚到编辑前状态。
- 这是第一版 B-Rep 结果几何修改,不是任意边参数化改长。
- `平移 X/Y/Z` + `平移选中零件` / `平移选中 solid`
- 这是当前版本的 B-Rep 结果几何修改,不是任意边参数化改长。
- `平移 X/Y/Z` + `平移选中零件` / `平移选中Solid`
- 输入 X、Y、Z 三个方向的平移量。
- 选择 `Part` 后点击 `平移选中零件`,会移动整个 part。
- 选择 `Solid`,或选择属于某个 solid 的 face/edge 后,点击 `平移选中 solid`,会移动当前 solid。
- 如果当前 part 只有一个 solid,平移 solid 实际会移动整个 part shape,程序会提示中风险。
- 选择 `Part` 后点击 `平移选中零件`,会移动整个Part。
- 选择 `Solid`,或选择属于某个Solid的Face/Edge后,点击 `平移选中Solid`,会移动当前Solid。
- 如果当前Part只有一个Solid,平移Solid实际会移动整个Part shape,程序会提示中风险。
- 平移向量为 0 会直接阻止。
- 如果平移距离相对对象包围盒过大,程序会提高风险并要求确认。
- 平移属于真实 B-Rep 变换,会写入操作历史,可以撤销/重做。
- `旋转轴/角度` + `旋转选中零件` / `旋转选中 solid`
- `旋转轴/角度` + `旋转选中零件` / `旋转选中Solid`
- 选择 X、Y 或 Z 旋转轴。
- 输入旋转角度,单位是度。
- 选择 `Part` 后点击 `旋转选中零件`,会绕该 part 的包围盒中心旋转整个 part。
- 选择 `Solid`,或选择属于某个 solid 的 face/edge 后,点击 `旋转选中 solid`,会绕该 solid 的包围盒中心旋转当前 solid。
- 如果当前 part 只有一个 solid,旋转 solid 实际会旋转整个 part shape,程序会提示中风险。
- 选择 `Part` 后点击 `旋转选中零件`,会绕该Part的包围盒中心旋转整个Part。
- 选择 `Solid`,或选择属于某个Solid的Face/Edge后,点击 `旋转选中Solid`,会绕该Solid的包围盒中心旋转当前Solid。
- 如果当前Part只有一个Solid,旋转Solid实际会旋转整个Part shape,程序会提示中风险。
- 旋转角度为 0 会直接阻止;角度超过 360 度会提高风险并要求确认。
- 旋转属于真实 B-Rep 变换,会写入操作历史,可以撤销/重做。
- `撤销`
- 撤销上一次成功的实验性编辑。
- 撤销上一次成功的编辑。
- `重做`
- 重做刚刚撤销的实验性编辑。
- 重做刚刚撤销的编辑。
- `操作历史`
- 显示当前模型已经成功执行的实验性编辑。
- 显示当前模型已经成功执行的编辑。
- 点击某条历史记录,会在 `选中对象信息` 中显示该次编辑的目标、参数、执行结果和拓扑数量变化。
- 历史详情也会显示几何差异摘要,包括体积、表面积、包围盒尺寸和包围盒对角线的前后变化。
- 点击某条历史记录时,3D 窗口会显示红/绿半透明差异叠加预览:红色表示编辑前,绿色表示编辑后。
@@ -613,13 +651,27 @@ python main.py --smoke-test
- 历史详情会显示热力图统计,包括最大距离、平均距离和发生明显变化的采样点比例。
- `清除差异预览` 可以关闭红/绿叠加显示。
- `导出差异报告` 会把当前选中的历史记录导出为 `.txt`,里面包含操作详情、拓扑变化、几何变化和热力图统计。
- `导出编辑历史` 会把当前会话中的本软件编辑记录导出为 `.json`,用于留档和后续复盘。
- `导出编辑历史` 会把当前会话中的本软件编辑记录导出为 `.json`,用于留档和后续复盘;当前格式是 `step-editor-operation-history-v2`,包含机器可读的动作名、目标和参数
- 当前热力图是“编辑后模型顶点到编辑前模型表面”的距离估算,不是完整 CAD 公差报告。
- 点击某条历史记录时,程序也会尝试重新高亮当时编辑的目标 face,并用青色小点标出当时的拾取位置。
- 由于布尔编辑后拓扑 ID 可能重新分配,历史里的 face ID 只能作为定位参考,不能视为稳定的 CAD 建模历史 ID。
- 点击某条历史记录时,程序也会尝试重新高亮当时编辑的目标Face,并用青色小点标出当时的拾取位置。
- 由于布尔编辑后拓扑 ID 可能重新分配,历史里的Face ID只能作为定位参考,不能视为稳定的 CAD 建模历史 ID。
- 撤销后对应记录会从列表里退回。
- 重做后对应记录会重新显示。
导出:
- `导出当前完整 STEP`:导出当前完整模型。
- `导出选中零件`:导出当前选中的零件。
- `导出选中Solid`:先选中一个Solid,再导出该Solid。
- `导出选中面区域`:先切换到 `Face` 选择模式并选中一个Face,再导出该Face或同域面区域。
- `导出选中特征区域`:先切换到 `Feature` 选择模式并选中局部特征,再导出当前特征高亮的Face集合。
- `导出选中Edge`:先切换到 `Edge` 选择模式并选中一个Edge,再导出该Edge。
- `检查导出质量`:检查当前选中对象;如果没有选中对象,就检查当前完整模型。
- `修复当前模型`:对当前完整模型执行 ShapeFix 和同域面/边合并,完成后会刷新显示并写入操作历史;如果修复失败,会尝试回滚到修复前状态。
- `修复选中零件/Solid`:优先修复当前选中对象所属Solid;如果当前对象没有Solid,则修复所属零件。Face或Edge被选中时,也会按其所属Solid/零件执行局部修复。
- 导出按钮会根据当前选中对象自动启用或禁用,避免选中Face时误点 `导出选中Edge` 这类不匹配的操作。
- 所有导出按钮都会先做质量检查。发现 B-Rep 无效、没有Solid、零件包含多个Solid、体积接近 0 等风险时,会先弹窗确认是否继续导出。
## 目录结构
当前整理后的项目结构是:
@@ -627,8 +679,14 @@ python main.py --smoke-test
```text
pythonocc-step-editor/
main.py # 程序入口,保持 python main.py 启动
docs/
screenshots/ # 调试截图和问题截图
assets/
models/
geom_extract.step # 当前默认测试模型
cube_10mm.step # 简单立方体测试模型,用于验证边长修改等基础操作
screenshots/ # 调试截图和问题截图,默认不提交
scripts/
generate_cube_step.py # 生成 assets/models/cube_10mm.step 的小工具
verify_edge_length_resize.py # 验证立方体 Edge 长度局部形变基线
step_editor/
__init__.py # 包导出
app.py # 主窗口初始化、UI 搭建和程序启动
@@ -640,10 +698,10 @@ pythonocc-step-editor/
model.py # StepModel 核心状态、拓扑索引、基础属性和 mixin 组合
model_types.py # PartNode、TopologyStats 等模型数据结构
operations.py # 推拉、孔径、孔深、圆角、倒角、边长等实际编辑操作
polydata.py # OCC shape / edge / face 到 VTK polydata 的显示数据生成
polydata.py # OCC Shape / Edge / Face 到 VTK polydata 的显示数据生成
records.py # 操作历史记录数据结构
step_io.py # STEP/XCAF 读取、产品名解析和 STEP 写出
transforms.py # part / solid 平移和旋转
transforms.py # Part / Solid 平移和旋转
ui_helpers.py # UI 常量、格式化函数和 VTK 显示小工具
widgets.py # 自定义 Qt 小组件
window_actions.py # 导出、编辑按钮、扫描、后台编辑调度等窗口动作
@@ -651,11 +709,10 @@ pythonocc-step-editor/
window_state.py # 选择状态、按钮状态、撤销/重做和历史定位
workers.py # 后台编辑和后台扫描 worker
environment.yml # Conda 环境说明,用于安装 pythonocc-core、VTK、PySide6 等依赖
geom_extract.step # 当前默认测试模型
README.md # 项目说明和使用说明
```
这次整理把根目录收窄到入口、环境配置、默认模型和文档,主要代码放到 `step_editor/` 包里;问题截图和调试截图放到 `docs/screenshots/`。后端和窗口层都按 mixin / 工具模块拆开,尽量不改变功能行为。
这次整理把根目录收窄到入口、环境配置和说明文档;仓库自带 STEP 测试模型放到 `assets/models/`问题截图和调试截图放到 `assets/screenshots/``environment.yml` 保留在根目录,方便 Conda 用户直接发现并运行 `conda env create -f environment.yml`后端和窗口层都按 mixin / 工具模块拆开,尽量不改变功能行为。
`step_editor/model.py` 现在主要保留 StepModel 的核心状态、拓扑缓存、基础属性查询和 mixin 组合;具体能力分散到 `features.py``operations.py``export.py``transforms.py``polydata.py``step_io.py``geometry_utils.py`
@@ -665,10 +722,117 @@ pythonocc-step-editor/
- `.gitignore`:Git 忽略规则,避免提交 Python 缓存、临时文件和导出的 STEP 文件。
- `*_edited.step``*_export.step``*_diff_*.txt``*_operation_history.json`:运行或导出时产生的结果文件,默认不提交。
- `docs/screenshots/`:调试截图和问题截图,例如第三方软件显示效果、模型结构树截图等
- `assets/models/`:仓库自带的 STEP 测试模型,可以提交;运行时导出的 STEP 结果仍然默认忽略
- `assets/screenshots/`:调试截图和问题截图,例如第三方软件显示效果、模型结构树截图等,默认不提交。
## 当前版本边界
这一版重点是验证整体链路,不是最终 CAD 编辑器。
模型显示、选择、属性查看、撤销/重做和导出是当前主要能力。局部编辑已经接入,但仍属于实验性能力。复杂 STEP 几何上的布尔操作可能失败;失败时程序会弹窗提示,不会静默覆盖原始文件。
模型显示、选择、属性查看、撤销/重做和导出是当前主要能力。局部编辑已经接入,但仍属于受限 B-Rep 能力。复杂 STEP 几何上的布尔操作可能失败;失败时程序会弹窗提示,不会静默覆盖原始文件。
## Codex 项目记忆(开发者可忽略)
这一节只给后续重新打开的 Codex 聊天框使用,普通开发者不需要阅读。Codex 接手时先读这里,再读本 README 的“目录结构”和“当前版本边界”。
### 项目目标
本项目是一个基于 Python、pythonocc-core/OCCT、VTK 和 PySide6/Qt 的 STEP 查看与局部编辑原型。它处理的是 STEP 里的 B-Rep 结果几何,不是原 CAD 软件的完整参数化建模历史。
最小系统目标是打通:加载 STEP、显示模型、选择 Part/Solid/Face/Edge/Feature、查看属性、识别初步候选特征、执行受限局部编辑、撤销/重做、质量检查和导出。
### 运行方式
常用命令:
```powershell
conda activate pyocc
python main.py
python main.py assets\models\cube_10mm.step
python scripts\generate_cube_step.py
python scripts\verify_edge_length_resize.py
python main.py --smoke-test
```
如果 shell 里没有 `conda`,用户机器上之前可用的方式是:
```powershell
C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python main.py
```
当前 Codex 沙盒环境可能没有 VTK/pythonocc,因此 GUI 和 smoke test 可能报 `ModuleNotFoundError: vtk`。能做的本地验证至少包括:
```powershell
python -m compileall -q main.py step_editor scripts
git diff --check
```
### 项目结构提示
- `main.py`:入口,保持 `python main.py` 可启动。
- `assets/models/`:仓库自带 STEP 测试模型,可以提交。默认模型是 `geom_extract.step`,简单边长测试模型是 `cube_10mm.step`
- `assets/screenshots/`:调试截图和问题截图,默认忽略,不提交。
- `scripts/generate_cube_step.py`:生成 `assets/models/cube_10mm.step`
- `scripts/verify_edge_length_resize.py`:加载 `cube_10mm.step`,把一条 10mm 直线 Edge 改到 15mm,并断言策略是 `local-edge-only-deform`、目标边长误差在容差内。
- `environment.yml`:保留在根目录,方便 Conda 用户直接创建环境。
- `step_editor/app.py`:主窗口初始化、左侧操作面板和入口。
- `step_editor/window_core.py`:文件加载、结构树、VTK、拾取、高亮、预览和首次/精细加载。
- `step_editor/window_actions.py`:导出按钮、编辑动作、后台任务、扫描、操作历史记录生成。
- `step_editor/window_state.py`:选择状态、按钮启用/禁用、撤销/重做和历史定位。
- `step_editor/model.py``StepModel` 核心状态和 mixin 组合。
- `step_editor/features.py`:可编辑对象和圆柱面/孔/槽/凸台/圆角等识别计划。
- `step_editor/operations.py`:推拉、孔径、孔深、圆角、倒角、薄壁、边长等真实几何编辑。
- `step_editor/export.py`STEP 导出和质量检查。
- `step_editor/polydata.py`OCCT shape/face/edge 到 VTK polydata。
- `step_editor/widgets.py`:Qt 自定义小控件,例如不误吃滚轮的下拉框。
### 左侧面板工作流
左侧面板前半段按用户指定顺序排列:STEP 文件、模型结构树、鼠标选择模式、按 ID 选择、编辑、导出、显示、测量。后半段继续放可编辑对象、圆柱面候选、操作历史和对象信息。
下拉框关闭时不能吃掉滚轮,鼠标停在下拉框上滚动应继续滚动左侧面板;只有打开下拉列表后,滚轮才用于滚动选项。
控件悬停提示必须面向普通用户说明“这个控件是干什么的、什么时候可用、失败后会怎样”,不要只写内部名词。
### 当前关键能力
- STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。
- 选择:支持 Part、Solid、Face、Edge、Feature。鼠标悬停红色预高亮,选中黄色高亮。
- 同域面:Face/Feature 会尽量把共面或同轴同半径的连续碎面合成逻辑区域。
- 编辑:平面推拉、孔径、槽/半孔宽度、盲孔/盲槽深度、凸台直径、已有圆角半径当前版本修改、Edge圆角、Edge倒角、薄壁厚度、修改Edge长度、Part/Solid 平移和旋转。
- 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。
- 导出:完整模型、Part、Solid、Face、Feature 区域和 Edge,导出前做基础质量检查。
### 修改Edge长度
这是当前继续深入的功能,但仍是 B-Rep 结果几何编辑,不是参数化历史恢复。
当前策略顺序:
1. `local-edge-only-deform`:直线Edge位于简单全平面多面体时,优先只移动被选Edge的端点并重建所属Solid/Part。对立方体测试模型,默认固定起点移动终点;相邻面会自然变成梯形,必要时非共面四边面拆三角面,而不是把整个正方体缩放成长方体。
2. `move-edge-end-plane-by-push-pull`:如果不适合局部形变,尝试找端点附近、方向匹配的平面端面,把改边长转成端面推拉。
3. `resize-adjacent-cylinder-from-circular-edge-length`:圆形/圆弧Edge尝试复用相邻圆柱孔/槽或凸台直径编辑。
4. `scale-owning-shape-from-edge`:圆边、椭圆边和可采样为平面的非直线Edge会优先用 `radial-affine` 围绕曲线平面法向做径向缩放;其他无明确局部路径的Edge才按Edge轴向或整体做几何缩放 fallback,风险高,会影响其它尺寸。
确认窗口和操作历史里要尽量记录策略、目标范围、目标Edge长度、基准、移动端点、重建面数和风险,方便用户判断结果是否符合预期。
最近一次基线验证:在 `assets/models/cube_10mm.step` 上选第一条直线Edge,将 10mm 改为 15mm,计划策略为 `local-edge-only-deform`,内存执行成功。执行后边长分布为 `10:9, 11.1803:2, 14.1421:1, 15:1`,Face数从 6 变 7,说明没有整体拉成长方体,而是局部重建并拆分了一个非共面面。
新增基线验证:内存构造椭圆Edge,将长度放大 1.25 倍,计划策略为 `radial-affine`,通过预变换Edge长度把缩放比例从 `1.25` 微调到约 `1.25354`,结果边长贴合目标。另一个圆锥圆边先径向缩放后会变成 B-spline,再选这个平面 B-spline Edge 放大 1.1 倍,计划会走 `sampled edge best-fit plane`,采样偏差约 `3.6e-15`,结果边长贴合目标。
后置校验基线:修改Edge长度完成后,结果消息会包含 `match``nearest_edge``nearest_length``target_error``relative_error``endpoint_error` 和校验范围;局部边形变、端面推拉和几何缩放会优先按预期端点匹配目标Edge,长度匹配只作为兜底。操作历史摘要也会带上 `actual_length``target_error`,用于提醒用户真实 B-Rep 刷新后的最近Edge长度是否贴近目标值。
### 当前边界
- STEP 通常不包含可稳定恢复的 CAD 建模历史;不要承诺“任意场景都能像原 CAD 参数一样修改”。
- 复杂槽、复杂凸台、复杂圆角链、带内孔Face、曲面混合拓扑和大装配仍可能失败。
- 布尔和 ShapeFix 可能耗时或失败,编辑动作应继续走后台 worker、预览和失败回滚。
- 操作历史是本软件加载后的编辑留档,不是 STEP 原始建模历史。
### 提交与忽略
- `assets/models/*.step` 中用于测试的模型可以提交。
- `assets/screenshots/` 默认忽略。
- 运行导出的 `*_edited.step``*_export.step``*_diff_*.txt``*_operation_history.json` 默认不提交。
- 不要把 `environment.yml` 移进子目录,它留在根目录更符合 Conda 用户预期。
- 工作区可能有用户改动,不能随意 `git checkout --``git reset --hard` 或回滚不相关文件。
+140
View File
@@ -0,0 +1,140 @@
ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('Simple 10 mm cube generated by Python for edge edit tests'),'2;1');
FILE_NAME('cube_10mm.step','2026-07-28T10:25:56',('Codex'),('OpenAI'),'Python ASCII STEP generator','python-occt','');
FILE_SCHEMA(('AUTOMOTIVE_DESIGN_CC2'));
ENDSEC;
DATA;
#1=APPLICATION_CONTEXT('automotive_design');
#2=APPLICATION_PROTOCOL_DEFINITION('international standard','automotive_design',2000,#1);
#3=PRODUCT_CONTEXT('',#1,'mechanical');
#4=PRODUCT('CUBE_10MM','CUBE_10MM','Simple 10 mm cube generated for edge editing tests',(#3));
#5=PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE('1','generated',#4,.NOT_KNOWN.);
#6=PRODUCT_DEFINITION_CONTEXT('part definition',#1,'design');
#7=PRODUCT_DEFINITION('design','',#5,#6);
#8=PRODUCT_DEFINITION_SHAPE('','',#7);
#9=(LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.));
#10=(NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.));
#11=(NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT());
#12=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#9,'distance_accuracy_value','');
#13=(GEOMETRIC_REPRESENTATION_CONTEXT(3) GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#12)) GLOBAL_UNIT_ASSIGNED_CONTEXT((#9,#10,#11)) REPRESENTATION_CONTEXT('3D Context',''));
#14=CARTESIAN_POINT('',(0.000000,0.000000,0.000000));
#15=CARTESIAN_POINT('',(10.000000,0.000000,0.000000));
#16=CARTESIAN_POINT('',(10.000000,10.000000,0.000000));
#17=CARTESIAN_POINT('',(0.000000,10.000000,0.000000));
#18=CARTESIAN_POINT('',(0.000000,0.000000,10.000000));
#19=CARTESIAN_POINT('',(10.000000,0.000000,10.000000));
#20=CARTESIAN_POINT('',(10.000000,10.000000,10.000000));
#21=CARTESIAN_POINT('',(0.000000,10.000000,10.000000));
#22=DIRECTION('',(1.000000,0.000000,0.000000));
#23=DIRECTION('',(-1.000000,0.000000,0.000000));
#24=DIRECTION('',(0.000000,1.000000,0.000000));
#25=DIRECTION('',(0.000000,-1.000000,0.000000));
#26=DIRECTION('',(0.000000,0.000000,1.000000));
#27=DIRECTION('',(0.000000,0.000000,-1.000000));
#28=AXIS2_PLACEMENT_3D('',#14,#26,#22);
#29=VERTEX_POINT('',#14);
#30=VERTEX_POINT('',#15);
#31=VERTEX_POINT('',#16);
#32=VERTEX_POINT('',#17);
#33=VERTEX_POINT('',#18);
#34=VERTEX_POINT('',#19);
#35=VERTEX_POINT('',#20);
#36=VERTEX_POINT('',#21);
#37=VECTOR('',#22,1.000000);
#38=LINE('',#14,#37);
#39=EDGE_CURVE('E1',#29,#30,#38,.T.);
#40=VECTOR('',#24,1.000000);
#41=LINE('',#15,#40);
#42=EDGE_CURVE('E2',#30,#31,#41,.T.);
#43=VECTOR('',#23,1.000000);
#44=LINE('',#16,#43);
#45=EDGE_CURVE('E3',#31,#32,#44,.T.);
#46=VECTOR('',#25,1.000000);
#47=LINE('',#17,#46);
#48=EDGE_CURVE('E4',#32,#29,#47,.T.);
#49=VECTOR('',#22,1.000000);
#50=LINE('',#18,#49);
#51=EDGE_CURVE('E5',#33,#34,#50,.T.);
#52=VECTOR('',#24,1.000000);
#53=LINE('',#19,#52);
#54=EDGE_CURVE('E6',#34,#35,#53,.T.);
#55=VECTOR('',#23,1.000000);
#56=LINE('',#20,#55);
#57=EDGE_CURVE('E7',#35,#36,#56,.T.);
#58=VECTOR('',#25,1.000000);
#59=LINE('',#21,#58);
#60=EDGE_CURVE('E8',#36,#33,#59,.T.);
#61=VECTOR('',#26,1.000000);
#62=LINE('',#14,#61);
#63=EDGE_CURVE('E9',#29,#33,#62,.T.);
#64=VECTOR('',#26,1.000000);
#65=LINE('',#15,#64);
#66=EDGE_CURVE('E10',#30,#34,#65,.T.);
#67=VECTOR('',#26,1.000000);
#68=LINE('',#16,#67);
#69=EDGE_CURVE('E11',#31,#35,#68,.T.);
#70=VECTOR('',#26,1.000000);
#71=LINE('',#17,#70);
#72=EDGE_CURVE('E12',#32,#36,#71,.T.);
#73=ORIENTED_EDGE('',*,*,#48,.F.);
#74=ORIENTED_EDGE('',*,*,#45,.F.);
#75=ORIENTED_EDGE('',*,*,#42,.F.);
#76=ORIENTED_EDGE('',*,*,#39,.F.);
#77=EDGE_LOOP('',(#73,#74,#75,#76));
#78=FACE_OUTER_BOUND('',#77,.T.);
#79=AXIS2_PLACEMENT_3D('',#14,#27,#22);
#80=PLANE('',#79);
#81=ADVANCED_FACE('BOTTOM_Z0',(#78),#80,.T.);
#82=ORIENTED_EDGE('',*,*,#51,.T.);
#83=ORIENTED_EDGE('',*,*,#54,.T.);
#84=ORIENTED_EDGE('',*,*,#57,.T.);
#85=ORIENTED_EDGE('',*,*,#60,.T.);
#86=EDGE_LOOP('',(#82,#83,#84,#85));
#87=FACE_OUTER_BOUND('',#86,.T.);
#88=AXIS2_PLACEMENT_3D('',#18,#26,#22);
#89=PLANE('',#88);
#90=ADVANCED_FACE('TOP_Z10',(#87),#89,.T.);
#91=ORIENTED_EDGE('',*,*,#39,.T.);
#92=ORIENTED_EDGE('',*,*,#66,.T.);
#93=ORIENTED_EDGE('',*,*,#51,.F.);
#94=ORIENTED_EDGE('',*,*,#63,.F.);
#95=EDGE_LOOP('',(#91,#92,#93,#94));
#96=FACE_OUTER_BOUND('',#95,.T.);
#97=AXIS2_PLACEMENT_3D('',#14,#25,#22);
#98=PLANE('',#97);
#99=ADVANCED_FACE('FRONT_Y0',(#96),#98,.T.);
#100=ORIENTED_EDGE('',*,*,#72,.T.);
#101=ORIENTED_EDGE('',*,*,#57,.F.);
#102=ORIENTED_EDGE('',*,*,#69,.F.);
#103=ORIENTED_EDGE('',*,*,#45,.T.);
#104=EDGE_LOOP('',(#100,#101,#102,#103));
#105=FACE_OUTER_BOUND('',#104,.T.);
#106=AXIS2_PLACEMENT_3D('',#17,#24,#22);
#107=PLANE('',#106);
#108=ADVANCED_FACE('BACK_Y10',(#105),#107,.T.);
#109=ORIENTED_EDGE('',*,*,#63,.T.);
#110=ORIENTED_EDGE('',*,*,#60,.F.);
#111=ORIENTED_EDGE('',*,*,#72,.F.);
#112=ORIENTED_EDGE('',*,*,#48,.T.);
#113=EDGE_LOOP('',(#109,#110,#111,#112));
#114=FACE_OUTER_BOUND('',#113,.T.);
#115=AXIS2_PLACEMENT_3D('',#14,#23,#24);
#116=PLANE('',#115);
#117=ADVANCED_FACE('LEFT_X0',(#114),#116,.T.);
#118=ORIENTED_EDGE('',*,*,#42,.T.);
#119=ORIENTED_EDGE('',*,*,#69,.T.);
#120=ORIENTED_EDGE('',*,*,#54,.F.);
#121=ORIENTED_EDGE('',*,*,#66,.F.);
#122=EDGE_LOOP('',(#118,#119,#120,#121));
#123=FACE_OUTER_BOUND('',#122,.T.);
#124=AXIS2_PLACEMENT_3D('',#15,#22,#24);
#125=PLANE('',#124);
#126=ADVANCED_FACE('RIGHT_X10',(#123),#125,.T.);
#127=CLOSED_SHELL('',(#81,#90,#99,#108,#117,#126));
#128=MANIFOLD_SOLID_BREP('CUBE_10MM',#127);
#129=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#28,#128),#13);
#130=SHAPE_DEFINITION_REPRESENTATION(#8,#129);
#131=PRODUCT_RELATED_PRODUCT_CATEGORY('part','',(#4));
ENDSEC;
END-ISO-10303-21;
+171
View File
@@ -0,0 +1,171 @@
from __future__ import annotations
import argparse
from datetime import datetime
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_OUTPUT = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
def write_cube_step(path: Path, size: float = 10.0) -> None:
entities: list[str] = []
def add(text: str) -> int:
index = len(entities) + 1
entities.append(f"#{index}={text};")
return index
def ref(index: int) -> str:
return f"#{index}"
def refs(indices) -> str:
return ",".join(ref(index) for index in indices)
app_ctx = add("APPLICATION_CONTEXT('automotive_design')")
add(f"APPLICATION_PROTOCOL_DEFINITION('international standard','automotive_design',2000,{ref(app_ctx)})")
prod_ctx = add(f"PRODUCT_CONTEXT('',{ref(app_ctx)},'mechanical')")
product = add(
"PRODUCT('CUBE_10MM','CUBE_10MM','Simple 10 mm cube generated for edge editing tests',"
f"({ref(prod_ctx)}))"
)
formation = add(
f"PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE('1','generated',{ref(product)},.NOT_KNOWN.)"
)
pd_ctx = add(f"PRODUCT_DEFINITION_CONTEXT('part definition',{ref(app_ctx)},'design')")
product_def = add(f"PRODUCT_DEFINITION('design','',{ref(formation)},{ref(pd_ctx)})")
product_shape = add(f"PRODUCT_DEFINITION_SHAPE('','',{ref(product_def)})")
length_unit = add("(LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.))")
angle_unit = add("(NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.))")
solid_angle_unit = add("(NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT())")
uncertainty = add(
f"UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),{ref(length_unit)},"
"'distance_accuracy_value','')"
)
geom_context = add(
"(GEOMETRIC_REPRESENTATION_CONTEXT(3) "
f"GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT(({ref(uncertainty)})) "
f"GLOBAL_UNIT_ASSIGNED_CONTEXT(({refs([length_unit, angle_unit, solid_angle_unit])})) "
"REPRESENTATION_CONTEXT('3D Context',''))"
)
point_ids: dict[str, int] = {}
for name, xyz in {
"P1": (0.0, 0.0, 0.0),
"P2": (size, 0.0, 0.0),
"P3": (size, size, 0.0),
"P4": (0.0, size, 0.0),
"P5": (0.0, 0.0, size),
"P6": (size, 0.0, size),
"P7": (size, size, size),
"P8": (0.0, size, size),
}.items():
point_ids[name] = add("CARTESIAN_POINT('',({:.6f},{:.6f},{:.6f}))".format(*xyz))
directions = {
"+X": add("DIRECTION('',(1.000000,0.000000,0.000000))"),
"-X": add("DIRECTION('',(-1.000000,0.000000,0.000000))"),
"+Y": add("DIRECTION('',(0.000000,1.000000,0.000000))"),
"-Y": add("DIRECTION('',(0.000000,-1.000000,0.000000))"),
"+Z": add("DIRECTION('',(0.000000,0.000000,1.000000))"),
"-Z": add("DIRECTION('',(0.000000,0.000000,-1.000000))"),
}
world_axis = add(
f"AXIS2_PLACEMENT_3D('',{ref(point_ids['P1'])},{ref(directions['+Z'])},{ref(directions['+X'])})"
)
vertex_ids = {
name.replace("P", "V"): add(f"VERTEX_POINT('',{ref(point_id)})")
for name, point_id in point_ids.items()
}
edge_specs = {
"E1": ("V1", "V2", "P1", "+X"),
"E2": ("V2", "V3", "P2", "+Y"),
"E3": ("V3", "V4", "P3", "-X"),
"E4": ("V4", "V1", "P4", "-Y"),
"E5": ("V5", "V6", "P5", "+X"),
"E6": ("V6", "V7", "P6", "+Y"),
"E7": ("V7", "V8", "P7", "-X"),
"E8": ("V8", "V5", "P8", "-Y"),
"E9": ("V1", "V5", "P1", "+Z"),
"E10": ("V2", "V6", "P2", "+Z"),
"E11": ("V3", "V7", "P3", "+Z"),
"E12": ("V4", "V8", "P4", "+Z"),
}
edge_ids: dict[str, int] = {}
for name, (start, end, start_point, direction) in edge_specs.items():
vector = add(f"VECTOR('',{ref(directions[direction])},1.000000)")
line = add(f"LINE('',{ref(point_ids[start_point])},{ref(vector)})")
edge_ids[name] = add(
f"EDGE_CURVE('{name}',{ref(vertex_ids[start])},{ref(vertex_ids[end])},{ref(line)},.T.)"
)
def oriented(edge_name: str, same: bool) -> int:
orientation = ".T." if same else ".F."
return add(f"ORIENTED_EDGE('',*,*,{ref(edge_ids[edge_name])},{orientation})")
def make_plane(origin_point: str, normal: str, ref_dir: str) -> int:
axis = add(
f"AXIS2_PLACEMENT_3D('',{ref(point_ids[origin_point])},"
f"{ref(directions[normal])},{ref(directions[ref_dir])})"
)
return add(f"PLANE('',{ref(axis)})")
faces: list[int] = []
face_specs = [
("BOTTOM_Z0", [("E4", False), ("E3", False), ("E2", False), ("E1", False)], "P1", "-Z", "+X"),
("TOP_Z10", [("E5", True), ("E6", True), ("E7", True), ("E8", True)], "P5", "+Z", "+X"),
("FRONT_Y0", [("E1", True), ("E10", True), ("E5", False), ("E9", False)], "P1", "-Y", "+X"),
("BACK_Y10", [("E12", True), ("E7", False), ("E11", False), ("E3", True)], "P4", "+Y", "+X"),
("LEFT_X0", [("E9", True), ("E8", False), ("E12", False), ("E4", True)], "P1", "-X", "+Y"),
("RIGHT_X10", [("E2", True), ("E11", True), ("E6", False), ("E10", False)], "P2", "+X", "+Y"),
]
for name, loop_edges, plane_origin, normal, ref_dir in face_specs:
loop = add(f"EDGE_LOOP('',({refs(oriented(edge, same) for edge, same in loop_edges)}))")
bound = add(f"FACE_OUTER_BOUND('',{ref(loop)},.T.)")
plane = make_plane(plane_origin, normal, ref_dir)
faces.append(add(f"ADVANCED_FACE('{name}',({ref(bound)}),{ref(plane)},.T.)"))
closed_shell = add(f"CLOSED_SHELL('',({refs(faces)}))")
solid = add(f"MANIFOLD_SOLID_BREP('CUBE_10MM',{ref(closed_shell)})")
shape_rep = add(
f"ADVANCED_BREP_SHAPE_REPRESENTATION('',({refs([world_axis, solid])}),{ref(geom_context)})"
)
add(f"SHAPE_DEFINITION_REPRESENTATION({ref(product_shape)},{ref(shape_rep)})")
add(f"PRODUCT_RELATED_PRODUCT_CATEGORY('part','',({ref(product)}))")
header = "\n".join(
[
"ISO-10303-21;",
"HEADER;",
"FILE_DESCRIPTION(('Simple 10 mm cube generated by Python for edge edit tests'),'2;1');",
(
f"FILE_NAME('{path.name}','{datetime.now().isoformat(timespec='seconds')}',"
"('Codex'),('OpenAI'),'Python ASCII STEP generator','python-occt','');"
),
"FILE_SCHEMA(('AUTOMOTIVE_DESIGN_CC2'));",
"ENDSEC;",
"DATA;",
]
)
path.write_text(header + "\n" + "\n".join(entities) + "\nENDSEC;\nEND-ISO-10303-21;\n", encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="Generate a simple STEP cube for editor tests.")
parser.add_argument("output", nargs="?", default=str(DEFAULT_OUTPUT), help="Output STEP file path.")
parser.add_argument("--size", type=float, default=10.0, help="Cube edge length in millimeters.")
args = parser.parse_args()
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
write_cube_step(output, args.size)
print(f"wrote {output.resolve()} ({output.stat().st_size} bytes)")
print(f"cube bounds: 0,0,0 to {args.size:g},{args.size:g},{args.size:g} mm")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import argparse
from collections import Counter
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
def _edge_length(model: StepModel, edge_id: int) -> float:
return float(model.edge_info(edge_id).get("length", 0.0))
def _line_edge_ids_near_length(model: StepModel, length: float, tolerance: float) -> list[int]:
edge_ids: list[int] = []
for edge_id in range(len(model.edges)):
info = model.edge_info(edge_id)
if info.get("curve") != "line":
continue
if abs(float(info.get("length", 0.0)) - length) <= tolerance:
edge_ids.append(edge_id)
return edge_ids
def _length_distribution(model: StepModel) -> dict[float, int]:
counts = Counter(round(_edge_length(model, edge_id), 6) for edge_id in range(len(model.edges)))
return dict(sorted(counts.items()))
def main() -> int:
parser = argparse.ArgumentParser(description="Verify cube edge-length local deformation.")
parser.add_argument("model", nargs="?", default=str(DEFAULT_MODEL), help="STEP model path.")
parser.add_argument("--source-length", type=float, default=10.0, help="Current line edge length to search for.")
parser.add_argument("--target-length", type=float, default=15.0, help="Target edge length to apply.")
parser.add_argument("--anchor", default="keep-start", choices=["auto", "center", "keep-start", "keep-end"])
parser.add_argument("--tolerance", type=float, default=1e-5, help="Allowed target length error.")
args = parser.parse_args()
model = StepModel.load(Path(args.model))
edge_ids = _line_edge_ids_near_length(model, args.source_length, args.tolerance)
if not edge_ids:
raise SystemExit(f"no line edge near source length {args.source_length:g}")
edge_id = edge_ids[0]
before = model.stats()
plan = model.general_edge_length_plan(edge_id, args.target_length, anchor_mode=args.anchor)
strategy = str(plan.get("resize_strategy", ""))
if strategy != "local-edge-only-deform":
raise SystemExit(f"expected local-edge-only-deform, got {strategy or '<none>'}")
result = model.resize_general_edge_length(edge_id, args.target_length, anchor_mode=args.anchor)
after = model.stats()
lengths = [_edge_length(model, item) for item in range(len(model.edges))]
nearest = min(lengths, key=lambda value: abs(value - args.target_length))
error = abs(nearest - args.target_length)
if error > args.tolerance:
raise SystemExit(f"target length check failed: nearest={nearest:g}, error={error:g}")
print(f"model={Path(args.model)}")
print(f"edge_id={edge_id}")
print(f"strategy={strategy}")
print(f"anchor_mode={args.anchor}")
print(f"start_move={plan.get('local_edge_deform_start_move')}")
print(f"end_move={plan.get('local_edge_deform_end_move')}")
print(f"before_faces={before.faces} before_edges={before.edges}")
print(f"after_faces={after.faces} after_edges={after.edges}")
print(f"nearest_length={nearest:.6f} target_error={error:.6g}")
print(f"length_distribution={_length_distribution(model)}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+288 -151
View File
@@ -49,6 +49,8 @@ from .window_state import WindowStateMixin
_CRASH_LOG_HANDLE = None
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
def _enable_crash_log() -> None:
@@ -85,8 +87,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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.hover_interval_ms = 85
self.hover_move_threshold_px = 5
self.pending_hover_position: tuple[int, int] | None = None
self.last_hover_pick_position: tuple[int, int] | None = None
self.pointer_button_down = False
@@ -132,13 +134,14 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.scan_thread: QThread | None = None
self.scan_worker: ScanWorker | None = None
self.pending_scan_kind: str | None = None
self.pending_scan_context: dict[str, object] | 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.initial_load_deflection = 2.0
self.last_id_kind = "Face"
self._build_ui()
@@ -169,56 +172,154 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
panel_scroll.setStyleSheet(
"""
QScrollArea {
background: #f4f7fb;
background: #edf2f8;
border: none;
}
QScrollArea > QWidget > QWidget {
background: #f4f7fb;
background: #edf2f8;
}
QScrollBar:vertical {
background: #e3eaf2;
border: none;
border-radius: 5px;
margin: 0;
width: 10px;
}
QScrollBar::handle:vertical {
background: #aab8c8;
border-radius: 5px;
min-height: 36px;
}
QScrollBar::handle:vertical:hover {
background: #8798ad;
}
QScrollBar::add-line:vertical,
QScrollBar::sub-line:vertical {
height: 0;
width: 0;
}
QGroupBox {
background: #ffffff;
border: 2px solid #6b7cff;
border: 1px solid #d4dde8;
border-left: 5px solid #6b7cff;
border-radius: 8px;
color: #1f2937;
margin-top: 12px;
padding: 12px 10px 10px 10px;
margin-top: 14px;
padding: 15px 10px 10px 12px;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 12px;
padding: 0 6px;
left: 14px;
padding: 2px 8px;
color: #172033;
background: #f4f7fb;
background: #ffffff;
border: 1px solid #d4dde8;
border-radius: 7px;
font-weight: 700;
}
QGroupBox#fileSection {
border-color: #3478f6;
background: #f7fbff;
border: 1px solid #bdd3ff;
border-left: 5px solid #3478f6;
}
QGroupBox#fileSection::title {
background: #eaf2ff;
border-color: #bdd3ff;
color: #1d4ed8;
}
QGroupBox#treeSection {
border-color: #0f9f8f;
background: #f2fbf8;
border: 1px solid #b8dfd6;
border-left: 5px solid #0f9f8f;
}
QGroupBox#treeSection::title {
background: #e2f5ef;
border-color: #b8dfd6;
color: #0f766e;
}
QGroupBox#modeSection,
QGroupBox#idSection {
border-color: #6b5cff;
background: #f7f5ff;
border: 1px solid #cbc4ff;
border-left: 5px solid #6b5cff;
}
QGroupBox#modeSection::title,
QGroupBox#idSection::title {
background: #eeeaff;
border-color: #cbc4ff;
color: #5642c7;
}
QGroupBox#viewSection {
border-color: #0e9bd8;
background: #f1faff;
border: 1px solid #b6dff2;
border-left: 5px solid #0e9bd8;
}
QGroupBox#viewSection::title {
background: #e3f4fd;
border-color: #b6dff2;
color: #0369a1;
}
QGroupBox#measureSection {
background: #f1fbfb;
border: 1px solid #b6e0e1;
border-left: 5px solid #14a3a8;
}
QGroupBox#measureSection::title {
background: #e0f5f6;
border-color: #b6e0e1;
color: #0f777b;
}
QGroupBox#exportSection {
border-color: #1f9d55;
background: #f4fbf3;
border: 1px solid #bfdcbd;
border-left: 5px solid #1f9d55;
}
QGroupBox#exportSection::title {
background: #e8f5e7;
border-color: #bfdcbd;
color: #1f7a3d;
}
QGroupBox#editSection {
border-color: #d97706;
background: #fff8ef;
border: 1px solid #edc98e;
border-left: 5px solid #d97706;
}
QGroupBox#editSection::title {
background: #fff0d8;
border-color: #edc98e;
color: #a45303;
}
QGroupBox#editableSection,
QGroupBox#candidateSection {
border-color: #b45309;
background: #fff7ed;
border: 1px solid #e6c297;
border-left: 5px solid #b45309;
}
QGroupBox#editableSection::title,
QGroupBox#candidateSection::title {
background: #ffefd7;
border-color: #e6c297;
color: #92400e;
}
QGroupBox#historySection {
border-color: #be3b6b;
background: #fff5f8;
border: 1px solid #edb8cb;
border-left: 5px solid #be3b6b;
}
QGroupBox#historySection::title {
background: #fde7ef;
border-color: #edb8cb;
color: #9d2854;
}
QGroupBox#infoSection {
border-color: #64748b;
background: #f8fafc;
border: 1px solid #cbd5e1;
border-left: 5px solid #64748b;
}
QGroupBox#infoSection::title {
background: #eef2f7;
border-color: #cbd5e1;
color: #475569;
}
QLabel {
color: #2f3846;
@@ -243,7 +344,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
font-weight: 700;
}
QPushButton {
background: #eef3f8;
background: #f8fafc;
border: 1px solid #c8d2df;
border-radius: 6px;
color: #172033;
@@ -252,16 +353,31 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
padding: 5px 8px;
}
QPushButton:hover {
background: #e3edf9;
border-color: #8fb0dc;
background: #eef6ff;
border-color: #77a7dd;
}
QPushButton:pressed {
background: #d7e6f6;
}
QPushButton:disabled {
background: #eef0f3;
border-color: #dfe3ea;
color: #7a7f86;
background: #e3e8ef;
border: 1px dashed #b6c0cd;
color: #8f99a8;
font-weight: 500;
}
QLineEdit:disabled,
QComboBox:disabled,
QPlainTextEdit:disabled {
background: #eef2f6;
border: 1px dashed #bcc7d4;
color: #8f99a8;
}
QCheckBox:disabled,
QTabWidget:disabled,
QTreeWidget:disabled,
QTableWidget:disabled,
QListWidget:disabled {
color: #8f99a8;
}
QTreeWidget,
QTableWidget,
@@ -271,11 +387,12 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
border: 1px solid #d8e0eb;
border-radius: 6px;
color: #172033;
selection-background-color: #dceafe;
alternate-background-color: #f7fafd;
selection-background-color: #d7ecff;
selection-color: #0f172a;
}
QHeaderView::section {
background: #edf2f7;
background: #e8eef6;
border: 0;
border-bottom: 1px solid #d8e0eb;
color: #334155;
@@ -315,14 +432,14 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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)
self.open_button = QPushButton("打开")
help_tip(self.open_button, "选择并打开一个 .step 或 .stp 文件。打开失败时会保留当前模型。")
self.open_button.clicked.connect(self.open_step)
self.reload_button = QPushButton("重新加载")
help_tip(self.reload_button, "从磁盘重新读取当前 STEP 文件,用于放弃本次会话里的临时查看状态。")
self.reload_button.clicked.connect(self.reload_step)
file_buttons.addWidget(self.open_button)
file_buttons.addWidget(self.reload_button)
file_layout.addLayout(file_buttons)
panel_layout.addWidget(file_box)
@@ -333,21 +450,22 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.part_tree = QTreeWidget()
self.part_tree.setHeaderLabels(["对象", "内容"])
self.part_tree.setMinimumHeight(180)
help_tip(self.part_tree, "模型里的装配、零件和 solid 列表。点击一行可以选中并高亮对应对象。")
self.part_tree.setAlternatingRowColors(True)
help_tip(self.part_tree, "模型里的装配、零件和Solid列表。点击一行可以选中并高亮对应对象。")
self.part_tree.currentItemChanged.connect(self.on_part_tree_select)
tree_layout.addWidget(self.part_tree)
panel_layout.addWidget(tree_box)
mode_box = QGroupBox("鼠标选择模式")
mode_box.setObjectName("modeSection")
help_tip(mode_box, "决定鼠标点模型时选中零件、solid、面、边,还是识别局部特征。")
help_tip(mode_box, "决定鼠标点模型时选中 Part、Solid、Face、Edge,还是识别局部特征。")
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、面、边,或把面解释成孔/槽/圆角等特征候选。",
"选择鼠标点击模型时要选什么:Part、Solid、Face、Edge,或把 Face 解释成孔/槽/圆角等特征候选。",
)
self.mode_combo.currentTextChanged.connect(self._on_mode_changed)
mode_layout.addWidget(self.mode_combo)
@@ -367,14 +485,15 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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.select_id_button = QPushButton("选择")
help_tip(self.select_id_button, "按当前选择模式跳转到输入的 ID,并在 3D 视图中高亮它。")
self.select_id_button.clicked.connect(lambda _checked=False: self.select_by_id(self.mode_combo.currentText()))
self.id_input.textChanged.connect(lambda _text: self._update_action_states())
self.id_input.returnPressed.connect(lambda: self.select_by_id(self.mode_combo.currentText()))
select_layout.addWidget(QLabel("ID"), 0, 0)
select_layout.addWidget(self.id_input, 0, 1)
select_layout.addWidget(self.id_mode_display, 0, 2)
select_layout.addWidget(select_button, 0, 3)
select_layout.addWidget(self.select_id_button, 0, 3)
panel_layout.addWidget(select_box)
view_box = QGroupBox("显示")
@@ -382,18 +501,18 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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)
self.isolate_button = QPushButton("只显示选中")
help_tip(self.isolate_button, "把视图临时隔离到当前选中的 Part、Solid、Face、Edge 或特征区域。不会修改模型。")
self.isolate_button.clicked.connect(self.isolate_selected)
self.fit_button = QPushButton("对准选中")
help_tip(self.fit_button, "把相机移动到当前选中对象附近,方便看清细节。不会修改模型。")
self.fit_button.clicked.connect(self.fit_selected)
self.show_all_button = QPushButton("显示全部")
help_tip(self.show_all_button, "取消隔离显示,恢复查看完整模型。不会修改模型。")
self.show_all_button.clicked.connect(self.show_all_geometry)
view_buttons.addWidget(self.isolate_button)
view_buttons.addWidget(self.fit_button)
view_buttons.addWidget(self.show_all_button)
view_layout.addLayout(view_buttons)
self.show_internal_edges_checkbox = QCheckBox("显示同域内部边")
help_tip(
@@ -402,10 +521,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
)
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")
measure_box.setObjectName("measureSection")
help_tip(measure_box, "把当前选中对象的拾取点或中心设为 A/B,计算两点距离和 X/Y/Z 差值。不会修改模型。")
measure_layout = QVBoxLayout(measure_box)
self.measure_text = QPlainTextEdit()
@@ -413,25 +531,24 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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)
self.set_measure_a_button = QPushButton("设为 A")
help_tip(self.set_measure_a_button, "把当前选中对象的拾取点设为测量点 A;没有拾取点时使用对象中心。")
self.set_measure_a_button.clicked.connect(lambda _checked=False: self.set_measure_point("A"))
self.set_measure_b_button = QPushButton("设为 B")
help_tip(self.set_measure_b_button, "把当前选中对象的拾取点设为测量点 B;没有拾取点时使用对象中心。")
self.set_measure_b_button.clicked.connect(lambda _checked=False: self.set_measure_point("B"))
self.copy_measure_button = QPushButton("复制测量")
help_tip(self.copy_measure_button, "复制当前测量结果文本,方便记录尺寸。")
self.copy_measure_button.clicked.connect(self.copy_measurement)
self.clear_measure_button = QPushButton("清除测量")
help_tip(self.clear_measure_button, "清除 A/B 测量点和 3D 测量线。不会影响模型。")
self.clear_measure_button.clicked.connect(self.clear_measurement)
measure_buttons.addWidget(self.set_measure_a_button, 0, 0)
measure_buttons.addWidget(self.set_measure_b_button, 0, 1)
measure_buttons.addWidget(self.copy_measure_button, 1, 0)
measure_buttons.addWidget(self.clear_measure_button, 1, 1)
measure_layout.addWidget(self.measure_text)
measure_layout.addLayout(measure_buttons)
panel_layout.addWidget(measure_box)
self._refresh_measurement_panel()
export_box = QGroupBox("导出")
@@ -442,10 +559,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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")
help_tip(self.export_part_button, "只导出当前选中的零件。适合从装配里拆出一个单独零件")
self.export_part_button.clicked.connect(self.export_selected_part)
self.export_solid_button = QPushButton("导出选中 solid")
help_tip(self.export_solid_button, "只导出当前选中的实体 solid。适合检查或单独保存某个实体。")
self.export_solid_button = 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, "导出当前选中的面区域;同域高亮的共面/同圆柱区域也会一起导出。")
@@ -453,17 +570,17 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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 = QPushButton("导出选中Edge")
help_tip(self.export_edge_button, "导出当前选中的Edge。主要用于调试、定位或把边界单独拿出去检查。")
self.export_edge_button.clicked.connect(self.export_selected_edge)
self.export_check_button = QPushButton("检查导出质量")
help_tip(self.export_check_button, "检查当前导出对象是否像有效实体:B-Rep、face/edge 数量、体积和包围盒等。")
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 = 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)
@@ -474,13 +591,12 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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 = QGroupBox("编辑")
edit_box.setObjectName("editSection")
help_tip(edit_box, "这里的按钮会真实修改当前 B-Rep 模型;执行前通常会预览,成功后可撤销。")
help_tip(edit_box, "这里的按钮会真实修改当前 B-Rep 模型;执行前通常会预览,成功后可撤销。部分能力仍是当前版本的几何近似。")
edit_layout = QGridLayout(edit_box)
edit_layout.addWidget(QLabel("偏移"), 0, 0)
edit_layout.addWidget(QLabel("Face偏移"), 0, 0)
self.offset_input = QLineEdit("5.0")
help_tip(self.offset_input, "平面推拉距离。正数通常向外加料,负数通常向内切削;单位沿用 STEP 模型单位。")
edit_layout.addWidget(self.offset_input, 0, 1)
@@ -503,7 +619,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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, "修改已识别槽/半孔候选的宽度。第一版是几何近似,失败会回滚。")
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)
@@ -541,10 +657,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
edit_layout.addWidget(QLabel("圆角半径"), 11, 0)
self.edge_fillet_radius_input = QLineEdit("")
help_tip(self.edge_fillet_radius_input, "新圆角或已有圆角的目标半径。选中/圆角候选后会自动填参考值。")
help_tip(self.edge_fillet_radius_input, "新圆角或已有圆角的目标半径。选中Edge/圆角候选后会自动填参考值。")
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 = QPushButton("Edge添加圆角")
help_tip(self.fillet_edge_button, "给当前直线Edge新增圆角。不是修改已有圆角;已有圆角请用下面那个按钮。")
self.fillet_edge_button.clicked.connect(self.fillet_edge)
edit_layout.addWidget(self.fillet_edge_button, 12, 0, 1, 2)
@@ -555,20 +671,20 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
edit_layout.addWidget(QLabel("倒角距离"), 14, 0)
self.edge_chamfer_distance_input = QLineEdit("")
help_tip(self.edge_chamfer_distance_input, "添加倒角时使用的距离。选中直线后会填一个较小参考值。")
help_tip(self.edge_chamfer_distance_input, "Edge添加倒角时使用的距离。选中直线Edge后会填一个较小参考值。")
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 = QPushButton("Edge添加倒角")
help_tip(self.chamfer_edge_button, "给当前直线Edge新增对称倒角。会先预览,再后台执行,失败会回滚。")
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)
edit_layout.addWidget(QLabel("Edge目标长度"), 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()
help_tip(self.edge_target_length_input, "当前Edge的目标长度。选中Edge后会自动填当前长度,改成新长度再执行。")
self.edge_length_anchor_combo = NoWheelComboBox()
self.edge_length_anchor_combo.addItem("自动", "auto")
self.edge_length_anchor_combo.addItem("中心", "center")
self.edge_length_anchor_combo.addItem("固定起点", "keep-start")
@@ -577,20 +693,35 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.edge_length_anchor_combo.setMinimumWidth(90)
help_tip(
self.edge_length_anchor_combo,
"选择边长修改时尽量固定哪里:自动会优先找局部端面;中心/起点/终点会影响缩放 fallback 的基准点。",
"选择修改Edge长度时尽量固定哪里:自动默认固定起点并移动终点;中心会两端各动一半;固定起点/终点会明确控制局部形变端点。",
)
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("直接修改边长")
self.resize_edge_length_button = QPushButton("修改Edge长度")
help_tip(
self.resize_edge_length_button,
"直接修改当前 edge 长度。直线边优先端面推拉,圆边优先换算相邻圆柱直径,必要时再用几何缩放 fallback",
"修改当前Edge长度。直线Edge优先局部形变;圆、椭圆和平面曲线会优先用局部/径向策略,必要时再用后备缩放",
)
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)
edit_layout.addWidget(QLabel("薄壁厚度"), 18, 0)
self.shell_thickness_input = QLineEdit("")
help_tip(
self.shell_thickness_input,
"薄壁/壳体局部区域的目标厚度。选中有相对平面的平面候选后会自动填一个参考值。",
)
edit_layout.addWidget(self.shell_thickness_input, 18, 1)
self.resize_shell_thickness_button = QPushButton("调整薄壁厚度")
help_tip(
self.resize_shell_thickness_button,
"移动当前平面区域来达到目标厚度。当前版本基于相对平面估算,执行前会预览并可回滚。",
)
self.resize_shell_thickness_button.clicked.connect(self.resize_shell_thickness)
edit_layout.addWidget(self.resize_shell_thickness_button, 19, 0, 1, 2)
edit_layout.addWidget(QLabel("平移 X/Y/Z"), 20, 0)
translate_inputs = QWidget()
translate_layout = QHBoxLayout(translate_inputs)
translate_layout.setContentsMargins(0, 0, 0, 0)
@@ -606,21 +737,21 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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)
edit_layout.addWidget(translate_inputs, 20, 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 = 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(self.translate_part_button, 21, 0, 1, 2)
edit_layout.addWidget(self.translate_solid_button, 22, 0, 1, 2)
edit_layout.addWidget(QLabel("旋转轴/角度"), 21, 0)
edit_layout.addWidget(QLabel("旋转轴/角度"), 23, 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 = NoWheelComboBox()
self.rotate_axis_combo.addItems(["X", "Y", "Z"])
self.rotate_axis_combo.setCurrentText("Z")
help_tip(self.rotate_axis_combo, "选择旋转轴。对象会绕自身包围盒中心旋转。")
@@ -629,20 +760,20 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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)
edit_layout.addWidget(rotate_inputs, 23, 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 = 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)
edit_layout.addWidget(self.rotate_part_button, 24, 0, 1, 2)
edit_layout.addWidget(self.rotate_solid_button, 25, 0, 1, 2)
self.cylinders_button = QPushButton("列出圆柱候选")
help_tip(self.cylinders_button, "扫描模型里的圆柱面,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。")
self.cylinders_button = QPushButton("扫描圆柱面")
help_tip(self.cylinders_button, "扫描模型里的圆柱面候选,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。")
self.cylinders_button.clicked.connect(self.list_cylinders)
edit_layout.addWidget(self.cylinders_button, 24, 0, 1, 2)
edit_layout.addWidget(self.cylinders_button, 26, 0, 1, 2)
self.undo_button = QPushButton("撤销")
help_tip(self.undo_button, "撤销上一次成功编辑,把模型恢复到编辑前快照。")
@@ -650,16 +781,19 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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)
edit_layout.addWidget(self.undo_button, 27, 0)
edit_layout.addWidget(self.redo_button, 27, 1)
panel_layout.addWidget(edit_box)
panel_layout.addWidget(export_box)
panel_layout.addWidget(view_box)
panel_layout.addWidget(measure_box)
editable_box = QGroupBox("第一版可编辑对象")
editable_box = QGroupBox("可编辑对象")
editable_box.setObjectName("editableSection")
help_tip(editable_box, "自动找出一批可以尝试编辑的 face 或 edge,方便不用手动到处点。")
help_tip(editable_box, "自动找出当前版本可以尝试编辑的Face或Edge,方便不用手动到处点。")
editable_layout = QVBoxLayout(editable_box)
editable_button_row = QHBoxLayout()
self.editable_refresh_button = QPushButton("扫描可编辑对象")
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("深度扫描")
@@ -678,14 +812,15 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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.setAlternatingRowColors(True)
help_tip(self.editable_table, "这里列出可尝试编辑的对象。点击一行会选中模型里的对应Face或Edge,并自动填入相关输入框。")
self.editable_table.cellClicked.connect(self.on_editable_row_clicked)
editable_layout.addWidget(self.editable_table)
panel_layout.addWidget(editable_box)
candidate_box = QGroupBox("圆柱候选")
candidate_box = QGroupBox("圆柱候选")
candidate_box.setObjectName("candidateSection")
help_tip(candidate_box, "列出圆柱面并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。")
help_tip(candidate_box, "列出圆柱面候选,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。")
candidate_layout = QVBoxLayout(candidate_box)
filter_layout = QHBoxLayout()
filter_layout.addWidget(QLabel("类型"))
@@ -701,7 +836,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
)
help_tip(
self.candidate_filter_combo,
"筛选下面的圆柱候选:孔/槽、圆角、凸台/外圆,或暂时无法明确分类的圆柱面。",
"筛选下面的圆柱候选:孔/槽、圆角、凸台/外圆,或暂时无法明确分类的圆柱面。",
)
self.candidate_filter_combo.currentTextChanged.connect(
lambda _text: self._filter_cached_cylinder_candidates(show_info=True)
@@ -710,13 +845,14 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
candidate_layout.addLayout(filter_layout)
self.cylinder_table = QTableWidget(0, 8)
self.cylinder_table.setHorizontalHeaderLabels(
["face", "guess", "diameter", "span", "height", "confidence", "risk", "part"]
["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.setAlternatingRowColors(True)
help_tip(self.cylinder_table, "这里显示圆柱面候选。点击一行会选中对应Face,并在属性区显示可用操作和风险。")
self.cylinder_table.cellClicked.connect(self.on_cylinder_row_clicked)
candidate_layout.addWidget(self.cylinder_table)
panel_layout.addWidget(candidate_box)
@@ -729,20 +865,21 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.history_list.setMinimumHeight(110)
help_tip(self.history_list, "成功执行过的编辑会出现在这里。点击一条记录可查看参数、差异预览和目标位置。")
self.history_list.currentRowChanged.connect(self.on_history_row_changed)
self.history_list.currentRowChanged.connect(lambda _row: self._update_action_states())
history_buttons = QHBoxLayout()
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)
self.clear_diff_button = QPushButton("清除差异预览")
help_tip(self.clear_diff_button, "关闭历史记录产生的红/绿差异叠加和热力图显示。不会修改模型。")
self.clear_diff_button.clicked.connect(lambda _checked=False: self.clear_diff_preview())
self.export_diff_button = QPushButton("导出差异报告")
help_tip(self.export_diff_button, "把当前选中的历史记录导出成文本报告,包含参数、拓扑变化和几何差异统计。")
self.export_diff_button.clicked.connect(self.export_diff_report)
self.export_history_button = QPushButton("导出编辑历史")
help_tip(self.export_history_button, "把本次会话里的所有编辑记录导出为 JSON,方便留档或后续复盘。")
self.export_history_button.clicked.connect(self.export_operation_history)
history_layout.addWidget(self.history_list)
history_buttons.addWidget(clear_diff_button)
history_buttons.addWidget(export_diff_button)
history_buttons.addWidget(export_history_button)
history_buttons.addWidget(self.clear_diff_button)
history_buttons.addWidget(self.export_diff_button)
history_buttons.addWidget(self.export_history_button)
history_layout.addLayout(history_buttons)
panel_layout.addWidget(history_box)
@@ -751,18 +888,18 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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)
self.copy_id_button = QPushButton("复制 ID")
help_tip(self.copy_id_button, "复制当前选中对象的 ID。Face/Feature 会优先复制逻辑 Face ID。")
self.copy_id_button.clicked.connect(self.copy_selected_id)
self.copy_pick_button = QPushButton("复制坐标")
help_tip(self.copy_pick_button, "复制最近一次鼠标点到模型上的三维坐标。")
self.copy_pick_button.clicked.connect(self.copy_pick_position)
self.copy_info_button = QPushButton("复制信息")
help_tip(self.copy_info_button, "复制当前属性区里的完整文本,方便发给别人或做问题记录。")
self.copy_info_button.clicked.connect(self.copy_current_info)
info_buttons.addWidget(self.copy_id_button)
info_buttons.addWidget(self.copy_pick_button)
info_buttons.addWidget(self.copy_info_button)
info_layout.addLayout(info_buttons)
self.info_tabs = QTabWidget()
@@ -803,7 +940,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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")
path = Path(paths[0]) if paths else DEFAULT_MODEL_PATH
return path, smoke_test
+83 -51
View File
@@ -271,48 +271,79 @@ class FeatureMixin:
break
plane_count = 0
shell_thickness_count = 0
shell_thickness_limit = max(2, min(per_type_limit, limit // 10))
face_scan_limit = len(self.faces) if max_scan_faces is None else min(len(self.faces), max(0, int(max_scan_faces)))
for face_id, face in enumerate(self.faces[:face_scan_limit]):
if progress_callback is not None and face_id % 30 == 0:
progress_callback()
if plane_count >= per_type_limit:
if plane_count >= per_type_limit and shell_thickness_count >= shell_thickness_limit:
break
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
continue
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
if detailed:
direction_info = self._plane_push_pull_direction(face_id, surf)
confidence = str(direction_info["confidence"])
risk = "low" if confidence == "high" else "medium"
status = "ready" if confidence == "high" else "caution"
note = str(direction_info["note"])
else:
confidence = "pending"
risk = "medium"
status = "caution"
note = "快速扫描:推拉方向会在选中 face 或执行编辑前再详细判断。"
candidates.append(
{
"operation_key": "push_pull_plane",
"operation": "推拉平面",
"target_kind": "face",
"target_id": face_id,
"face_id": face_id,
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"surface": "plane",
"feature_guess": "planar push/pull candidate",
"current_value": props.Mass(),
"current_value_label": "area",
"status": status,
"risk": risk,
"confidence": confidence,
"note": note,
}
)
plane_count += 1
if plane_count < per_type_limit:
if detailed:
direction_info = self._plane_push_pull_direction(face_id, surf)
confidence = str(direction_info["confidence"])
risk = "low" if confidence == "high" else "medium"
status = "ready" if confidence == "high" else "caution"
note = str(direction_info["note"])
else:
confidence = "pending"
risk = "medium"
status = "caution"
note = "快速扫描:推拉方向会在选中Face或执行编辑前再详细判断。"
candidates.append(
{
"operation_key": "push_pull_plane",
"operation": "推拉平面",
"target_kind": "face",
"target_id": face_id,
"face_id": face_id,
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"surface": "plane",
"feature_guess": "planar push/pull candidate",
"current_value": props.Mass(),
"current_value_label": "area",
"status": status,
"risk": risk,
"confidence": confidence,
"note": note,
}
)
plane_count += 1
if shell_thickness_count < shell_thickness_limit:
feature = self.feature_info(face_id)
if feature.get("shell_region_status") == "candidate":
shell_confidence = str(feature.get("shell_confidence", "low"))
shell_risk = "low" if shell_confidence == "high" else "medium" if shell_confidence == "medium" else "high"
candidates.append(
{
"operation_key": "resize_shell_thickness",
"operation": "调整薄壁厚度",
"target_kind": "face",
"target_id": face_id,
"face_id": face_id,
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"surface": "plane",
"feature_guess": "thin-wall opposite plane candidate",
"current_value": feature.get("shell_thickness_estimate"),
"current_value_label": "shell_thickness",
"status": "ready" if shell_confidence == "high" else "caution",
"risk": shell_risk,
"confidence": shell_confidence,
"note": (
"快速扫描:已找到投影重叠的相对平面;点击后会填入参考目标厚度,"
"执行时会移动当前平面区域来改变局部薄壁/壳体厚度。"
),
}
)
shell_thickness_count += 1
fillet_edge_count = 0
chamfer_edge_count = 0
@@ -342,7 +373,7 @@ class FeatureMixin:
candidates.append(
{
"operation_key": "fillet_edge",
"operation": "添加圆角",
"operation": "Edge添加圆角",
"target_kind": "edge",
"target_id": edge_id,
"edge_id": edge_id,
@@ -363,7 +394,7 @@ class FeatureMixin:
candidates.append(
{
"operation_key": "chamfer_edge",
"operation": "添加倒角",
"operation": "Edge添加倒角",
"target_kind": "edge",
"target_id": edge_id,
"edge_id": edge_id,
@@ -384,7 +415,7 @@ class FeatureMixin:
candidates.append(
{
"operation_key": "resize_edge_length",
"operation": "直接修改边长",
"operation": "修改Edge长度",
"target_kind": "edge",
"target_id": edge_id,
"edge_id": edge_id,
@@ -397,7 +428,7 @@ class FeatureMixin:
"status": "caution",
"risk": "medium" if is_line_edge or is_circle_edge else "high",
"confidence": "pending",
"note": "快速扫描:直线边优先尝试端面推拉;圆边会尝试换算相邻圆柱直径;其他边使用几何缩放 fallback",
"note": "快速扫描:直线边优先局部形变;圆边优先换算相邻圆柱直径;椭圆/平面曲线会尝试径向缩放;其他边使用后备策略",
}
)
edge_length_count += 1
@@ -411,10 +442,11 @@ class FeatureMixin:
"suppress_cylinder": 3,
"resize_depth": 4,
"inspect_existing_fillet": 5,
"push_pull_plane": 6,
"fillet_edge": 7,
"chamfer_edge": 8,
"resize_edge_length": 9,
"resize_shell_thickness": 6,
"push_pull_plane": 7,
"fillet_edge": 8,
"chamfer_edge": 9,
"resize_edge_length": 10,
}
candidates.sort(
key=lambda item: (
@@ -488,7 +520,7 @@ class FeatureMixin:
return {
"status": "blocked",
"risk": "blocked",
"message": "当前选中的 face 不是圆柱面,不能执行圆柱切削。",
"message": "当前选中的 Face 不是圆柱面,不能执行圆柱切削。",
}
current_diameter = float(info["diameter"])
feature = self.feature_info(face_id)
@@ -550,7 +582,7 @@ class FeatureMixin:
return {
"status": "blocked",
"risk": "blocked",
"message": "当前选中的 face 不是圆柱面,不能调整圆柱凸台直径。",
"message": "当前选中的 Face 不是圆柱面,不能调整圆柱凸台直径。",
}
current_diameter = float(info["diameter"])
@@ -609,7 +641,7 @@ class FeatureMixin:
return {
"status": "blocked",
"risk": "blocked",
"message": "当前选中的 face 不是圆柱面,不能封堵圆柱孔。",
"message": "当前选中的 Face 不是圆柱面,不能封堵圆柱孔。",
}
feature = self.feature_info(face_id)
axis_range = self._cylindrical_axis_range(
@@ -664,7 +696,7 @@ class FeatureMixin:
return {
"status": "blocked",
"risk": "blocked",
"message": "当前选中的 face 不是圆柱面,不能调整盲孔深度。",
"message": "当前选中的 Face 不是圆柱面,不能调整盲孔深度。",
}
feature = self.feature_info(face_id)
@@ -742,7 +774,7 @@ class FeatureMixin:
if surf.GetType() != GeomAbs_Cylinder:
return {
"context_status": "blocked",
"context_message": "当前选中的 face 不是圆柱面。",
"context_message": "当前选中的 Face 不是圆柱面。",
}
manual_bottom_face_id = None
@@ -775,7 +807,7 @@ class FeatureMixin:
if not bottom_face_ids:
return {
"context_status": "blocked",
"context_message": "第一版孔深调整需要疑似底面;如果自动识别失败,请手动填写底面 Face ID。",
"context_message": "当前版本的孔深调整需要疑似底面;如果自动识别失败,请手动填写底面Face ID。",
}
cyl = surf.Cylinder()
@@ -912,7 +944,7 @@ class FeatureMixin:
if surf.GetType() != GeomAbs_Cylinder:
return {
"cutter_strategy": "unavailable",
"cutter_note": "selected face is not cylindrical",
"cutter_note": "选中Face不是圆柱面",
}
cyl = surf.Cylinder()
@@ -954,7 +986,7 @@ class FeatureMixin:
"cutter_strategy": "bounded-to-selected-cylinder-v-range",
"cutter_note": (
"有限长度切削:优先按同域圆柱侧壁整体 V 范围生成 cutter,"
"如果没有同域拆分则退回选中 face 范围,减少贯穿整个零件的误切风险。"
"如果没有同域拆分则退回选中Face范围,减少贯穿整个零件的误切风险。"
),
"cutter_scope_face_ids": axis_range["same_domain_face_ids"],
"cutter_scope_face_count": axis_range["same_domain_face_count"],
@@ -982,7 +1014,7 @@ class FeatureMixin:
if surf.GetType() != GeomAbs_Cylinder:
return {
"fill_strategy": "unavailable",
"fill_note": "selected face is not cylindrical",
"fill_note": "选中Face不是圆柱面",
}
cyl = surf.Cylinder()
@@ -1019,7 +1051,7 @@ class FeatureMixin:
if surf.GetType() != GeomAbs_Cylinder:
return {
"boss_tool_strategy": "unavailable",
"boss_tool_note": "selected face is not cylindrical",
"boss_tool_note": "选中Face不是圆柱面",
}
cyl = surf.Cylinder()
@@ -1055,7 +1087,7 @@ class FeatureMixin:
"boss_tool_note": (
"扩大凸台会在同域圆柱侧壁整体 V 范围内生成目标半径圆柱并 Fuse;"
"缩小凸台会先用旧半径包络体移除原凸台范围,再 Fuse 目标半径圆柱重建。"
"第一版会给轴向两端保留少量重叠,让布尔结果更容易和原实体合并。"
"当前版本会给轴向两端保留少量重叠,让布尔结果更容易和原实体合并。"
),
"boss_tool_scope_face_ids": axis_range["same_domain_face_ids"],
"boss_tool_scope_face_count": axis_range["same_domain_face_count"],
+24 -24
View File
@@ -191,12 +191,12 @@ def _shape_quality_info(label: str, shape: TopoDS_Shape, expect_solid: bool) ->
if not brep_valid:
warnings.append("B-Rep 校验未通过,导出后其他 CAD 软件可能无法正常识别。")
if faces == 0:
warnings.append("没有检测到 face,导出结果可能不可用。")
warnings.append("没有检测到Face,导出结果可能不可用。")
if expect_solid and solids == 0:
warnings.append("没有检测到 solid,导出后可能不是实体。")
warnings.append("没有检测到Solid,导出后可能不是实体。")
elif expect_solid and solids > 1:
warnings.append(
f"检测到 {solids} solid。"
f"检测到 {solids}Solid。"
"如果这不是有意的多实体零件,导出后可能看起来像多个体叠在一起或彼此分离。"
)
@@ -782,7 +782,7 @@ def _cylinder_resize_readiness(
if guess != "hole/groove candidate":
status = "blocked"
risk = "blocked"
blockers.append("缩小孔径第一版只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。")
blockers.append("缩小孔径当前版本只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。")
else:
risk = _max_risk(risk, "high")
warnings.append("缩小孔径会先补料再重切,属于高风险实验功能。")
@@ -817,9 +817,9 @@ def _cylinder_boss_resize_readiness(
height_estimate = float(info.get("height_estimate", 0.0))
if guess != "boss/outer-round candidate":
blockers.append("凸台直径调整第一版只支持明确的凸台/外圆柱候选。")
blockers.append("凸台直径调整当前版本只支持明确的凸台/外圆柱候选。")
if angular_span < math.tau * 0.92:
blockers.append("凸台直径调整第一版只支持接近完整圆柱的凸台,不处理局部外圆角或圆角面。")
blockers.append("凸台直径调整当前版本只支持接近完整圆柱的凸台,不处理局部外圆角或圆角面。")
if current_diameter <= 1e-9:
blockers.append("当前圆柱面的直径估算无效。")
@@ -885,9 +885,9 @@ def _cylinder_depth_readiness(
manual_bottom_face_used = bool(info.get("manual_bottom_face_used"))
if guess != "hole/groove candidate":
blockers.append("孔深调整第一版只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。")
blockers.append("孔深调整当前版本只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。")
if end_type != "blind" and not manual_bottom_face_used:
blockers.append("孔深调整第一版只支持端部类型为 blind 的盲孔/盲槽。")
blockers.append("孔深调整当前版本只支持端部类型为 blind 的盲孔/盲槽。")
elif end_type != "blind" and manual_bottom_face_used:
risk = _max_risk(risk, "medium")
warnings.append("端部类型不是明确 blind,当前按手动底面 Face ID 推断孔深方向。")
@@ -951,9 +951,9 @@ def _cylinder_suppress_readiness(info: dict[str, object]) -> dict[str, object]:
diameter = float(info.get("diameter", 0.0))
if guess != "hole/groove candidate":
blockers.append("封堵圆柱孔第一版只支持孔候选,不支持圆角、凸柱或未明确圆柱面。")
blockers.append("封堵圆柱孔当前版本只支持孔候选,不支持圆角、凸柱或未明确圆柱面。")
if angular_span < math.tau * 0.92:
blockers.append("封堵圆柱孔第一版只支持接近完整圆柱的孔,不支持半孔/槽。")
blockers.append("封堵圆柱孔当前版本只支持接近完整圆柱的孔,不支持半孔/槽。")
if end_type == "closed/internal":
blockers.append("当前圆柱两端都像在材料内部,不像可封堵的外部孔。")
if height <= 1e-9 or diameter <= 1e-9:
@@ -998,14 +998,14 @@ def _edge_fillet_readiness(
adjacent_count = int(info.get("adjacent_face_count", 0))
if curve != "line":
blockers.append("添加圆角第一版只支持直线 edge。")
blockers.append("添加圆角当前版本只支持直线Edge。")
if length <= 1e-9:
blockers.append("当前 edge 长度无效。")
blockers.append("当前Edge长度无效。")
if adjacent_count < 2:
blockers.append("当前 edge 没有检测到至少两个相邻 face,不能可靠添加圆角。")
blockers.append("当前 Edge 没有检测到至少两个相邻 Face,不能可靠添加圆角。")
elif adjacent_count > 2:
risk = _max_risk(risk, "medium")
warnings.append(f"当前 edge 相邻 face 数为 {adjacent_count},可能是复杂交汇边。")
warnings.append(f"当前 Edge 相邻 Face 数为 {adjacent_count},可能是复杂交汇边。")
if radius is not None:
if radius <= 0:
@@ -1013,13 +1013,13 @@ def _edge_fillet_readiness(
elif length > 1e-9:
ratio = radius / length
if ratio >= 0.45:
blockers.append("圆角半径接近或超过 edge 长度的一半,第一版直接阻止。")
blockers.append("圆角半径接近或超过Edge长度的一半,当前版本会直接阻止。")
elif ratio > 0.25:
risk = _max_risk(risk, "high")
warnings.append("圆角半径超过 edge 长度的 25%,很容易导致倒圆失败。")
warnings.append("圆角半径超过Edge长度的 25%,很容易导致倒圆失败。")
elif ratio > 0.12:
risk = _max_risk(risk, "medium")
warnings.append("圆角半径相对 edge 长度偏大,请确认预览范围。")
warnings.append("圆角半径相对Edge长度偏大,请确认预览范围。")
if blockers:
status = "blocked"
@@ -1053,14 +1053,14 @@ def _edge_chamfer_readiness(
adjacent_count = int(info.get("adjacent_face_count", 0))
if curve != "line":
blockers.append("添加倒角第一版只支持直线 edge。")
blockers.append("添加倒角当前版本只支持直线Edge。")
if length <= 1e-9:
blockers.append("当前 edge 长度无效。")
blockers.append("当前Edge长度无效。")
if adjacent_count < 2:
blockers.append("当前 edge 没有检测到至少两个相邻 face,不能可靠添加倒角。")
blockers.append("当前 Edge 没有检测到至少两个相邻 Face,不能可靠添加倒角。")
elif adjacent_count > 2:
risk = _max_risk(risk, "medium")
warnings.append(f"当前 edge 相邻 face 数为 {adjacent_count},可能是复杂交汇边。")
warnings.append(f"当前 Edge 相邻 Face 数为 {adjacent_count},可能是复杂交汇边。")
if distance is not None:
if distance <= 0:
@@ -1068,13 +1068,13 @@ def _edge_chamfer_readiness(
elif length > 1e-9:
ratio = distance / length
if ratio >= 0.45:
blockers.append("倒角距离接近或超过 edge 长度的一半,第一版直接阻止。")
blockers.append("倒角距离接近或超过Edge长度的一半,当前版本会直接阻止。")
elif ratio > 0.25:
risk = _max_risk(risk, "high")
warnings.append("倒角距离超过 edge 长度的 25%,很容易导致倒角失败。")
warnings.append("倒角距离超过Edge长度的 25%,很容易导致倒角失败。")
elif ratio > 0.12:
risk = _max_risk(risk, "medium")
warnings.append("倒角距离相对 edge 长度偏大,请确认预览范围。")
warnings.append("倒角距离相对Edge长度偏大,请确认预览范围。")
if blockers:
status = "blocked"
+6
View File
@@ -27,6 +27,8 @@ class InfoPanelMixin:
self.info_text.setPlainText(self.current_info_text)
self._populate_info_tree(info)
self.info_tabs.setCurrentWidget(self.info_tree)
if hasattr(self, "export_all_button"):
self._update_action_states()
def set_plain_info(self, text: str) -> None:
self.current_info_values = {}
@@ -34,6 +36,8 @@ class InfoPanelMixin:
self.info_tree.clear()
self.info_text.setPlainText(text)
self.info_tabs.setCurrentWidget(self.info_text)
if hasattr(self, "export_all_button"):
self._update_action_states()
def _populate_info_tree(self, info: dict[str, object]) -> None:
self.info_tree.clear()
@@ -106,6 +110,7 @@ class InfoPanelMixin:
self.measure_label_b = source
self.statusBar().showMessage(f"已设置测量点 B: {_format_value(point)}")
self._refresh_measurement_panel()
self._update_action_states()
def clear_measurement(self) -> None:
self.measure_point_a = None
@@ -115,6 +120,7 @@ class InfoPanelMixin:
self._clear_measure_actor()
self._refresh_measurement_panel()
self.statusBar().showMessage("已清除测量")
self._update_action_states()
def copy_measurement(self) -> None:
text = self.measure_text.toPlainText() if hasattr(self, "measure_text") else ""
+12 -5
View File
@@ -374,8 +374,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"feature_face_ids": (face_id,),
"feature_highlight_face_ids": (face_id,),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_edit_actions": "当前第一版只能查看该局部曲面,暂不支持直接编辑。",
"feature_mode": "Feature 模式会把选中的 face 解释为局部几何特征候选。",
"feature_edit_actions": "当前版只能查看该局部曲面,暂不支持直接编辑。",
"feature_mode": "Feature 模式会把选中的Face解释为局部几何特征候选。",
}
)
return result
@@ -390,7 +390,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
scope_note = "当前 face 没有检测到可一起推拉的共面相接/重叠邻居。"
edit_actions = "推拉平面"
if shell_info.get("shell_region_status") == "candidate":
edit_actions += "查看薄壁/壳体厚度估算"
edit_actions += "调整薄壁/壳体厚度"
result = dict(info)
result.update(
{
@@ -493,6 +493,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
best = {
"shell_opposite_face_id": candidate_id,
"shell_thickness_estimate": thickness,
"shell_signed_thickness": _axis_parameter(plane.Location(), normal, candidate_plane.Location()),
"shell_overlap_ratio_estimate": overlap_ratio,
"shell_opposite_normal_dot": normal_dot,
}
@@ -557,7 +558,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
edit_actions += ";封堵圆柱孔"
elif guess == "round/fillet candidate":
feature_type = "圆角/倒圆候选"
edit_actions = "可尝试修改已有圆角半径;第一版会先移除圆角面,再在恢复出的锐边上重新倒圆。"
edit_actions = "可尝试修改已有圆角半径;当前版本会先移除圆角面,再在恢复出的锐边上重新倒圆。"
elif guess == "boss/outer-round candidate":
feature_type = "凸台/外圆候选"
edit_actions = "调整圆柱凸台直径。"
@@ -668,7 +669,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"feature_existing_fillet_support_face_ids": tuple(support_face_ids),
"existing_fillet_note": (
"这是由局部小半径圆柱面推断出的已有圆角/倒圆候选;"
"第一版可尝试使用 defeature + 重新倒圆修改半径;"
"当前版本可尝试使用 defeature + 重新倒圆修改半径;"
"复杂 blend 或支撑面不明确时可能失败并回滚。"
),
}
@@ -1302,6 +1303,12 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
info["axis"] = _dir_tuple(circle.Axis().Direction())
info["radius"] = circle.Radius()
info["diameter"] = circle.Radius() * 2.0
if curve_type == GeomAbs_Ellipse:
ellipse = curve.Ellipse()
info["center"] = _point_tuple(ellipse.Location())
info["axis"] = _dir_tuple(ellipse.Axis().Direction())
info["major_radius"] = ellipse.MajorRadius()
info["minor_radius"] = ellipse.MinorRadius()
adjacent_face_ids = self._edge_adjacent_face_ids(edge_id)
info["adjacent_face_ids"] = tuple(adjacent_face_ids)
info["adjacent_face_count"] = len(adjacent_face_ids)
+996 -138
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -7,6 +7,10 @@ from dataclasses import dataclass
class OperationRecord:
summary: str
detail: str
operation_name: str | None = None
target: str | None = None
parameters: dict[str, object] | None = None
result_message: str | None = None
target_kind: str | None = None
target_id: int | None = None
target_logical_id: int | None = None
+2 -2
View File
@@ -94,7 +94,7 @@ class TransformMixin:
risk = readiness["translate_risk"]
status = readiness["translate_status"]
if part_solid_count <= 1 and status != "blocked":
warnings = _join_nonempty(warnings, "当前 part 只有一个 solid,平移 solid 实际会移动整个 part shape。")
warnings = _join_nonempty(warnings, "当前Part只有一个Solid,平移Solid实际会移动整个Part shape。")
if risk == "low":
risk = "medium"
status = "caution"
@@ -192,7 +192,7 @@ class TransformMixin:
risk = readiness["rotate_risk"]
status = readiness["rotate_status"]
if part_solid_count <= 1 and status != "blocked":
warnings = _join_nonempty(warnings, "当前 part 只有一个 solid,旋转 solid 实际会旋转整个 part shape。")
warnings = _join_nonempty(warnings, "当前Part只有一个Solid,旋转Solid实际会旋转整个Part shape。")
if risk == "low":
risk = "medium"
status = "caution"
+58 -10
View File
@@ -65,6 +65,13 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [
"length_change_ratio",
"edge_length_anchor_mode",
"edge_length_anchor_label",
"affine_initial_scale",
"affine_scale",
"affine_scale_correction",
"affine_predicted_edge_length",
"affine_scale_refine_iterations",
"planar_edge_scale_sample_count",
"planar_edge_scale_plane_deviation",
"circular_edge_current_radius",
"circular_edge_target_radius",
"circular_edge_length_scale",
@@ -160,8 +167,15 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [
"shell_source_face_ids",
"shell_opposite_face_id",
"shell_thickness_estimate",
"shell_current_thickness",
"shell_target_thickness",
"shell_delta_thickness",
"shell_delta_ratio",
"shell_signed_thickness",
"shell_overlap_ratio_estimate",
"shell_opposite_normal_dot",
"shell_desired_movement_vector",
"shell_movement_alignment",
"shell_note",
"shell_region_note",
],
@@ -379,13 +393,21 @@ INFO_LABELS = {
"surface_area": "表面积",
"area": "面积",
"length": "长度",
"edge_length": "边长",
"current_length": "当前边长",
"target_length": "目标边长",
"delta_length": "边长变化量",
"length_change_ratio": "边长变化比例",
"edge_length_anchor_mode": "边长基准模式",
"edge_length_anchor_label": "边长基准",
"edge_length": "Edge长度",
"current_length": "当前Edge长度",
"target_length": "目标Edge长度",
"delta_length": "Edge长度变化量",
"length_change_ratio": "Edge长度变化比例",
"edge_length_anchor_mode": "Edge长度基准模式",
"edge_length_anchor_label": "Edge长度基准",
"local_edge_deform_target_kind": "局部边形变目标",
"local_edge_deform_anchor": "局部边形变基准",
"local_edge_deform_face_count": "局部边形变重建面数",
"local_edge_deform_start_move": "局部边形变起点移动",
"local_edge_deform_end_move": "局部边形变终点移动",
"local_edge_deform_moved_endpoint_count": "局部边形变移动端点数",
"local_edge_deform_risk": "局部边形变风险",
"local_edge_deform_note": "局部边形变说明",
"circular_edge_current_radius": "圆边当前半径",
"circular_edge_target_radius": "圆边目标半径",
"circular_edge_length_scale": "圆边长度比例",
@@ -400,10 +422,28 @@ INFO_LABELS = {
"cylinder_resize_delta_ratio": "圆柱直径变化比例",
"cylinder_resize_status": "圆柱编辑状态",
"cylinder_resize_risk": "圆柱编辑风险",
"cylinder_resize_feature_guess": "圆柱候选判断",
"cylinder_resize_feature_guess": "圆柱候选判断",
"cylinder_resize_confidence": "圆柱判断置信度",
"cylinder_resize_same_domain_face_ids": "圆柱同域 Face",
"cylinder_resize_same_domain_face_count": "圆柱同域 Face 数",
"affine_scale": "几何缩放比例",
"affine_initial_scale": "几何缩放初始比例",
"affine_scale_correction": "几何缩放校正比例",
"affine_predicted_edge_length": "几何缩放预估Edge长度",
"affine_scale_refine_iterations": "几何缩放校正次数",
"affine_scale_refine_note": "几何缩放校正说明",
"affine_transform_kind": "几何缩放类型",
"affine_transform_label": "几何缩放类型",
"affine_transform_note": "几何缩放说明",
"affine_transform_warning": "几何缩放警告",
"affine_transform_risk": "几何缩放风险",
"affine_axis_point": "几何缩放基准点",
"affine_axis_direction": "几何缩放轴线方向",
"affine_axis_source": "几何缩放轴线来源",
"affine_anchor_source": "几何缩放基准来源",
"affine_target_kind": "几何缩放目标",
"planar_edge_scale_sample_count": "平面曲线采样点数",
"planar_edge_scale_plane_deviation": "平面曲线采样偏差",
"radius": "半径",
"diameter": "直径",
"target_radius": "目标圆角半径",
@@ -476,11 +516,19 @@ INFO_LABELS = {
"shell_region_kind": "壳体/薄壁候选类型",
"shell_region_status": "壳体/薄壁识别状态",
"shell_confidence": "壳体/薄壁置信度",
"shell_thickness": "薄壁厚度",
"shell_source_face_ids": "壳体/薄壁源平面 Face",
"shell_opposite_face_id": "相对平面 Face",
"shell_thickness_estimate": "薄壁厚度估算",
"shell_current_thickness": "当前薄壁厚度",
"shell_target_thickness": "目标薄壁厚度",
"shell_delta_thickness": "薄壁厚度变化量",
"shell_delta_ratio": "薄壁厚度变化比例",
"shell_signed_thickness": "有符号薄壁厚度",
"shell_overlap_ratio_estimate": "相对平面重叠率估算",
"shell_opposite_normal_dot": "相对平面法向点积",
"shell_desired_movement_vector": "薄壁目标移动向量",
"shell_movement_alignment": "薄壁移动方向匹配度",
"shell_note": "壳体/薄壁识别说明",
"shell_region_note": "壳体/薄壁识别说明",
"u_range": "U 参数范围",
@@ -730,10 +778,10 @@ def _edit_quality_warnings(before_part_stats, after_part_stats) -> list[str]:
return []
warnings: list[str] = []
if after_part_stats.solids == 0:
warnings.append("目标零件编辑后没有检测到 solid,导出前请确认模型是否有效。")
warnings.append("目标零件编辑后没有检测到Solid,导出前请确认模型是否有效。")
elif before_part_stats.solids > 0 and after_part_stats.solids != before_part_stats.solids:
warnings.append(
"目标零件 solid 数发生变化:"
"目标零件Solid数发生变化:"
f"{before_part_stats.solids} -> {after_part_stats.solids}"
"如果这是一次局部推拉/孔径修改,请重点检查导出后是否仍是一体实体。"
)
+4 -1
View File
@@ -5,4 +5,7 @@ from PySide6.QtWidgets import QComboBox
class NoWheelComboBox(QComboBox):
def wheelEvent(self, event) -> None:
event.accept()
if self.view().isVisible():
super().wheelEvent(event)
return
event.ignore()
File diff suppressed because it is too large Load Diff
+157 -112
View File
@@ -54,7 +54,7 @@ class WindowCoreMixin:
self.interactor.AddObserver("MouseMoveEvent", self.on_mouse_move)
light = vtk.vtkLight()
light.SetLightTypeToSceneLight()
light.SetLightTypeToHeadlight()
light.SetPosition(1, 1, 1)
light.SetIntensity(0.9)
self.renderer.AddLight(light)
@@ -62,20 +62,30 @@ class WindowCoreMixin:
self.interactor.Initialize()
def closeEvent(self, event) -> None:
if self.load_in_progress:
load_thread_running = bool(
(self.load_thread is not None and self.load_thread.isRunning())
or (self.load_refine_thread is not None and self.load_refine_thread.isRunning())
)
if self.load_in_progress or load_thread_running:
self.statusBar().showMessage("STEP background loading is still running.")
event.ignore()
return
if self.operation_in_progress:
edit_thread_running = bool(self.edit_thread is not None and self.edit_thread.isRunning())
if self.operation_in_progress or edit_thread_running:
self.statusBar().showMessage("后台编辑正在计算,请等待当前操作完成后再关闭窗口。")
event.ignore()
return
if self.scan_in_progress:
self.statusBar().showMessage("后台扫描正在进行,请等待扫描完成后再关闭窗口。")
scan_thread_running = bool(self.scan_thread is not None and self.scan_thread.isRunning())
if self.scan_in_progress or scan_thread_running:
self.statusBar().showMessage("扫描正在进行,请等待扫描完成后再关闭窗口。")
event.ignore()
return
super().closeEvent(event)
def _request_thread_quit(self, thread: QThread | None) -> None:
if thread is not None and thread.isRunning():
thread.quit()
def load_step(self, path: str | Path, *, background: bool = True) -> None:
self._load_step_background_or_sync(path, background=background)
return
@@ -110,53 +120,62 @@ class WindowCoreMixin:
return
new_path = Path(path)
if not background:
self.statusBar().showMessage(f"Loading {new_path.name}...")
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
QApplication.processEvents()
try:
result = self._load_step_result(
new_path,
deflection=0.8,
show_internal_edges=self._show_same_domain_internal_edges(),
)
except Exception as exc:
QMessageBox.critical(self, "Load failed", str(exc))
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
return
finally:
QApplication.restoreOverrideCursor()
self._apply_loaded_model_result(result, reset_camera=True)
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
self._load_step_sync(
new_path,
deflection=0.8,
show_internal_edges=self._show_same_domain_internal_edges(),
status_prefix="Loading",
)
return
self.load_in_progress = True
self.pending_load_path = new_path
self.statusBar().showMessage(f"Loading {new_path.name} in background...")
self.statusBar().showMessage(f"Preparing fast load for {new_path.name}...")
self._clear_hover(render=True)
self._update_action_states()
QTimer.singleShot(0, lambda path=new_path: self._run_deferred_initial_load(path))
def action():
return self._load_step_result(
def _load_step_sync(
self,
new_path: Path,
*,
deflection: float,
show_internal_edges: bool,
status_prefix: str,
) -> bool:
self.statusBar().showMessage(f"{status_prefix} {new_path.name}...")
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
QApplication.processEvents()
try:
result = self._load_step_result(
new_path,
deflection=deflection,
show_internal_edges=show_internal_edges,
)
result["display"] = "ready"
except Exception as exc:
QMessageBox.critical(self, "Load failed", str(exc))
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
return False
finally:
QApplication.restoreOverrideCursor()
self._apply_loaded_model_result(result, reset_camera=True)
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
return True
@Slot(object)
def _run_deferred_initial_load(self, expected_path: Path) -> None:
if self.pending_load_path != expected_path:
return
try:
self._load_step_sync(
expected_path,
deflection=self.initial_load_deflection,
show_internal_edges=True,
build_polydata=False,
status_prefix="Fast loading",
)
thread = QThread(self)
worker = LoadWorker(action)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._finish_initial_load)
worker.failed.connect(self._fail_initial_load)
worker.finished.connect(thread.quit)
worker.failed.connect(thread.quit)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_load_thread)
self.load_thread = thread
self.load_worker = worker
thread.start()
finally:
self._end_load_task()
def _apply_loaded_model_result(self, result: dict[str, object], *, reset_camera: bool) -> None:
new_path = Path(result["path"])
@@ -194,11 +213,12 @@ class WindowCoreMixin:
"faces": stats.faces,
"edges": stats.edges,
"vertices": stats.vertices,
"display": "quick preview" if self.load_in_progress else "ready",
"display": result.get("display", "quick preview" if self.load_in_progress else "ready"),
}
)
self._update_action_states()
@Slot(object)
def _finish_initial_load(self, result: object) -> None:
try:
if not isinstance(result, dict):
@@ -210,49 +230,21 @@ class WindowCoreMixin:
self._end_load_task()
QMessageBox.critical(self, "Load failed", str(exc))
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
finally:
self._request_thread_quit(self.load_thread)
@Slot(str)
def _fail_initial_load(self, message: str) -> None:
self._end_load_task()
QMessageBox.critical(self, "Load failed", message)
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
def _start_load_refine(self, initial_result: dict[str, object]) -> None:
model = initial_result.get("model")
if model is None or model is not self.model:
self._end_load_task()
return
path = Path(initial_result["path"])
stats = initial_result["stats"]
show_internal_edges = self._show_same_domain_internal_edges()
def action():
return {
"path": path,
"model": model,
"stats": stats,
"model_polydata": model.build_face_polydata(deflection=0.8),
"edge_polydata": model.build_edge_polydata(
deflection=0.8,
show_same_domain_internal_edges=show_internal_edges,
),
"show_internal_edges": show_internal_edges,
}
thread = QThread(self)
worker = LoadWorker(action)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._finish_load_refine)
worker.failed.connect(self._fail_load_refine)
worker.finished.connect(thread.quit)
worker.failed.connect(thread.quit)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_load_refine_thread)
self.load_refine_thread = thread
self.load_refine_worker = worker
thread.start()
self._end_load_task()
if self.model is not None:
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
@Slot(object)
def _finish_load_refine(self, result: object) -> None:
try:
if isinstance(result, dict) and result.get("model") is self.model:
@@ -277,6 +269,7 @@ class WindowCoreMixin:
finally:
self._end_load_task()
@Slot(str)
def _fail_load_refine(self, message: str) -> None:
self._end_load_task()
self.statusBar().showMessage(f"Quick preview is available; display refinement failed: {message}")
@@ -285,6 +278,8 @@ class WindowCoreMixin:
self.load_in_progress = False
self.pending_load_path = None
self._update_action_states()
self._request_thread_quit(self.load_thread)
self._request_thread_quit(self.load_refine_thread)
def _forget_load_thread(self) -> None:
self.load_thread = None
@@ -319,6 +314,8 @@ class WindowCoreMixin:
self.clear_diff_preview(render=False)
if hasattr(self, "history_list"):
self.history_list.clear()
if hasattr(self, "export_all_button"):
self._update_action_states()
def _refresh_history_list(self) -> None:
was_blocked = self.history_list.blockSignals(True)
@@ -328,6 +325,7 @@ class WindowCoreMixin:
self.history_list.addItem(f"{index}. {entry.summary}")
finally:
self.history_list.blockSignals(was_blocked)
self._update_action_states()
def on_history_row_changed(self, row: int) -> None:
if self._edit_busy("编辑计算中,暂时不能查看历史记录详情。"):
@@ -407,6 +405,10 @@ class WindowCoreMixin:
"index": index,
"summary": record.summary,
"detail": record.detail,
"operation_name": record.operation_name,
"target": record.target,
"parameters": safe_value(record.parameters or {}),
"result_message": record.result_message,
"target_kind": record.target_kind,
"target_id": record.target_id,
"target_logical_id": record.target_logical_id,
@@ -417,7 +419,7 @@ class WindowCoreMixin:
}
)
payload = {
"format": "step-editor-operation-history-v1",
"format": "step-editor-operation-history-v2",
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"source_file": str(self.step_path),
"record_count": len(records),
@@ -558,27 +560,27 @@ class WindowCoreMixin:
if self.selected_kind == "part" and self.selected_part_id is not None:
part_ids = [self.selected_part_id]
label = f"part {self.selected_part_id}"
label = f"Part {self.selected_part_id}"
elif self.selected_kind == "solid" and self.selected_solid_id is not None:
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == self.selected_solid_id]
edge_ids = self.model.edge_ids_for_solid(self.selected_solid_id)
label = f"solid {self.selected_solid_id}"
label = f"Solid {self.selected_solid_id}"
elif self.selected_kind == "feature" and self.selected_face_id is not None:
info = self.model.feature_info(self.selected_face_id)
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id]
edge_ids = _int_values(info.get("feature_boundary_edge_ids"))
label = f"feature face {self.selected_face_id}"
label = f"Feature Face {self.selected_face_id}"
elif self.selected_kind == "face" and self.selected_face_id is not None:
face_ids = self.model.connected_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id]
edge_ids = self.model.face_region_boundary_edge_ids(self.selected_face_id)
label = f"face {self.selected_face_id}"
label = f"Face {self.selected_face_id}"
elif self.selected_kind == "edge" and self.selected_edge_id is not None:
info = self.model.edge_info(self.selected_edge_id)
face_ids = _int_values(info.get("adjacent_face_ids"))
edge_ids = [self.selected_edge_id]
label = f"edge {self.selected_edge_id}"
label = f"Edge {self.selected_edge_id}"
else:
QMessageBox.information(self, "未选择对象", "请先选择 part、solid、face、edge 或 feature。")
QMessageBox.information(self, "未选择对象", "请先选择 Part、Solid、Face、Edge 或 Feature。")
return
model_polydata = self.model.build_face_polydata(face_ids=face_ids, part_ids=part_ids)
@@ -608,7 +610,7 @@ class WindowCoreMixin:
return
bounds = self._selected_focus_bounds()
if bounds is None:
QMessageBox.information(self, "未选择对象", "请先选择 part、solid、face、edge 或 feature。")
QMessageBox.information(self, "未选择对象", "请先选择 Part、Solid、Face、Edge 或 Feature。")
return
self._fit_camera_to_bounds(bounds)
self.render_window.Render()
@@ -772,6 +774,11 @@ class WindowCoreMixin:
self.model_actor.GetProperty().SetSpecular(0.25)
self.model_actor.GetProperty().SetSpecularPower(18)
self.model_actor.GetProperty().SetInterpolationToPhong()
backface_property = vtk.vtkProperty()
backface_property.SetColor(0.58, 0.62, 0.64)
backface_property.SetDiffuse(0.9)
backface_property.SetAmbient(0.28)
self.model_actor.SetBackfaceProperty(backface_property)
self.renderer.AddActor(self.model_actor)
self.edge_polydata = edge_polydata
@@ -816,7 +823,7 @@ class WindowCoreMixin:
self.statusBar().showMessage("编辑计算中,请等待当前操作完成")
return
if self.scan_in_progress:
self.statusBar().showMessage("后台扫描中,请等待扫描完成后再选择对象。")
self.statusBar().showMessage("扫描中,请等待扫描完成后再选择对象。")
return
if self.model is None:
return
@@ -876,7 +883,16 @@ class WindowCoreMixin:
self._queue_hover_position(x, y)
def _queue_hover_position(self, x: int, y: int) -> None:
self.pending_hover_position = (int(x), int(y))
position = (int(x), int(y))
threshold = int(getattr(self, "hover_move_threshold_px", 0) or 0)
if threshold > 0:
previous = self.pending_hover_position or self.last_hover_pick_position
if previous is not None:
dx = abs(position[0] - int(previous[0]))
dy = abs(position[1] - int(previous[1]))
if max(dx, dy) < threshold:
return
self.pending_hover_position = position
if not self.hover_timer.isActive():
self.hover_timer.start(self.hover_interval_ms)
@@ -1102,7 +1118,7 @@ class WindowCoreMixin:
self.select_part(int(target_id))
def on_cylinder_row_clicked(self, row: int, _column: int) -> None:
if self._edit_busy("编辑计算中,暂时不能切换圆柱候选。"):
if self._edit_busy("编辑计算中,暂时不能切换圆柱候选。"):
return
item = self.cylinder_table.item(row, 0)
if item is None:
@@ -1127,47 +1143,51 @@ class WindowCoreMixin:
if action == "resize_cylinder":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整孔径候选 face {target_id}")
self.statusBar().showMessage(f"已选择可调整孔径候选Face {target_id}")
elif action == "resize_boss":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整凸台直径候选 face {target_id}")
self.statusBar().showMessage(f"已选择可调整凸台直径候选Face {target_id}")
elif action == "resize_slot_width":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整槽/半孔宽度候选 face {target_id}")
self.statusBar().showMessage(f"已选择可调整槽/半孔宽度候选Face {target_id}")
elif action == "resize_shell_thickness":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整薄壁厚度候选Face {target_id}")
elif action == "suppress_cylinder":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可封堵圆柱孔 face {target_id}")
self.statusBar().showMessage(f"已选择可封堵圆柱孔Face {target_id}")
elif action == "resize_depth":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整盲孔深度候选 face {target_id}")
self.statusBar().showMessage(f"已选择可调整盲孔深度候选Face {target_id}")
elif action == "inspect_existing_fillet":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择已有圆角/倒圆候选 face {target_id}")
self.statusBar().showMessage(f"已选择已有圆角/倒圆候选Face {target_id}")
elif action == "fillet_edge":
self.mode_combo.setCurrentText("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可添加圆角 edge {target_id}")
self.statusBar().showMessage(f"已选择可添加圆角Edge {target_id}")
elif action == "chamfer_edge":
self.mode_combo.setCurrentText("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可添加倒角 edge {target_id}")
self.statusBar().showMessage(f"已选择可添加倒角Edge {target_id}")
elif action == "resize_edge_length":
self.mode_combo.setCurrentText("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可尝试调整长度的直线 edge {target_id}")
self.statusBar().showMessage(f"已选择可尝试调整长度的Edge {target_id}")
elif target_kind == "edge":
self.mode_combo.setCurrentText("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择 edge {target_id}")
self.statusBar().showMessage(f"已选择Edge {target_id}")
else:
self.mode_combo.setCurrentText("Face")
self.select_face(int(target_id))
self.statusBar().showMessage(f"已选择可推拉平面 face {target_id}")
self.statusBar().showMessage(f"已选择可推拉平面Face {target_id}")
def select_by_id(self, kind: str | None = None) -> None:
if self.model is None:
@@ -1240,7 +1260,7 @@ class WindowCoreMixin:
) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能选择 solid。"):
if self._edit_busy("编辑计算中,暂时不能选择Solid。"):
return
info = self.model.solid_info(solid_id)
self._reset_selection(clear_highlight=False)
@@ -1254,7 +1274,7 @@ class WindowCoreMixin:
self._sync_id_picker("Solid", solid_id)
self.set_info(self._with_pick_info(info, pick_position))
self._update_action_states()
self.statusBar().showMessage(self._selection_status(f"已选择 solid {solid_id}", pick_position))
self.statusBar().showMessage(self._selection_status(f"已选择Solid {solid_id}", pick_position))
def select_face(
self,
@@ -1264,7 +1284,7 @@ class WindowCoreMixin:
) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能选择 face。"):
if self._edit_busy("编辑计算中,暂时不能选择Face。"):
return
self._reset_selection(clear_highlight=False)
self.selected_kind = "feature" if feature_mode else "face"
@@ -1279,9 +1299,10 @@ class WindowCoreMixin:
if len(highlight_face_ids) > 1:
info["same_domain_face_ids"] = tuple(highlight_face_ids)
info["same_domain_face_count"] = len(highlight_face_ids)
info["same_domain_note"] = "已高亮属于同一几何面且范围相接/重叠的连续 face 区域。"
info["same_domain_note"] = "已高亮属于同一几何面且范围相接/重叠的连续Face区域。"
logical_id = self.model.face_region_logical_id(face_id)
self._sync_cylindrical_edit_inputs(info)
input_info = self.model.feature_info(face_id) if info.get("surface") == "plane" else info
self._sync_cylindrical_edit_inputs(input_info)
self.selected_part_id = int(info["part_id"])
self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None
self._highlight_faces(face_ids=highlight_face_ids or [face_id])
@@ -1289,9 +1310,9 @@ class WindowCoreMixin:
self._sync_id_picker("Feature" if feature_mode else "Face", face_id if feature_mode else logical_id)
self.set_info(self._with_pick_info(info, pick_position))
self._update_action_states()
suffix = f",同域区域 {len(highlight_face_ids)} face" if not feature_mode and len(highlight_face_ids) > 1 else ""
raw_note = f"(拓扑 face {face_id}" if not feature_mode and logical_id != face_id else ""
message = f"已选择逻辑面区域 {logical_id}{raw_note}{suffix}" if not feature_mode else f"已选择 face {face_id}"
suffix = f",同域区域 {len(highlight_face_ids)}Face" if not feature_mode and len(highlight_face_ids) > 1 else ""
raw_note = f"(拓扑Face {face_id}" if not feature_mode and logical_id != face_id else ""
message = f"已选择逻辑面区域 {logical_id}{raw_note}{suffix}" if not feature_mode else f"已选择Face {face_id}"
self.statusBar().showMessage(self._selection_status(message, pick_position))
def select_feature(self, face_id: int, pick_position: tuple[float, float, float] | None = None) -> None:
@@ -1314,10 +1335,16 @@ class WindowCoreMixin:
self.set_info(self._with_pick_info(info, pick_position))
feature_type = str(info.get("feature_type", "局部特征候选"))
self._update_action_states()
self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 face {face_id}", pick_position))
self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源Face {face_id}", pick_position))
def _sync_cylindrical_edit_inputs(self, info: dict[str, object]) -> None:
fillet_radius_suggestion: str | None = None
shell_thickness = _float_or_none(info.get("shell_thickness_estimate"))
if hasattr(self, "shell_thickness_input"):
if info.get("shell_region_status") == "candidate" and shell_thickness is not None and shell_thickness > 0:
self.shell_thickness_input.setText(_format_float(shell_thickness * 1.2))
else:
self.shell_thickness_input.clear()
if "diameter" in info:
feature_guess = str(info.get("feature_guess", ""))
if feature_guess == "boss/outer-round candidate":
@@ -1373,6 +1400,8 @@ class WindowCoreMixin:
def _sync_edge_edit_inputs(self, info: dict[str, object]) -> None:
self.hole_diameter_input.clear()
if hasattr(self, "shell_thickness_input"):
self.shell_thickness_input.clear()
if hasattr(self, "slot_width_input"):
self.slot_width_input.clear()
self.boss_diameter_input.clear()
@@ -1395,7 +1424,7 @@ class WindowCoreMixin:
def select_edge(self, edge_id: int, pick_position: tuple[float, float, float] | None = None) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能选择 edge。"):
if self._edit_busy("编辑计算中,暂时不能选择Edge。"):
return
self._reset_selection(clear_highlight=False)
self.selected_kind = "edge"
@@ -1410,7 +1439,7 @@ class WindowCoreMixin:
self._sync_id_picker("Edge", edge_id)
self.set_info(self._with_pick_info(info, pick_position))
self._update_action_states()
self.statusBar().showMessage(self._selection_status(f"已选择 edge {edge_id}", pick_position))
self.statusBar().showMessage(self._selection_status(f"已选择Edge {edge_id}", pick_position))
def _highlight_faces(self, face_ids=None, part_ids=None) -> None:
if self.model is None:
@@ -1635,6 +1664,19 @@ class WindowCoreMixin:
self._start_edit_preview_pulse()
self.render_window.Render()
def _show_shell_thickness_preview(self, face_id: int, target_thickness: float) -> None:
if self.model is None:
return
self.clear_edit_preview(render=False)
try:
polydata = self.model.shell_thickness_preview_polydata(face_id, target_thickness)
except Exception as exc:
self.statusBar().showMessage(f"薄壁厚度调整预览不可用:{exc}")
return
self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34)
self._start_edit_preview_pulse()
self.render_window.Render()
def _show_cylinder_resize_preview(self, face_id: int, diameter: float) -> None:
if self.model is None:
return
@@ -1740,7 +1782,7 @@ class WindowCoreMixin:
try:
polydata = self.model.straight_edge_length_preview_polydata(edge_id, target_length, anchor_mode=anchor_mode)
except Exception as exc:
self.statusBar().showMessage(f"边长直接修改预览不可用:{exc}")
self.statusBar().showMessage(f"Edge长度修改预览不可用:{exc}")
return
if isinstance(polydata, list):
for preview in polydata:
@@ -1771,7 +1813,7 @@ class WindowCoreMixin:
ids.InsertNextId(cell_id)
break
if ids.GetNumberOfIds() == 0:
self.statusBar().showMessage(f"{label}不可用:没有找到选中的 edge。")
self.statusBar().showMessage(f"{label}不可用:没有找到选中的Edge。")
return
extract = vtk.vtkExtractCells()
extract.SetInputData(polydata)
@@ -1817,6 +1859,8 @@ class WindowCoreMixin:
if render and hasattr(self, "render_window"):
self.render_window.Render()
self.statusBar().showMessage("已清除差异预览")
if hasattr(self, "export_all_button"):
self._update_action_states()
def _show_operation_diff(self, record: OperationRecord) -> str:
if self.model is None:
@@ -1838,6 +1882,7 @@ class WindowCoreMixin:
for actor in self.diff_actors:
self.renderer.AddViewProp(actor)
self.render_window.Render()
self._update_action_states()
if heatmap_stats:
record.diff_stats = heatmap_stats
return (
+384 -61
View File
@@ -87,7 +87,7 @@ class WindowStateMixin:
self.statusBar().showMessage(message)
return True
if self.scan_in_progress:
self.statusBar().showMessage("后台扫描中,请等待扫描完成后再执行该操作。")
self.statusBar().showMessage("扫描中,请等待扫描完成后再执行该操作。")
return True
return False
@@ -98,33 +98,229 @@ class WindowStateMixin:
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
)
has_loaded_model = self.model is not None
busy = self.operation_in_progress or self.scan_in_progress or self.load_in_progress
has_model = has_loaded_model and not busy
selected_kind = self.selected_kind
self.export_all_button.setEnabled(has_model)
self.export_check_button.setEnabled(has_model)
has_selection = any(
item is not None
for item in (
self.selected_part_id,
self.selected_solid_id,
self.selected_face_id,
self.selected_edge_id,
)
)
has_part = self.selected_part_id is not None
has_solid = self.selected_solid_id is not None
has_face = self.selected_face_id is not None and selected_kind in {"face", "feature"}
has_edge = self.selected_edge_id is not None and selected_kind == "edge"
id_text = self.id_input.text().strip() if hasattr(self, "id_input") else ""
history_row = self.history_list.currentRow() if hasattr(self, "history_list") else -1
has_history = has_model and bool(getattr(self, "operation_history", []))
has_history_selection = has_history and 0 <= history_row < len(self.operation_history)
has_diff_preview = has_model and bool(getattr(self, "diff_actors", []))
has_measure_points = bool(getattr(self, "measure_point_a", None) or getattr(self, "measure_point_b", None))
has_measure_result = bool(getattr(self, "measure_point_a", None) and getattr(self, "measure_point_b", None))
pick_value = getattr(self, "current_info_values", {}).get("pick_position")
has_pick_position = self.selected_pick_position is not None or (
isinstance(pick_value, (tuple, list)) and len(pick_value) == 3
)
selected_id_text = self._selected_id_text() if hasattr(self, "_selected_id_text") else ""
has_current_info = bool(getattr(self, "current_info_text", ""))
wait_or_load_tip = "请先加载 STEP 文件,或等待当前后台任务完成。"
if hasattr(self, "open_button"):
self._set_control_state(
self.open_button,
not busy,
"选择并打开一个 .step 或 .stp 文件。",
"请等待当前后台任务完成后再打开其他 STEP 文件。",
)
self._set_control_state(
self.reload_button,
has_loaded_model and not busy,
"从磁盘重新读取当前 STEP 文件。",
wait_or_load_tip,
)
if hasattr(self, "part_tree"):
self._set_control_state(
self.part_tree,
has_model,
"从结构树中选择 Part、Solid 或装配节点。",
wait_or_load_tip,
)
if hasattr(self, "mode_combo"):
self._set_control_state(
self.mode_combo,
not busy,
"选择鼠标点击模型时要选什么对象。",
"请等待当前后台任务完成后再切换鼠标选择模式。",
)
if hasattr(self, "id_input"):
self._set_control_state(
self.id_input,
has_model,
"输入要选中的对象 ID。",
wait_or_load_tip,
)
self._set_control_state(
self.select_id_button,
has_model and bool(id_text),
"按当前选择模式跳转到输入的 ID。",
"请先加载模型并输入整数 ID。",
)
self._set_control_state(
self.export_all_button,
has_model,
"导出当前完整 STEP 模型。",
wait_or_load_tip,
)
self._set_control_state(
self.export_check_button,
has_model,
"检查当前导出对象的 B-Rep、数量、体积和包围盒等质量信息。",
wait_or_load_tip,
)
if hasattr(self, "repair_model_button"):
self._set_control_state(
self.repair_model_button,
has_model,
"对当前完整模型执行 ShapeFix 和同域面/边合并,并写入撤销历史。",
"请先加载 STEP 文件,或等待当前后台任务完成。",
wait_or_load_tip,
)
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,或等待当前后台任务完成。",
has_model and (has_solid or has_part),
"优先修复当前选中对象所属Solid;没有Solid时修复所属零件,并写入撤销历史。",
"请先选择 Part、Solid、Face 或 Edge,或等待当前后台任务完成。",
)
self._set_control_state(
self.export_part_button,
has_model and has_part,
"导出当前选中对象所属零件。",
"请先选择 Part,或选择属于某个 Part 的 Solid、Face 或 Edge。",
)
self._set_control_state(
self.export_solid_button,
has_model and has_solid,
"导出当前选中对象所属 Solid。",
"请先选择 Solid,或选择属于某个 Solid 的 Face 或 Edge。",
)
self._set_control_state(
self.export_face_button,
has_model and has_face,
"导出当前选中的 Face 或同域面区域。",
"请先在 Face 或 Feature 模式下选择一个 Face。",
)
self._set_control_state(
self.export_feature_button,
has_model and selected_kind == "feature" and self.selected_face_id is not None,
"导出 Feature 模式识别到的局部特征区域。",
"请先切换到 Feature 模式并选择孔、槽、圆角或凸台候选。",
)
self._set_control_state(
self.export_edge_button,
has_model and has_edge,
"导出当前选中的 Edge。",
"请先切换到 Edge 模式并选择一条 Edge。",
)
if hasattr(self, "isolate_button"):
self._set_control_state(
self.isolate_button,
has_model and has_selection,
"只显示当前选中的对象或区域。",
"请先加载模型并选择 Part、Solid、Face、Edge 或 Feature。",
)
self._set_control_state(
self.fit_button,
has_model and has_selection,
"把相机对准当前选中对象。",
"请先加载模型并选择一个对象。",
)
self._set_control_state(
self.show_all_button,
has_model,
"恢复显示完整模型。",
wait_or_load_tip,
)
self._set_control_state(
self.show_internal_edges_checkbox,
has_model,
"显示或隐藏同域内部拓扑边。",
wait_or_load_tip,
)
if hasattr(self, "set_measure_a_button"):
measure_point_tip = "把当前选中对象的拾取点或中心设为测量点。"
self._set_control_state(
self.set_measure_a_button,
has_model and has_selection,
measure_point_tip,
"请先加载模型并选择一个对象。",
)
self._set_control_state(
self.set_measure_b_button,
has_model and has_selection,
measure_point_tip,
"请先加载模型并选择一个对象。",
)
self._set_control_state(
self.copy_measure_button,
has_measure_result,
"复制 A/B 两点距离和 X/Y/Z 差值。",
"请先设置测量点 A 和 B。",
)
self._set_control_state(
self.clear_measure_button,
has_measure_points,
"清除 A/B 测量点和 3D 测量线。",
"当前没有可清除的测量点。",
)
if hasattr(self, "history_list"):
self._set_control_state(
self.history_list,
has_history,
"点击历史记录可查看参数、定位目标并显示差异预览。",
"当前还没有编辑历史。",
)
self._set_control_state(
self.clear_diff_button,
has_diff_preview,
"清除当前显示的差异预览。",
"当前没有正在显示的差异预览。",
)
self._set_control_state(
self.export_diff_button,
has_history_selection,
"导出当前选中历史记录的差异报告。",
"请先在操作历史中选择一条记录。",
)
self._set_control_state(
self.export_history_button,
has_history,
"导出本次会话的完整编辑历史 JSON。",
"当前还没有可导出的编辑历史。",
)
if hasattr(self, "copy_id_button"):
self._set_control_state(
self.copy_id_button,
bool(selected_id_text),
"复制当前选中对象的 ID。",
"请先选择一个对象。",
)
self._set_control_state(
self.copy_pick_button,
has_pick_position,
"复制最近一次鼠标拾取到的三维坐标。",
"当前没有可复制的拾取坐标。",
)
self._set_control_state(
self.copy_info_button,
has_current_info,
"复制当前属性区里的完整文本。",
"当前属性区还没有可复制的信息。",
)
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:
@@ -139,6 +335,7 @@ class WindowStateMixin:
has_face = self.selected_face_id is not None and self.selected_kind in {"face", "feature"}
has_edge = self.selected_edge_id is not None and self.selected_kind == "edge"
is_plane = has_face and surface == "plane"
is_shell_candidate = is_plane and action_info.get("shell_region_status") == "candidate"
is_cylinder = has_face and surface == "cylinder" and "diameter" in action_info
is_hole_or_groove = is_cylinder and feature_guess == "hole/groove candidate"
is_boss = is_cylinder and feature_guess == "boss/outer-round candidate"
@@ -158,111 +355,237 @@ class WindowStateMixin:
)
has_fillet_support = len(_int_values(action_info.get("feature_existing_fillet_support_face_ids"))) >= 2
is_line_edge = has_edge and curve == "line"
can_push = has_model and is_plane
can_resize_shell = has_model and is_shell_candidate
can_resize_hole = has_model and is_hole_or_groove
can_resize_slot = has_model and is_slot_or_half_hole
can_resize_boss = has_model and is_boss and is_full_cylinder
can_suppress_hole = has_model and is_hole_or_groove and is_full_cylinder
can_resize_depth = has_model and is_hole_or_groove and ((is_blind and has_bottom) or has_manual_bottom)
can_fillet_edge = has_model and is_line_edge
can_resize_existing_fillet = has_model and is_existing_fillet and has_fillet_support
can_chamfer_edge = has_model and is_line_edge
can_resize_edge_length = has_model and has_edge
can_transform_part = has_model and self.selected_part_id is not None
can_transform_solid = has_model and self.selected_solid_id is not None
self._set_control_state(
self.offset_input,
can_push,
"输入平面 Face 的推拉距离。",
"请先选择一个可推拉的平面 Face。",
)
self._set_control_state(
self.push_button,
has_model and is_plane,
"对当前平面 face 执行推拉。",
"请先选择一个平面 face,或在 Feature 模式下选择可推拉平面候选。",
can_push,
"对当前平面 Face 执行推拉。",
"请先选择一个平面 Face,或在 Feature 模式下选择可推拉平面候选。",
)
if hasattr(self, "resize_shell_thickness_button"):
self._set_control_state(
self.shell_thickness_input,
can_resize_shell,
"输入薄壁/壳体局部区域的目标厚度。",
"请先选择一个已识别到相对平面的薄壁/壳体平面候选。",
)
self._set_control_state(
self.resize_shell_thickness_button,
can_resize_shell,
"按目标厚度推拉当前薄壁/壳体平面区域。",
"请先选择一个已识别到相对平面的薄壁/壳体平面候选。",
)
self._set_control_state(
self.hole_diameter_input,
can_resize_hole,
"输入圆柱孔/槽的目标直径。",
"请先选择被识别为孔/槽候选的圆柱 Face。",
)
self._set_control_state(
self.resize_button,
has_model and is_hole_or_groove,
can_resize_hole,
"调整当前圆柱孔/槽候选的直径。",
"请先选择被识别为孔/槽候选的圆柱 face。",
"请先选择被识别为孔/槽候选的圆柱 Face。",
)
if hasattr(self, "resize_slot_button"):
self._set_control_state(
self.slot_width_input,
can_resize_slot,
"输入槽/半孔候选的目标宽度。",
"请先选择被识别为槽/半孔的部分圆柱 Face。",
)
self._set_control_state(
self.resize_slot_button,
has_model and is_slot_or_half_hole,
"按目标槽宽调整当前槽/半孔候选;第一版会换算为对应圆柱直径后执行。",
"请先选择被识别为槽/半孔的部分圆柱 face。",
can_resize_slot,
"按目标槽宽调整当前槽/半孔候选;当前版本会换算为对应圆柱直径后执行。",
"请先选择被识别为槽/半孔的部分圆柱 Face。",
)
self._set_control_state(
self.boss_diameter_input,
can_resize_boss,
"输入完整圆柱凸台候选的目标直径。",
"请先选择被识别为完整凸台/外圆候选的圆柱 Face。",
)
self._set_control_state(
self.resize_boss_button,
has_model and is_boss and is_full_cylinder,
can_resize_boss,
"调整当前完整圆柱凸台候选的直径。",
"请先选择被识别为完整凸台/外圆候选的圆柱 face。",
"请先选择被识别为完整凸台/外圆候选的圆柱 Face。",
)
self._set_control_state(
self.suppress_button,
has_model and is_hole_or_groove and is_full_cylinder,
can_suppress_hole,
"封堵当前完整圆柱孔候选。",
"请先选择接近完整圆柱的孔候选;半孔/槽不会放行。",
)
self._set_control_state(
self.hole_depth_input,
can_resize_hole,
"输入盲孔/盲槽的目标深度。",
"请先选择孔/槽圆柱面。",
)
self._set_control_state(
self.hole_bottom_face_input,
can_resize_hole,
"必要时输入底面 Face ID,用来明确盲孔/盲槽深度。",
"请先选择孔/槽圆柱面。",
)
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),
can_resize_depth,
"调整当前盲孔/盲槽深度;自动底面不稳定时可手动填写底面 Face ID。",
"请先选择孔/槽圆柱面;如果没有自动识别到底面,请填写底面 Face ID。",
)
self._set_control_state(
self.edge_fillet_radius_input,
can_fillet_edge or can_resize_existing_fillet,
"输入新圆角或已有圆角的目标半径。",
"请先选择直线Edge,或选择可修改的已有圆角 Face。",
)
self._set_control_state(
self.fillet_edge_button,
has_model and is_line_edge,
"给当前直线 edge 添加新圆角。",
"请先选择一条直线 edge。",
can_fillet_edge,
"给当前直线Edge添加新圆角。",
"请先选择一条直线Edge。",
)
self._set_control_state(
self.resize_existing_fillet_button,
has_model and is_existing_fillet and has_fillet_support,
can_resize_existing_fillet,
"尝试修改当前已有圆角/倒圆候选的半径。",
"请先选择一个已有圆角/倒圆候选 face第一版需要识别到至少两个支撑 face。",
"请先选择一个已有圆角/倒圆候选 Face当前版本需要识别到至少两个支撑 Face。",
)
self._set_control_state(
self.edge_chamfer_distance_input,
can_chamfer_edge,
"输入当前直线Edge的倒角距离。",
"请先选择一条直线Edge。",
)
self._set_control_state(
self.chamfer_edge_button,
has_model and is_line_edge,
"给当前直线 edge 添加倒角。",
"请先选择一条直线 edge。",
can_chamfer_edge,
"给当前直线Edge添加倒角。",
"请先选择一条直线Edge。",
)
self._set_control_state(
self.edge_target_length_input,
can_resize_edge_length,
"输入当前Edge的目标长度。",
"请先选择一条Edge。",
)
self._set_control_state(
self.edge_length_anchor_combo,
can_resize_edge_length,
"选择修改Edge长度时尽量固定哪一端。",
"请先选择一条Edge。",
)
self._set_control_state(
self.resize_edge_length_button,
has_model and has_edge,
"直接修改当前 edge 的长度;直线优先端面推拉,其他 edge 使用高风险几何 fallback",
"请先选择一条 edge。",
can_resize_edge_length,
"修改当前Edge的长度;直线Edge优先局部形变,圆/椭圆/平面曲线优先用局部或径向策略",
"请先选择一条Edge。",
)
for widget in (self.translate_x_input, self.translate_y_input, self.translate_z_input):
self._set_control_state(
widget,
can_transform_part or can_transform_solid,
"输入选中零件或 Solid 的平移距离。",
"请先选择一个零件、Solid,或其所属 Face/Edge。",
)
self._set_control_state(
self.translate_part_button,
has_model and self.selected_part_id is not None,
can_transform_part,
"平移当前选中对象所属零件。",
"请先选择一个零件,或选择属于某个零件的对象。",
)
self._set_control_state(
self.rotate_part_button,
has_model and self.selected_part_id is not None,
can_transform_part,
"旋转当前选中对象所属零件。",
"请先选择一个零件,或选择属于某个零件的对象。",
)
self._set_control_state(
self.rotate_axis_combo,
can_transform_part or can_transform_solid,
"选择选中零件或 Solid 的旋转轴。",
"请先选择一个零件、Solid,或其所属 Face/Edge。",
)
self._set_control_state(
self.rotate_angle_input,
can_transform_part or can_transform_solid,
"输入选中零件或 Solid 的旋转角度。",
"请先选择一个零件、Solid,或其所属 Face/Edge。",
)
self._set_control_state(
self.translate_solid_button,
has_model and self.selected_solid_id is not None,
"平移当前选中对象所属 solid。",
"请先选择一个 solid,或选择属于某个 solid 的 face/edge。",
can_transform_solid,
"平移当前选中对象所属 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。",
can_transform_solid,
"旋转当前选中对象所属 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.editable_table,
has_model and self.editable_table.rowCount() > 0,
"点击一行可选中对应对象并填入相关编辑参数。",
"当前没有可编辑对象列表;请先点击扫描对象。",
)
if hasattr(self, "candidate_filter_combo"):
has_cylinder_candidates = has_model and bool(getattr(self, "cylinder_candidates_loaded", False))
self._set_control_state(
self.candidate_filter_combo,
has_cylinder_candidates,
"筛选已扫描的圆柱面候选。",
"请先点击扫描圆柱面。",
)
self._set_control_state(
self.cylinder_table,
has_model and self.cylinder_table.rowCount() > 0,
"点击一行可选中对应圆柱 Face。",
"当前没有圆柱面候选列表;请先点击扫描圆柱面。",
)
self._set_control_state(
self.undo_button,
has_model and bool(self.undo_stack),
@@ -292,7 +615,7 @@ class WindowStateMixin:
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":
if info.get("surface") in {"cylinder", "plane"}:
return self.model.feature_info(self.selected_face_id)
return info
if self.selected_kind == "edge" and self.selected_edge_id is not None:
@@ -330,23 +653,23 @@ class WindowStateMixin:
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 ""
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}"
else f"Face {record.target_id}"
)
if resolved_face_id != record.target_id:
id_note += f"(当前拓扑 face {resolved_face_id}"
id_note += f"(当前拓扑Face {resolved_face_id}"
locator_note = (
f"定位: 已尝试高亮当前模型中的 {id_note}{region_note}"
"布尔编辑后 face ID 可能发生语义变化,请结合拾取点确认。"
"布尔编辑后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} 在当前模型索引中已经不存在。"
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):
@@ -361,11 +684,11 @@ class WindowStateMixin:
self._highlight_edge(record.target_id)
located = True
locator_note = (
f"定位: 已尝试高亮当前模型中的 edge {record.target_id}"
"布尔/倒圆编辑后 edge ID 可能发生语义变化,请结合拾取点确认。"
f"定位: 已尝试高亮当前模型中的Edge {record.target_id}"
"布尔/倒圆编辑后Edge ID可能发生语义变化,请结合拾取点确认。"
)
else:
locator_note = f"定位: 原目标 edge {record.target_id} 在当前模型索引中已经不存在。"
locator_note = f"定位: 原目标Edge {record.target_id} 在当前模型索引中已经不存在。"
if record.pick_position is not None:
self._show_pick_marker(record.pick_position)