diff --git a/.gitignore b/.gitignore index 93e8d03..6536e08 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ assets/screenshots/ local/ +# Local reference docs; keep them on disk, never commit them. +Face一级关系专项测试说明.md +Creo软件具备哪些建模形式.md 中文版Creo+4.0从入门到精通.pdf .DS_Store diff --git a/Creo软件具备哪些建模形式.md b/Creo软件具备哪些建模形式.md deleted file mode 100644 index 265d273..0000000 --- a/Creo软件具备哪些建模形式.md +++ /dev/null @@ -1,311 +0,0 @@ -Creo(原 Pro/ENGINEER)支持的参数化建模操作非常丰富,远不止"拉伸、倒圆"。如果按建模功能分类,大约有 **40~60 种常用特征(Feature)**。 - -下面按类别整理。 - ---- - -# 一、草绘(Sketch) - -二维参数化基础。 - -* 直线 -* 圆 -* 圆弧 -* 椭圆 -* 样条曲线(Spline) -* 点 -* 中心线 -* 构造线 -* 倒角 -* 圆角 -* 文本 -* 尺寸约束 -* 几何约束(平行、垂直、相切、重合等) - ---- - -# 二、实体基础特征(Solid Feature) - -最常用。 - -| 功能 | Creo名称 | -| ------ | ---------------------- | -| 拉伸 | Extrude | -| 旋转 | Revolve | -| 扫描 | Sweep | -| 可变截面扫描 | Variable Section Sweep | -| 螺旋扫描 | Helical Sweep | -| 混合 | Blend | -| 边界混合 | Boundary Blend | -| 放样 | Loft(Blend实现) | -| 扫掠混合 | Swept Blend | -| 壳体 | Shell | -| 厚度 | Thicken | - ---- - -# 三、布尔运算 - -* 合并(Merge) -* 切除(Cut) -* 相交(Intersect) -* 继承体(Inheritance) -* 布尔组合(Boolean) - ---- - -# 四、孔(Hole) - -Creo孔功能非常丰富。 - -例如: - -* 简单孔 -* 通孔 -* 盲孔 -* 沉头孔 -* 沉孔 -* 螺纹孔 -* 标准孔 -* 自定义孔 -* 多深度孔 - -支持: - -* ANSI -* ISO -* DIN -* JIS - -等标准。 - ---- - -# 五、圆角和倒角 - -圆角(Round) - -包括: - -* 恒定圆角 -* 可变半径圆角 -* 全圆角 -* 面圆角 -* 边圆角 -* 相切传播 - -倒角(Chamfer) - -支持: - -* D×D -* D×Angle -* Angle×D -* 顶点倒角 - ---- - -# 六、筋板(Rib) - -例如: - -* Rib -* Web -* Thin Rib - ---- - -# 七、拔模(Draft) - -包括: - -* 中性面拔模 -* 分型面拔模 -* 可变拔模 -* 分段拔模 - ---- - -# 八、镜像和阵列 - -Mirror - -支持: - -* 特征镜像 -* 几何镜像 - -Pattern - -支持: - -* 方向阵列 -* 轴阵列 -* 表阵列 -* 曲线阵列 -* 填充阵列 -* 尺寸驱动阵列 -* 引用阵列 -* 用户定义阵列 - ---- - -# 九、复制 - -* Copy Feature -* Move -* Rotate -* Scale -* Shrink - ---- - -# 十、曲面(Surface) - -Creo曲面功能非常强。 - -包括: - -* 拉伸曲面 -* 旋转曲面 -* 扫描曲面 -* 边界曲面 -* 混合曲面 -* 填充曲面(Fill) -* 偏移曲面(Offset) -* 延伸曲面(Extend) -* 修剪曲面(Trim) -* 合并曲面(Merge) -* 替换曲面(Replace) -* 删除曲面(Delete) -* 缝合曲面(Quilt) -* 相交曲面(Intersect) -* 镜像曲面 -* 复制曲面 - ---- - -# 十一、工程特征 - -* 坐标系(CSYS) -* 基准点 -* 基准轴 -* 基准面 -* 基准曲线 -* 包络体 -* 骨架模型(Skeleton) -* 发布几何(Publish Geometry) - ---- - -# 十二、曲线(Curve) - -* 投影曲线 -* 相交曲线 -* 边界曲线 -* 样条曲线 -* 方程曲线(Equation Curve) -* 螺旋曲线 -* 基准曲线 -* 偏移曲线 -* 组合曲线 - ---- - -# 十三、扫描高级功能 - -例如: - -* 普通扫描 -* 可变截面扫描 -* 沿轨迹扫描 -* 双轨扫描 -* 法向扫描 -* 扭转扫描 -* 导向扫描 - ---- - -# 十四、螺纹 - -* Cosmetic Thread -* 实体螺纹 -* 内螺纹 -* 外螺纹 -* 锥螺纹 - ---- - -# 十五、柔性与变形 - -* Flex -* Warp -* Bend -* Twist -* Offset Geometry -* Morph - ---- - -# 十六、钣金特征(Sheetmetal) - -* Wall -* Flange -* Bend -* Unbend -* Flat Pattern -* Corner Relief -* Rip -* Hem -* Punch -* Form - ---- - -# 十七、自由曲面(ISDX/Style) - -* Style Surface -* Boundary Curve -* Bézier 曲面 -* NURBS 曲面 -* G0/G1/G2 连续 -* 自由形状编辑 - ---- - -# 十八、参数化能力 - -Creo 的核心优势在于所有特征均可参数驱动,包括: - -* 尺寸参数(Dimension) -* 关系式(Relation) -* Family Table -* UDF(用户定义特征) -* Program(Pro/PROGRAM) -* Mapkey -* 参数(Parameter) -* 层级引用(Reference) -* 外部参数关联 - ---- - -## 与 STEP AP242 特征的对应关系 - -如果你的目标是开发类似 **Creo → STEP → 自研 CAD/CAE** 的参数化建模系统,可以重点支持以下高频特征(约占工业模型的 90% 以上): - -| Creo 操作 | STEP 表达 | 建议优先级 | -| -------------- | -------------------------- | ----- | -| 拉伸(Extrude) | Extruded Area Solid | ★★★★★ | -| 旋转(Revolve) | Revolved Area Solid | ★★★★★ | -| 扫描(Sweep) | Swept Area Solid | ★★★★★ | -| 放样(Blend/Loft) | Lofted/B-Spline Surface | ★★★★☆ | -| 孔(Hole) | B-Rep + 圆柱面 | ★★★★★ | -| 倒圆(Round) | B-Rep + 圆环面/圆柱面 | ★★★★★ | -| 倒角(Chamfer) | B-Rep + 平面 | ★★★★★ | -| 壳体(Shell) | Offset Surface + B-Rep | ★★★★☆ | -| 筋(Rib) | Extrude + Thin Feature | ★★★★☆ | -| 阵列(Pattern) | Transformation + Instances | ★★★★☆ | -| 镜像(Mirror) | Transformation | ★★★★☆ | -| 拔模(Draft) | Drafted Faces | ★★★★☆ | - -**如果按工业软件(Creo、NX、SolidWorks、CATIA)的实际使用统计,前 15~20 类参数化特征(拉伸、旋转、扫描、放样、孔、倒圆、倒角、壳体、筋、拔模、镜像、阵列、曲面等)即可覆盖绝大多数机械零件的建模需求。** diff --git a/Face一级关系专项测试说明.md b/Face一级关系专项测试说明.md deleted file mode 100644 index 563b9f5..0000000 --- a/Face一级关系专项测试说明.md +++ /dev/null @@ -1,59 +0,0 @@ -# Face 一级关系专项测试说明 - -这份文档用于模型工程师专项测试当前 Face 阶段能力。当前版本还不能说是完整 CAD 级 Face 参数化,但已经可以围绕 STEP/B-Rep 的一级关系做集中验证。 - -## 当前目标 - -当前阶段只处理 Face 的一级拓扑关系: - -- 当前选中 Face 或同域共面/同圆柱面区域。 -- 当前 Face 边界上的 Edge / Vertex。 -- 与当前 Face 共享边的一级相邻 Face。 - -二级、三级关系暂不自动递归传播。也就是说,相邻 Face 再连接出去的孔壁、槽底、台阶、其它特征,目前只记录为后续能力目标,不会强行一起修改。 - -## 建议重点测试 - -1. 简单平面 Face - - 测试 `面积`、`U向尺寸`、`V向尺寸`、`中心`、`偏移曲面`。 - - 分别测试 `局部重建`、`移动特征`、`缩放特征`、`拉伸/切除`。 - - 简单正方体/长方体应优先通过。 - -2. 共面碎面 - - 如果一个大平面被 STEP 拆成多个共面 Face,`拉伸/切除` 应尽量按同一片平面区域处理。 - - 修改后要观察是否仍是单 Solid,边界是否明显丢失。 - -3. 圆柱端盖 / 空心圆柱环形端面 - - 可重点测试 `偏移曲面` 的 `拉伸/切除`。 - - 简单圆柱上底面、下底面和同轴空心圆柱环形端面应能改变高度。 - - 如果完整圆柱侧面被 STEP 拆成多个同域圆柱 Face,属性表仍应按完整圆柱显示 `高度`、`直径/半径`,不能误显示成槽/半孔参数。 - -4. 默认模型里的 Face 594 - - 当前已补充回归:Face 594 的 `偏移曲面(拉伸/切除)` 从 `57.5` 改到 `90` 应能成功。 - - 该操作会重建目标端盖,旧拓扑 Face ID 可能变成新 Face,例如测试中会定位到新目标面 `Face 1805`。 - - 这是正常现象。判断是否成功应看目标偏移曲面是否达到 `90`、内边界是否保留、逻辑选中对象是否继续指向修改后的新面,而不是死盯旧 Face ID。 - - 这次修复过一个 UI 回归:属性表显示的方向和按钮执行时的快速计划方向必须一致,不能把 `normal` 和反向的 `oriented_normal` 混用。 - -5. 壳体厚度 - - 识别到相对平面时,可以测试 `壳体厚度`。 - - 当前有 `拉伸/切除` 和 `缩放特征` 两种语义,测试时要区分它们。 - -## 暂时不要当作失败的情况 - -- 修改后 Face ID 变化:STEP/B-Rep 重建后拓扑编号变化是正常的,应看逻辑 Face 是否跟到新面。 -- 复杂多内孔端盖向内切除被阻止:如果会涉及孔壁、槽底、台阶背后的二级/三级关系,当前版本可能会快速阻止,这是预期保护。 -- 自由曲面、B-spline 面、复杂壳体局部区域不能改:这些暂时不是 Face 一级稳定能力。 -- STEP 原 CAD 历史无法恢复:当前参数是 B-Rep 几何语义,不等于原 CAD 软件里的草图尺寸或建模历史参数。 - -## 测试时重点观察 - -- 软件是否卡死、闪退、长时间无法关闭。 -- 操作失败后原模型是否保持不变。 -- 修改成功后是否仍是有效 B-Rep,是否保持单 Solid。 -- 当前选中对象是否继续指向被修改后的新 Face。 -- 属性表里的当前值是否能回读到新几何。 -- 失败提示是否能说明原因,而不是只报无效 shape。 - -## 当前判断 - -Face 一级关系可以继续交给模型工程师做专项测试,但还不能宣布完全完成。若简单平面、共面碎面、简单圆柱端盖、Face 594 这类基线场景仍失败,应优先修复;若是二级/三级关系、复杂自由曲面或复杂内孔向内切除,暂时可以记录为后续能力。 diff --git a/README.md b/README.md index e0d22ac..d5554cb 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - 主入口是 `python main.py`;默认模型是 `assets/models/geom_extract.step`;立方体测试模型是 `assets/models/cube_10mm.step`。 - 左侧操作面板当前优先保留最小建模链路:STEP 文件、选择模式 / 按 ID 选择、当前选中对象、编辑、参数化建模和导出当前完整 STEP;部分辅助面板暂时收起,后续需要时再放回。 - 局部编辑会先做计划、风险提示、预览和后台执行;失败时会尽量回滚,成功后进入撤销/重做历史。 -- 当前开发顺序改为分阶段闭环:先集中完成 Face 修改能力并让工程师专项测试,再进入孔/槽,再进入 Edge,最后推进凸台、圆角、倒角、壳体等特征;不要每类只做一点。 +- 当前开发顺序改为分阶段闭环:先集中完成 Face 修改能力并让工程师专项测试,再进入孔/槽,再进入凸台、圆角/倒角、Edge 和壳体等特征,最后扩展二级/三级关系;不要每类只做一点。 - 新开 Codex 聊天框继续开发时,只需要 Codex 阅读 README 末尾的“Codex 项目记忆”;普通开发者可以忽略那一节。 ## 怎么运行 @@ -68,7 +68,7 @@ python main.py assets\models\cube_10mm.step python scripts\generate_cube_step.py ``` -10. 验证几何修改基线。当前推进和测试优先级是先 Face,再孔/槽,再 Edge 和其它特征。完整一级编辑回归可以用一个入口串起来: +10. 验证几何修改基线。当前推进和测试优先级按用户常用操作排序:先 Face 面编辑,再孔、槽、凸台、圆角/倒角、壳体和 Edge,最后再扩二级/三级关系。完整一级编辑回归可以用一个入口串起来: ```powershell python scripts\verify_first_level_edit_suites.py @@ -80,7 +80,7 @@ python scripts\verify_first_level_edit_suites.py python scripts\verify_first_level_edit_suites.py --quick ``` -`--quick` 会检查 smoke test、属性表规格、参数卡片 UI、一级事实图、关联探测、显示网格预算,以及 README 里的一级验收口径是否仍和脚本入口一致。 +`--quick` 会检查 smoke test、属性表规格、参数表 UI、特征识别优先级、一级事实图、关联探测、显示网格预算,以及 README 里的一级验收口径是否仍和脚本入口一致。 只验证某个阶段时,例如 Edge: @@ -158,22 +158,177 @@ git diff --check - 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。 - 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。 - 识别结果会区分 `当前可改`、`当前受限修改`、`识别限制` 和 `受限能力`:比如一个通孔可以改孔径,但盲孔深度属于受限能力;一个带内孔的平面可以拉伸/切除,但“局部重建尺寸/中心/偏移”会被标成受限修改。 -- 用户可以修改可控的特征,比如 Face 的面积、U向尺寸/V向尺寸、中心、偏移变换和壳体厚度,并通过 `建模意图` 选择拉伸/切除、局部重建、移动特征或缩放特征;还可以做孔径/半径调整、孔/槽轴心并通过 `建模意图` 选择移动局部特征或移动特征、槽宽/槽深/弧长并通过 `建模意图` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、壳体厚度(局部拉伸/切除或整体缩放)、凸台直径/半径/高度/轴心并通过 `建模意图` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `建模意图` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(简单圆锥解析重建,嵌入式锥孔优先局部重切)、球面半径/直径(缩放特征)、环面主/小半径或直径(缩放特征)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `建模意图` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `建模意图` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。 +- 用户可以修改可控的特征,比如 Face 的面内长度、面内宽度、中心、偏移和壳体厚度,并通过 `建模意图` 选择拉伸/切除、局部重建、移动特征或缩放特征;面积是结果变量,放在诊断信息里查看,不作为特征参数表里的驱动变量。还可以做孔径/半径调整、孔/槽轴心并通过 `建模意图` 选择移动局部特征或移动特征、槽宽/槽深/弧长并通过 `建模意图` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、壳体厚度(局部拉伸/切除或整体缩放)、凸台直径/半径/高度/轴心并通过 `建模意图` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `建模意图` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(简单圆锥解析重建,嵌入式锥孔优先局部重切)、球面半径/直径(缩放特征)、环面主/小半径或直径(缩放特征)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `建模意图` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `建模意图` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。 - 用户可以撤销/重做修改,避免一步编辑做坏。 - 用户可以导出修改后的完整模型。 - 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。 需要注意:STEP 文件通常是 B-Rep 几何数据,不是带完整建模历史的参数化 CAD 原文件。所以“随便选一条边改长度”不一定能稳定实现,实际会转化成更可靠的操作,比如移动某个面、重切某个孔、调整某个圆角等。 +## 参数化编辑路线图 + +排序原则:`用户最常用优先 > B-Rep 上稳定可实现 > 参数语义清楚`。这张路线图是后续实现顺序和验收拆分的主线;UI 可以只显示短版进度,但开发和测试按下面这棵树推进。 + +状态标记: + +- `[x]` 已实现:已经接入 UI/后端/回归测试,适用于当前明确支持的模型范围。 +- `[~]` 部分实现/进行中:已有识别、计划或部分模型编辑能力,但真实 STEP 上还需要继续补守门、补失败解释或补局部重建。 +- `[ ]` 未实现:作为后续目标保留,当前不应在 UI 里伪装成可稳定修改。 + +```text +STEP/B-Rep 参数化编辑主线 +├── 0. 先让用户知道“能不能改” +│ ├── [x] [选中对象 -> 识别建模形式] +│ │ └── Face / 孔 / 槽 / 凸台 / Edge / 圆角 / 倒角候选都要先映射成用户能理解的 CAD 入口。 +│ ├── [x] [识别结果 -> 显示可修改项] +│ │ └── 特征参数表只放当前真正可执行的 [参数 -> 操作],面积这类结果变量放诊断信息。 +│ ├── [x] [不能修改 -> 立即说明原因] +│ │ └── 所有常见特征入口的 blocked plan 会在执行前统一归类成非法输入 / 暂未实现 / 风险过高 / 识别不足,避免等几十秒后才失败。 +│ ├── [x] [高风险修改 -> 后台隔离执行] +│ │ └── OCCT 布尔、圆角、局部重建等危险计算走子进程,失败回滚,主界面和原模型保持稳定。 +│ └── [x] [路线图 -> 验收脚本守门] +│ └── quick 回归覆盖属性表、一级事实、关联探测、各阶段入口、README 口径一致性,并守住 0~7 不混入二级/三级传播任务。 +│ +├── 1. 平面 Face,第一条主线 +│ ├── [x] [偏移 -> 推拉平面 / 拉伸切除] +│ │ └── 当前 Face 移动或加料/切削,边、点和共享边一级相邻面跟随重建,复杂端盖走隔离和回滚。 +│ ├── [x] [面内长度 -> 改长度] +│ │ └── 近矩形平面 Face 沿长度方向变化,一级相邻面跟随,面积不作为驱动参数。 +│ ├── [x] [面内宽度 -> 改宽度] +│ │ └── 近矩形平面 Face 沿宽度方向变化,一级相邻面跟随。 +│ ├── [x] [中心位置 -> 移动局部面组] +│ │ └── 可选择局部重建或移动所属对象,避免无提示地把整个模型拖走。 +│ ├── [x] [壳体厚度 -> 改薄壁厚度] +│ │ └── 平面相对面可做局部拉伸/切除或厚度方向缩放,并做 Face 结果校验。 +│ └── [x] [复杂多边界端盖 -> 边界侧壁重建] +│ └── 大 STEP 多内孔平面端盖可走 boundary-shell-rebuild,浅范围内外移动保持内孔线圈和单 Solid;接近切穿或需要孔底/槽底更深关系判断时快速阻止。 +│ +├── 2. 孔,第二条主线 +│ ├── [x] [直径/半径 -> 改孔径] +│ │ └── 通孔/盲孔优先同轴重切孔壁,结果必须仍能识别目标圆柱孔。 +│ ├── [x] [深度 -> 改盲孔深度] +│ │ └── 加深切削、变浅补料,目标底面和盲孔语义必须能回读。 +│ ├── [x] [中心位置 -> 移动孔] +│ │ └── 先补旧孔再切新孔,移动的是整个孔特征,不是一片圆柱面。 +│ ├── [x] [孔本体 -> 封堵/删除孔] +│ │ └── 孔壁消失,孔口补面有效,结果保持单 Solid。 +│ ├── [x] [锥孔/沉孔 -> 局部重切或提前阻止] +│ │ └── 简单圆锥解析重建,嵌入式锥孔/沉孔优先局部重切;复杂浅锥、螺纹孔和组合孔提前解释为受限能力。 +│ └── [x] [孔编辑范围 -> 单个稳定孔特征] +│ └── 当前只承诺单个孔/锥孔/沉孔的一级关系修改;孔组、阵列和同尺寸孔联动放到第 8 阶段。 +│ +├── 3. 槽 / 长圆孔,从孔扩展到组合切除特征 +│ ├── [x] [槽宽 -> 改槽宽] +│ │ └── 两侧壁和圆弧端同步更新,结果仍能识别为槽/半孔。 +│ ├── [x] [槽深 -> 改盲槽深度] +│ │ └── 槽底移动,槽口边界有效,不把母体切坏。 +│ ├── [x] [矩形槽/口袋长宽/中心/深度 -> 局部重建矩形切除] +│ │ └── 规则矩形槽/口袋可改长宽、中心和深度;中心只支持在基准平面内移动。 +│ ├── [x] [弧长/弧角/开口角 -> 改圆弧槽段] +│ │ └── 保持圆柱槽语义,重切后能回读目标弧长或角度。 +│ ├── [x] [总长/中心距 -> 改长圆槽长度] +│ │ └── 自动或手动配对两个圆弧端,槽宽保持不变。 +│ ├── [x] [中心位置 -> 移动槽] +│ │ └── 半圆槽和长圆槽都按特征整体补旧槽、切新槽。 +│ └── [ ] [复杂草图槽/跨复杂面槽 -> 稳定修改] +│ └── 非圆柱槽、跨复杂面的槽暂不承诺稳定编辑;多槽组关联放到第 8 阶段。 +│ +├── 4. 凸台 / Boss,从切除特征扩展到加料特征 +│ ├── [x] [直径/半径 -> 改圆柱凸台直径] +│ │ └── 移除旧凸台包络后重建,底部融合必须有效。 +│ ├── [x] [高度 -> 改凸台高度] +│ │ └── 顶面移动,侧壁延伸或裁剪,目标高度可回读。 +│ ├── [x] [中心位置 -> 移动圆柱凸台] +│ │ └── 旧凸台先移除,新凸台按目标轴心补回,不只移动侧壁。 +│ ├── [x] [矩形凸台/口袋高度/深度 -> 端面推拉] +│ │ └── 规则矩形凸台顶面和规则矩形口袋底面可按高度/深度推拉,修改后会回读矩形拉伸尺寸。 +│ ├── [x] [矩形凸台/口袋长度/宽度 -> 局部重建矩形包络] +│ │ └── 规则矩形凸台/口袋可先移除或补回旧包络,再按目标长宽重建;当前不在一次编辑中交换长宽方向。 +│ ├── [x] [矩形凸台/口袋中心位置 -> 平面内移动并重建包络] +│ │ └── 规则矩形凸台/口袋可在基准平面内移动中心,长宽和高度/深度保持不变;沿高度/深度方向移动会提前阻止。 +│ ├── [x] [多台阶矩形凸台顶层规则台阶 -> 独立编辑] +│ │ └── 选中多台阶凸台最上层规则矩形台阶的顶面时,可按独立矩形凸台修改长宽、中心和高度。 +│ └── [ ] [复杂多台阶凸台整体 -> 局部重建] +│ └── 多台阶整组联动、异形凸台和复杂融合边界暂不作为稳定能力。 +│ +├── 5. 圆角 / 倒角,从主形体扩展到边修饰 +│ ├── [x] [Edge -> 添加圆角] +│ │ └── 简单直线 Edge 可调用 OCCT 倒圆,失败时回滚。 +│ ├── [x] [Edge -> 添加倒角] +│ │ └── 支持对称倒角、不等距倒角和距离+角度倒角的简单边场景。 +│ ├── [x] [半径 -> 改已有简单圆角] +│ │ └── 当前走 defeature + refillet,目标半径和结果邻域必须反查通过。 +│ ├── [x] [弧长 -> 调整已有圆角] +│ │ └── 按当前圆弧角把目标弧长换算成目标半径,再走 defeature + refillet,结果弧长必须反查通过。 +│ ├── [x] [复杂圆角链 -> 识别并快速阻止] +│ │ └── 相邻同半径圆角 Face 会标成圆角链,修改半径/弧长时在计划阶段阻止并说明当前暂未实现。 +│ └── [ ] [复杂圆角链 / 变半径圆角 -> 稳定重建] +│ └── 链式 blend 和变半径圆角的同步重建仍未完成,暂不承诺修改。 +│ +├── 6. Edge 一级编辑,补齐底层直接改边能力 +│ ├── [x] [边长 -> 改直线 Edge 长度] +│ │ └── 支持只改当前边、移动端面、相邻圆柱和缩放所属对象等显式建模意图。 +│ ├── [x] [起点/中心/终点 -> 移动直线 Edge] +│ │ └── 简单全平面模型可移动端点或整条边,并重建相邻面。 +│ ├── [x] [圆形 Edge 半径/直径 -> 代理到相邻圆柱] +│ │ └── 圆边优先复用孔/槽/凸台直径或轴心修改,而不是裸改曲线。 +│ ├── [x] [椭圆 Edge 主/小半径 -> 单轴缩放] +│ │ └── 简单椭圆边可沿主轴或小轴做局部仿射,并校验采样半径。 +│ ├── [x] [移动端面 -> 简单盒体保持垂直边长语义] +│ │ └── 直线 Edge 可把长度变化转成端面推拉,端面和同向边跟随,计划和结果都会反查端面/侧面垂直关系。 +│ ├── [x] [固定起点/终点/中心 -> Edge 长度锚点] +│ │ └── 修改直线 Edge 长度时可选择固定起点、固定终点或固定中心,结果会按锚点反查。 +│ ├── [x] [平行/垂直 -> 端面推拉一级关系守门] +│ │ └── 移动端面策略会确认直接相邻平面和移动端面可验证,执行后反查垂直/平行关系。 +│ ├── [~] [平行/垂直 -> 通用可选约束] +│ │ └── 跨多面、跨特征的平行/垂直约束仍需继续泛化,还不是通用约束求解。 +│ ├── [x] [复杂曲线 Edge -> 可编辑入口守门] +│ │ └── B-spline / Bezier / 其它复杂曲线不再在扫描列表或参数表里伪装成稳定“改长度”入口。 +│ └── [ ] [任意曲线 Edge -> 通用约束编辑] +│ └── B-spline 边、复杂曲线边和多约束边不作为当前稳定目标。 +│ +├── 7. 壳体 / 解析曲面,补齐高价值但边界更窄的能力 +│ ├── [x] [壳体厚度 -> 局部拉伸/切除或缩放] +│ │ └── 简单薄板和可识别相对平面已纳入 Face/壳体套件。 +│ ├── [x] [圆锥参考半径/半角 -> 简单圆锥解析重建] +│ │ └── 嵌入式锥孔优先局部重切,复杂拔模面提前阻止。 +│ ├── [x] [球面半径 -> 缩放特征] +│ │ └── 修改后仍需识别为目标球面。 +│ ├── [x] [环面主/小半径 -> 缩放特征] +│ │ └── 修改后仍需识别为目标环面。 +│ └── [ ] [完整抽壳/开口面编辑 -> 壳体特征级重建] +│ └── STEP 无历史下的通用抽壳关系恢复放在后期。 +│ +└── 8. 二级 / 三级关系,从“局部能改”走向“关系能保持” + ├── [ ] [Face 二级传播 -> 孔底/槽底/台阶联动] + │ └── 当前只自动处理一级共享边邻域,二级/三级传播放到本阶段。 + ├── [x] [一级影响范围 -> 明确显示] + │ └── 当前 Face/孔/槽/Edge 已能显示或记录一级边界、顶点和直接相邻 Face 事实。 + ├── [~] [关联探测 -> 找相邻特征] + │ └── 已能在有限共享边拓扑范围内找关联孔、槽、凸台等,并合并部分关联尺寸到参数表。 + ├── [~] [共面/同域碎片 -> 同步处理] + │ └── 同域合并已用于计划、扫描和历史定位;作为用户可选“共面同步修改”仍需完善。 + ├── [~] [同轴圆柱 -> 保持同轴] + │ └── 孔、槽、凸台和圆柱端盖已在部分路径保持同轴;跨特征同轴约束还不是通用能力。 + ├── [ ] [平行/垂直平面 -> 保持方向关系] + │ └── 用户选择保持平行/垂直时,相关面方向需要作为约束参与重建。 + ├── [ ] [相切关系 -> 保持连续] + │ └── 圆角、圆柱过渡和相切支撑面的连续性需要在传播计划里显式表达。 + ├── [ ] [重复特征组 -> 同组联动] + │ └── 孔组、阵列孔、多槽组和多个同尺寸凸台要能选择只改当前一个,或改同组全部。 + └── [ ] [传播冲突 -> 用户选择范围] + └── 当二级/三级关系互相冲突时,列出冲突原因,允许只执行一级或扩展传播,不强行硬改。 +``` + ## 几何修改语义原则 所有可修改参数都要先明确“这次修改到底代表什么”,不能只因为程序能算出一个结果就直接放行。这个原则适用于 Face、Edge、孔、槽、凸台、圆角、Solid 和整件特征,不是 Edge 专属。比如修改 Face 可能代表 Face 拉伸/切除、局部补料、局部切削、平移所属对象或整体缩放;修改孔/槽可能代表重切侧壁、移动轴心、调整端面深度或重新配对长圆槽端部;修改圆角可能代表移除旧圆角后重建;修改 Edge 可能代表只动这条边、移动相关端面,或者高风险地缩放所属几何。 所以每个可编辑行都应尽量说明本次采用的建模意图、执行影响和风险;当同一个目标值有多种合理语义时,应逐步做成用户可选择的策略,而不是程序悄悄替用户决定。高风险 fallback 必须提示用户确认,不能把整体尺寸变化、局部形变、Face 拉伸/切除、切削补料、对象平移、对象缩放和特征重建混成一个简单的“修改成功”。 -Creo 资料的参考价值不只是统一术语,还要用来组织功能入口和操作流程。界面不应优先暴露底层 B-Rep 字段,而应先把选中对象映射到 CAD 用户熟悉的建模形式,例如拉伸/切除、偏移变换、孔、槽、倒圆角、倒角、抽壳、拔模、移动几何和缩放特征;只有当识别结果足够稳定,才把对应的可改尺寸放出来。 +Creo 资料的参考价值不只是统一术语,还要用来组织功能入口和操作流程。界面不应优先暴露底层 B-Rep 字段,而应先把选中对象映射到 CAD 用户熟悉的建模形式,例如拉伸/切除、偏移、孔、槽、倒圆角、倒角、抽壳、拔模、移动几何和缩放特征;只有当识别结果足够稳定,才把对应的可改尺寸放出来。 -当前 Face 参数化修改已经接入隔离子进程执行,包括 `拉伸/切除`、`偏移变换(局部重建)`、`面积(局部重建)`、`U向尺寸/V向尺寸(局部重建)`、`中心(局部重建)`、`壳体厚度(拉伸/切除)`、`面积(缩放特征)`、`U向尺寸/V向尺寸(缩放特征)`、`壳体厚度(缩放特征)`、普通圆柱高度、圆柱凸台高度,以及圆锥参考半径/直径/半角、球面半径/直径、环面主/小半径或直径:主程序先导出临时 STEP,让 helper 进程执行危险 OCCT 计算并导出结果 STEP,再由主程序加载成功结果;如果 helper 卡死、超时或崩溃,主界面进程和原模型保持不变。关闭窗口时如果正在运行隔离子进程,主程序会请求终止该子进程,避免用户被长时间计算锁住。打包后的 `main.exe` 也会通过 `--isolated-edit-worker` 进入同一条 worker 通道,避免子进程误打开第二个 GUI。无法识别为简单圆锥或锥孔/沉孔的复杂圆锥/拔模面参考半径、参考直径和半角暂不开放,不再退回整体径向缩放;复杂浅锥/拔模面会在计划阶段快速阻止,避免界面线程长时间等待或生成无效 B-Rep。仍未归类到明确 Face、孔槽或 Edge 语义的复杂 fallback 会继续被稳定性保护阻止,后续按特征逐步开放。 +当前 Face 参数化修改已经接入隔离子进程执行,特征参数表面向用户开放 `拉伸/切除`、`偏移(局部重建/移动特征)`、`面内长度/面内宽度(局部重建或缩放特征)`、`中心(局部重建或移动特征)`、`壳体厚度(拉伸/切除或缩放特征)`、普通圆柱高度、圆柱凸台高度,以及圆锥参考半径/直径/半角、球面半径/直径、环面主/小半径或直径;面积仍作为诊断和校验结果保留,但不再作为表格里的可编辑驱动参数。主程序先导出临时 STEP,让 helper 进程执行危险 OCCT 计算并导出结果 STEP,再由主程序加载成功结果;如果 helper 卡死、超时或崩溃,主界面进程和原模型保持不变。关闭窗口时如果正在运行隔离子进程,主程序会请求终止该子进程,避免用户被长时间计算锁住。打包后的 `main.exe` 也会通过 `--isolated-edit-worker` 进入同一条 worker 通道,避免子进程误打开第二个 GUI。无法识别为简单圆锥或锥孔/沉孔的复杂圆锥/拔模面参考半径、参考直径和半角暂不开放,不再退回整体径向缩放;复杂浅锥/拔模面会在计划阶段快速阻止,避免界面线程长时间等待或生成无效 B-Rep。仍未归类到明确 Face、孔槽或 Edge 语义的复杂 fallback 会继续被稳定性保护阻止,后续按特征逐步开放。 孔/槽一级关系的主要布尔/重建入口也开始复用同一条隔离执行通道,包括孔径、圆柱尺寸缩放、孔轴心、孔封堵、盲孔/盲槽深度、槽/半孔轴心、槽宽、槽深、圆弧长度、圆弧角度、长圆槽总长度和中心距。这样即使孔/槽局部重切在复杂 STEP 上失败、超时或触发 OCCT 崩溃,主界面和原模型也应保持不变。圆柱直径整体缩放和盲孔/盲槽深度整体缩放会在模型层做结果校验;如果结果虽然是有效 B-Rep,但已经丢失目标圆柱/盲孔语义,会自动回滚并提示原因。 @@ -185,7 +340,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 如果轻量预检查已经能判断出风险不可接受,比如复杂大模型里的多内孔端盖向内切除已经接近切穿,或需要判断孔底、槽底、台阶背后的二级关系,界面会直接阻止并说明原因,不再提交隔离子进程等待失败返回。浅范围内的多内孔端盖向内收缩会走后台/隔离子进程里的一级边界侧壁重建。 -复杂大 STEP 中带内孔/内边界的平面 Face,如果用户选择 `面积(局部重建)`、`U向尺寸/V向尺寸(局部重建)`、`中心(局部重建)` 或 `偏移变换(局部重建)` 这类“只改当前 Face 顶点并重建相邻面”的局部变形语义,UI 会在轻量预检查阶段直接阻止并说明原因,不再先进入完整局部重建计划。`壳体厚度(拉伸/切除)` 在这类复杂面上会先标记为后台高风险计划,把相对面识别和拉伸/切除距离计算放进隔离进程,避免主界面等待复杂拓扑扫描。 +复杂大 STEP 中带内孔/内边界的平面 Face,如果用户选择 `面内长度/面内宽度(局部重建)`、`中心(局部重建)` 或 `偏移(局部重建)` 这类“只改当前 Face 顶点并重建相邻面”的局部变形语义,UI 会在轻量预检查阶段直接阻止并说明原因,不再先进入完整局部重建计划。`壳体厚度(拉伸/切除)` 在这类复杂面上会先标记为后台高风险计划,把相对面识别和拉伸/切除距离计算放进隔离进程,避免主界面等待复杂拓扑扫描。 普通平面 Face 的核心修改路径现在还会在模型层做结果守门:执行后必须通过 B-Rep 校验、能重新找到目标 Face 的目标值,并且能重新识别该 Face 的一级共享边邻域。矩形、多边形 Face 会检查边界 Edge 和 Vertex;圆形端盖这类解析边界会允许 1 条闭合圆 Edge。圆锥参考半径/半角、球面半径、环面半径这类解析 Face 也会强制校验目标值和 B-Rep。若目标值没达到、一级邻域丢失或 B-Rep 无效,模型层会恢复操作前 shape,不把坏结果交给界面或导出流程。 @@ -201,7 +356,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 设计上优先把选择做成当前选中对象里的清晰属性行或策略控件,而不是弹出一堆工程术语。每个可选策略都要用用户能看懂的话说明结果,例如“只改当前边,邻面会变斜”“移动端面,整体变成长方体”“缩放所属对象,所有尺寸同比例变化”“重切孔壁,只改孔径”。默认策略可以存在,但必须可见、可解释、可切换。 -界面里的 `当前选中对象` 会优先显示 `建模形式` 和 `推荐操作` 行:前者告诉你当前对象更像哪类 Creo/CAD 建模入口,后者告诉你现在最适合从哪个参数或策略开始改。比如孔径是孔特征的同轴重切,槽/半孔是槽特征的局部扇形槽或配对长圆槽重建,凸台是包络重建或端盖拉伸/切除,已有圆角是先移除旧圆角再重建。对于圆柱直径/半径,当前也开始提供独立的 `缩放所属对象` 行,让你明确选择“重切/重建局部特征”还是“按目标尺寸比例均匀缩放所属特征或 Solid”。 +界面里的 `特征参数` 只显示能直接修改的输入参数,不再混入 `建模形式`、`推荐操作`、`一级关系`、`关联探测` 这类诊断说明。程序内部仍会计算这些信息,用来决定哪些参数可显示、哪些操作要阻止,以及失败时该给出什么原因。对于同一个尺寸存在多种语义的情况,例如圆柱直径/半径,用户仍通过 `建模意图` 明确选择“重切/重建局部特征”还是“按目标尺寸比例均匀缩放所属特征或 Solid”。 ## 开源复用原则 @@ -223,7 +378,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 能导出选中的零件。 - 能撤销/重做编辑。 - 先实现少量可控编辑操作,用于验证后续特征编辑路线。 -- 当前开发顺序固定为先集中完成 Face 修改能力,再进入孔/槽,再进入 Edge,最后推进凸台、圆角、倒角、壳体和其它解析曲面;每一类要先做到可操作、可解释、可测试,再进入下一类,避免每类只做一点导致测试分散。 +- 当前开发顺序固定按上面的“参数化编辑路线图”推进:先把交互规则和 Face 打稳,再进入孔、槽、凸台、圆角/倒角、Edge、壳体/解析曲面,最后扩展二级/三级关系;每一类都要先做到可操作、可解释、可测试,再进入下一类,避免每类只做一点导致测试分散。 ## 能改哪些,一分钟版 @@ -232,11 +387,11 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 | 对象 | 现在能改哪些 | 程序大概怎么改 | 暂时还不能稳定改 | | --- | --- | --- | --- | | Edge / 边 | 直线边的长度、起点、中心、终点;直线边新增圆角/倒角;圆形或圆弧边的半径/直径、圆心/相邻轴心;椭圆边的主半径/小半径。 | 简单模型优先局部重建当前边和相邻面;圆边可复用相邻圆柱改孔/槽/凸台直径或轴心;椭圆边可沿主轴或小轴单轴缩放所属特征。 | 任意复杂曲线边、B-spline 边、需要复杂约束跟随的边。 | -| Face / 面 | 面积、U向尺寸/V向尺寸、中心、偏移变换、壳体厚度;圆柱端盖或完整圆柱侧面的高度。 | 拉伸/切除当前平面或圆柱端盖;移动当前面顶点并重建相邻面;或平移/缩放所属特征。 | 自由曲面会明确识别为只读,不开放伪参数化修改;带复杂内孔的面、复杂壳体局部区域、非完整圆柱侧面高度。 | +| Face / 面 | 面内长度、面内宽度、中心、偏移、壳体厚度;圆柱端盖或完整圆柱侧面的高度。面积作为诊断结果只读展示。 | 拉伸/切除当前平面或圆柱端盖;移动当前面顶点并重建相邻面;或平移/缩放所属特征。 | 自由曲面会明确识别为只读,不开放伪参数化修改;带复杂内孔的面、复杂壳体局部区域、非完整圆柱侧面高度。 | | 孔 / 圆柱孔 | 规则孔的直径/半径、完整孔轴心、完整孔封堵、可识别盲孔/盲槽深度。 | 改孔径时同轴重切孔壁;移动孔时先补旧孔再切新孔;改深度时切削或补料孔底。 | 螺纹孔、锥孔、复杂孔组、底面识别不可靠的盲孔。 | -| 槽 / 半孔 | 槽宽、槽深、弧长、弧角、开口角、简单轴心、长圆槽总长度/中心距。 | 按圆柱槽或长圆槽胶囊区域,先补旧槽,再切出新槽。 | 复杂草图槽、非圆柱槽、多槽联动、跨复杂面的槽。 | -| 凸台 / 外圆 | 圆柱凸台的直径/半径、高度、轴心。 | 移除旧凸台包络后重建;或拉伸/切除端盖、轴向缩放。 | 异形凸台、多台阶凸台、复杂相邻拓扑。 | -| 圆角 / 倒角 | 部分已有圆角的半径/弧长;直线边新增圆角或倒角。 | 已有圆角先移除再重建;新增圆角/倒角调用 OCCT 的圆角/倒角算法。 | 复杂 blend、圆角链、支撑面不明确的圆角。 | +| 槽 / 半孔 | 槽宽、槽深、弧长、弧角、开口角、简单轴心、长圆槽总长度/中心距;规则矩形槽/口袋的长宽、中心和深度。 | 按圆柱槽、长圆槽胶囊区域或矩形切除包络,先补旧槽,再切出新槽。 | 复杂草图槽、非圆柱槽、多槽联动、跨复杂面的槽;矩形槽中心沿深度方向移动暂不处理。 | +| 凸台 / 外圆 | 圆柱凸台的直径/半径、高度、轴心;规则矩形凸台/口袋的长度、宽度、中心、高度/深度;多台阶矩形凸台的顶层规则台阶。 | 移除旧特征包络后重建;或拉伸/切除端盖、轴向缩放。 | 异形凸台、多台阶凸台整组联动、复杂相邻拓扑;矩形长宽方向交换和沿高度/深度方向移动中心暂不在一次编辑中处理。 | +| 圆角 / 倒角 | 部分已有圆角的半径/弧长;直线边新增圆角或倒角;相邻同半径圆角链可识别并快速阻止。 | 已有简单圆角先移除再重建;新增圆角/倒角调用 OCCT 的圆角/倒角算法;圆角链不进入危险重建。 | 复杂 blend、圆角链稳定重建、变半径圆角、支撑面不明确的圆角。 | | Solid / 整体特征 | 平移、旋转、包围盒尺寸、体积、表面积整体缩放。 | 对所属 Solid / 特征做整体变换。 | 装配约束、零件间关联约束、原 CAD 建模历史恢复。 | ## 当前可修改范围 @@ -245,13 +400,13 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 | 对象 / 特征 | 现在可以修改 | 暂不能保证或还不能修改 | | --- | --- | --- | -| Face | 平面 Face 可以改 `面积`、`U向尺寸`、`V向尺寸`、`中心` 和 `偏移变换`;这些行会在 `建模意图` 里选择 `局部重建`、`拉伸/切除`、`移动特征` 或 `缩放特征`。识别到相对平面时还可以改 `壳体厚度`,并通过 `建模意图` 选择 `拉伸/切除` 或 `缩放特征`。圆柱上/下端盖、同轴空心圆柱的环形端面可以通过 `拉伸/切除` 改高度;选中完整圆柱侧面时,也可以把 `高度` 换算成端盖拉伸/切除,或者选择沿轴向 `缩放特征`。简单端盖会解析重建,复杂同轴筒体会优先只处理旧端盖到新端盖之间的局部段,端盖边界上有槽口/缺口时会按真实端盖轮廓向外拉伸或在安全范围内向内切削。 | `U向尺寸/V向尺寸` 是这个 Face 自身平面里的两个方向尺寸,不是面积,也不是模型高度;`偏移变换` 是沿当前面垂直方向测到的位置,不再单独显示容易混淆的额外的“本次移动量”提示。`拉伸/切除` 会加料或切削;`局部重建` 会移动当前 Face 顶点并重建相邻平面,相邻面可能变斜;`移动特征` 会平移所属特征或 Solid;`缩放特征` 会缩放所属特征或 Solid 并影响其它尺寸;自由曲面会显示为只读受限对象,不把面积、中心、包围盒或底层 UV 参数伪装成可修改尺寸;复杂壳体局部区域、非完整圆柱侧面、非同轴多孔环形端面、端盖边界本身连着槽/缺口/台阶且向内切除越过槽底/台阶终点的面和其它带复杂边界的面仍不能稳定做局部尺寸编辑,界面会禁用或快速阻止并在提示里说明具体原因,建议改用孔/槽专门入口、`拉伸/切除` 的简单端盖路径或整体调整。 | +| Face | 平面 Face 可以改 `面内长度`、`面内宽度`、`中心` 和 `偏移`;这些行会在 `建模意图` 里选择 `局部重建`、`拉伸/切除`、`移动特征` 或 `缩放特征`。面积是结果变量,只在诊断信息里展示。识别到相对平面时还可以改 `壳体厚度`,并通过 `建模意图` 选择 `拉伸/切除` 或 `缩放特征`。圆柱上/下端盖、同轴空心圆柱的环形端面可以通过 `拉伸/切除` 改高度;选中完整圆柱侧面时,也可以把 `高度` 换算成端盖拉伸/切除,或者选择沿轴向 `缩放特征`。简单端盖会解析重建,复杂同轴筒体会优先只处理旧端盖到新端盖之间的局部段,端盖边界上有槽口/缺口时会按真实端盖轮廓向外拉伸或在安全范围内向内切削。 | `面内长度/面内宽度` 是这个 Face 自身平面里的两个方向尺寸,不是面积,也不是模型高度;`偏移` 是沿当前面垂直方向测到的位置,不再单独显示容易混淆的额外的“本次移动量”提示。`拉伸/切除` 会加料或切削;`局部重建` 会移动当前 Face 顶点并重建相邻平面,相邻面可能变斜;`移动特征` 会平移所属特征或 Solid;`缩放特征` 会缩放所属特征或 Solid 并影响其它尺寸;自由曲面会显示为只读受限对象,不把面积、中心、包围盒或底层 UV 参数伪装成可修改尺寸;复杂壳体局部区域、非完整圆柱侧面、非同轴多孔环形端面、端盖边界本身连着槽/缺口/台阶且向内切除越过槽底/台阶终点的面和其它带复杂边界的面仍不能稳定做局部尺寸编辑,界面会禁用或快速阻止并在提示里说明具体原因,建议改用孔/槽专门入口、`拉伸/切除` 的简单端盖路径或整体调整。 | | 圆柱孔 / 圆柱面 | 孔或圆柱的 `直径`、`半径`,并通过 `建模意图` 选择 `只改孔/槽壁` 或 `缩放特征`;完整圆柱孔可以改轴心坐标;完整孔可以尝试封堵。普通孔径语义是同轴圆柱重切,孔轴心语义是先填旧孔再切新孔。 | 复杂孔、螺纹孔、锥孔、不完整圆柱孔和拓扑不稳定的孔可能失败并回滚;`缩放特征` 会影响同一对象上的高度、厚度和其它尺寸,不适合只想改孔壁的场景。 | | 盲孔 / 盲槽 | 识别到疑似底面 Face 时,可以改 `盲孔/盲槽深度`,并通过 `建模意图` 选择 `改底面深度` 或 `缩放特征`。`改底面深度` 加深会沿开口到底面方向切削,变浅会从新底面到旧底面补料;`缩放特征` 会沿孔/槽轴向缩放所属特征或 Solid。 | STEP 不保存真实孔深历史;找不到可靠底面或底面被复杂拓扑切碎时,深度只能只读或需要手动辅助;`缩放特征` 会影响同一对象上的壁厚、孔距和其它轴向尺寸,不适合只想改孔底位置的场景。 | -| 槽 / 半孔 | 可以改槽宽、槽深、弧长、弧角、开口角和简单局部轴心;槽宽、槽深和弧长会在 `建模意图` 里选择 `只改槽壁` 或 `缩放特征`。长圆槽的轴心、总长度和中心距会在点击修改时自动尝试配对另一个半圆端,也可以手动填写配对端 Face ID。 | 轴心仍是受限的局部扇形槽/胶囊槽重建;复杂草图槽、非圆柱槽、多槽关联迁移、链式槽和跨多个不规则面的槽还不是稳定能力。`只改槽壁` 会局部重建槽,`缩放特征` 会缩放所属对象并影响其它尺寸;如果结果真的把一个 Solid 拆成多个 Solid,会自动回滚。 | +| 槽 / 半孔 | 可以改槽宽、槽深、弧长、弧角、开口角和简单局部轴心;槽宽、槽深和弧长会在 `建模意图` 里选择 `只改槽壁` 或 `缩放特征`。长圆槽的轴心、总长度和中心距会在点击修改时自动尝试配对另一个半圆端,也可以手动填写配对端 Face ID。规则矩形槽/口袋可以改长宽、中心和深度。 | 轴心仍是受限的局部扇形槽/胶囊槽重建;复杂草图槽、非圆柱槽、多槽关联迁移、链式槽和跨多个不规则面的槽还不是稳定能力。矩形槽/口袋会局部重建矩形切除包络,但长宽方向交换和中心沿深度方向移动暂不处理。`只改槽壁` 会局部重建槽,`缩放特征` 会缩放所属对象并影响其它尺寸;如果结果真的把一个 Solid 拆成多个 Solid,会自动回滚。 | | 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径`、`半径`、`高度` 和 `轴心` 会在 `建模意图` 里选择 `只改凸台`、`拉伸/切除端盖`、`移动凸台`、`缩放特征` 或 `移动特征`。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建;`缩放特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 | -| 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改 `圆角半径` 或 `圆角弧长`,并通过 `建模意图` 选择 `重建圆角` 或 `缩放特征`。`重建圆角` 会先移除旧圆角再重建新圆角;`缩放特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。 | 复杂 blend、支撑面不明确、圆角链或恢复锐边失败时会阻止或回滚;`缩放特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 | -| Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径、直径和 `圆心/轴心`,其中圆心移动会把圆边移动量转成相邻孔/槽/凸台的轴心移动;椭圆 Edge 可以改 `椭圆主半径` 或 `椭圆小半径`,分别沿主轴或小轴单轴缩放所属对象。 | “通用任意 Edge”仍是受限实现;复杂曲线、B-spline、和无法确定局部变形区域的 Edge 可能走高风险 fallback 或失败;圆Edge轴心移动需要找到稳定相邻圆柱特征;椭圆单轴缩放会影响同一对象上同方向的其它几何,不是恢复 CAD 草图约束。 | +| 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改 `圆角半径` 或 `圆角弧长`,并通过 `建模意图` 选择 `重建圆角` 或 `缩放特征`。`重建圆角` 会先移除旧圆角再重建新圆角;`缩放特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。相邻同半径圆角 Face 会被标成圆角链,计划阶段直接提示暂未实现稳定重建。 | 复杂 blend、支撑面不明确、圆角链稳定重建、变半径圆角或恢复锐边失败时会阻止或回滚;`缩放特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 | +| Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径、直径和 `圆心/轴心`,其中圆心移动会把圆边移动量转成相邻孔/槽/凸台的轴心移动;椭圆 Edge 可以改 `椭圆主半径` 或 `椭圆小半径`,分别沿主轴或小轴单轴缩放所属对象。 | “通用任意 Edge”仍未作为稳定能力开放;复杂曲线、B-spline、Bezier 和无法确定局部变形区域的 Edge 不会在扫描列表或参数表里显示为可改长度入口;圆Edge轴心移动需要找到稳定相邻圆柱特征;椭圆单轴缩放会影响同一对象上同方向的其它几何,不是恢复 CAD 草图约束。 | | 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径`、`参考直径` 或 `半角`:简单圆锥会解析重建,嵌入式锥孔/沉孔会优先局部重切;选中圆锥特征时会显示可识别的小端半径、大端半径、锥孔高度和半角,便于判断真实修改对象。球面可以改 `半径(缩放特征)` 或 `直径(缩放特征)`;环面可以改 `主半径(缩放特征)`、`主直径(缩放特征)`、`小半径(缩放特征)` 或 `小直径(缩放特征)`。 | B-Rep 里的 Face 可以是平面、圆柱面、圆锥面等;半角属于圆锥面属性,1° 左右的浅锥/拔模面看起来会很像平面。圆锥局部重切主要覆盖可识别的双圆边界锥孔;复杂圆锥/拔模面的参考半径、参考直径和半角暂不开放,会提前禁用或快速阻止,而不是进入危险整体缩放或布尔计算。解析曲面的识别评分过低、置信度低或存在识别限制时,属性会保留当前值展示,但修改按钮会变为不可用并显示原因。其它复杂球面和环面仍可能几何缩放所属特征或 Solid,不是恢复原 CAD 历史参数,复杂相邻拓扑可能失败或回滚。 | | Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 | | 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 | @@ -292,9 +447,9 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 圆柱面会识别为圆柱孔候选、槽/半孔候选、圆角/倒圆候选、凸台/外圆候选或未明确圆柱特征。 - 圆柱特征会先把同一实体内同轴、同半径且轴向连续/重叠的完整圆柱Face当作同一侧壁区域,再尝试找出边界Edge、相邻Face、端面Face、开口端相邻Face和疑似底面 Face,并一起高亮相关局部区域。 - 槽/半孔候选会额外显示槽宽、圆弧长度、槽深估算和槽边界相邻Face。 - - 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑Face。 -- `特征探测级别` 切到 `探测相邻特征` 或 `探测二级特征` 后,当前选中对象表会显示 `关联探测` 说明行:它会写明本次探测级别、找到的关联特征数量,以及关联孔、槽、凸台等尺寸是否已经作为 `关联 Face` 行合并到同一张参数表里。这样即使没有找到额外可改对象,也能看出程序已经完成探测,而不是界面没有反应。 -- 当前选中对象表已经从横向五列表格改成竖向参数卡片:每个参数卡片显示参数名、当前值、目标值、建模意图和单项应用按钮;识别摘要、一级关系和其它只读诊断默认折叠在 `更多诊断信息` 后面,避免左侧窄面板里文字互相挤压。 + - 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑Face;如果直接相邻同半径圆角 Face 形成圆角链,会标出链内 Face 并在修改半径/弧长前快速阻止。 +- `特征探测级别` 切到 `探测相邻特征` 或 `探测二级特征` 后,关联孔、槽、凸台等可改尺寸可以作为 `关联 Face` 参数行合并到同一张参数表里;`关联探测` 本身属于诊断信息,不占用特征参数表。 +- 当前选中对象表使用四列表格:`尺寸参数`、`当前值`、`建模意图` 和 `目标值`。表格只放可作为建模输入的尺寸,面积、识别摘要、一级关系、关联探测和其它只读诊断放在下方 `诊断信息(高级)` 中;用户改完目标值后统一点击 `参数化建模`,不再保留逐行执行入口。 - 属性面板会统一显示 `识别摘要`、`识别候选`、`识别置信度`、`识别风险`、`识别评分`、`识别结论`、`识别依据`、`识别限制` 和 `受限能力`。这些字段来自当前已知的 B-Rep 几何事实、一级拓扑关系、同域区域、材料采样和阻止原因,用来说明程序为什么把对象当作某类候选,而不是把猜测隐藏在代码里。`识别限制` 表示这个候选整体不能稳定编辑;`受限能力` 表示对象本身可识别、某些修改可用,但另一些子能力暂不开放。低置信解析曲面不会开放可编辑按钮,避免把“底层曲面类型像圆锥/球/环面”误当成稳定 CAD 设计参数。 - 模型层提供统一的一级拓扑事实包 `face_first_level_facts()`:它把当前主体 Face 区域、边界 Edge、边界 Vertex、共享边直接相邻 Face、角色分组和暂不传播的二/三级关系整理成同一份事实证据。当前 Face、孔、槽等编辑计划也会携带这份事实包;`只改当前 Face` 这类局部重建计划会在执行前先检查一级事实图是否 ready,若无法确认当前 Face、边界 Edge/Vertex 和共享边相邻 Face,会直接 blocked 并说明原因。Face 编辑成功后还会重建目标 Face 的一级事实图做结果校验,若目标值虽然达到但一级事实图不再成立,会自动回滚。后续孔、槽、凸台、圆角等候选识别应优先使用这份事实包,再叠加各自的语义判断和置信度。 - Edge 阶段新增统一的一级拓扑事实包 `edge_first_level_topology()` / `edge_first_level_facts()`:它把当前 Edge、端点 Vertex、共享端点的相邻 Edge、直接包含该 Edge 的相邻 Face、角色分组和暂不传播的二/三级关系整理成同一份事实证据。当前 Edge 长度、起点/中心/终点坐标、圆角、倒角、圆边轴心和椭圆半径等编辑计划会携带这份事实包,后续 Edge 一级策略应优先用它判断“只改当前边、移动端面、相邻圆柱或缩放所属对象”的执行影响。 @@ -464,7 +619,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - `长度基准` 行可以选择自动、中心、固定起点、固定终点。 - `长度` 行的 `建模意图` 可以选择 `自动`、`只改当前Edge`、`移动端面`、`相邻圆柱`、`缩放所属`。 - `只改当前Edge` 会强制使用局部边形变;如果当前Edge不满足简单全平面多面体条件,会直接阻止,不会偷偷改成整体缩放。 - - `移动端面` 会强制找端点附近、方向匹配的平面端面并做端面拉伸/切除;对正方体这类模型,会得到更像“变成长方体”的结果。如果找不到端面拉伸/切除路径,会直接阻止。 + - `移动端面` 会强制找端点附近、方向匹配的平面端面并做端面拉伸/切除;对正方体这类模型,会得到更像“变成长方体”的结果。计划阶段会确认直接相邻面和移动端面能形成可验证的垂直关系;如果找不到端面拉伸/切除路径或关系不可验证,会直接阻止。 - `缩放所属` 会强制对所属特征或 Solid 做轴向/整体缩放,是高风险兜底语义,会影响其它尺寸。 - 对直线Edge,当前选中对象表会额外提供 `起点`、`中心` 和 `终点`,可以直接输入目标 X/Y/Z 坐标来移动单个端点或整条边。 - 对正方体这类全平面多面体,直线Edge会优先走“局部边形变”:只移动被选Edge的一个端点或两个端点,并重建周边平面。 @@ -475,10 +630,10 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 圆形/圆弧Edge会优先检查相邻圆柱面;如果能复用孔/槽或凸台直径编辑,就把Edge长度按比例换算成目标圆柱直径再执行局部编辑。 - 圆边没有可复用圆柱面时,会优先围绕圆边轴线做径向缩放,尽量只改半径方向并保留轴向尺寸;这类一般仿射可能把解析圆/圆锥/圆柱几何转换为 B-spline。 - 椭圆 Edge 的 `椭圆主半径` / `椭圆小半径` 是单独的属性行:主半径会沿椭圆主轴方向单轴缩放,小半径会沿小轴方向单轴缩放;另一个半径方向尽量不动。 - - 如果直接改椭圆 Edge 的 `长度`,或选择其它可稳定采样为平面的 B-spline / Bezier 等非直线边,仍会尝试围绕曲线所在平面法向做径向缩放;如果采样点不够平面,会退回更保守的缩放 fallback。 - - 几何缩放 fallback 会先只预变换目标Edge来估算结果长度,并在必要时微调缩放比例,让实际边长更接近输入值。 - - `中心`、没有明确端面、或非圆边没有更明确局部路径时,会尝试整体/轴向几何缩放 fallback,以覆盖更多任意Edge的最小可用场景。 - - 该 fallback 会影响所属零件/Solid的其它尺寸,因此会按高风险确认,并依赖失败回滚/撤销兜底。 + - 椭圆 Edge 不再把通用 `长度` 作为默认可改入口;界面只开放语义更清楚的主半径/小半径。B-spline / Bezier 等复杂曲线 Edge 暂不在参数表里显示可改长度,避免把实验性 fallback 误当成稳定 CAD 参数。 + - 几何缩放 fallback 会先只预变换目标Edge来估算结果长度,并在必要时微调缩放比例,让实际边长更接近输入值;这条路径保留作内部回归和后备,不作为复杂曲线Edge的稳定界面入口。 + - `中心`、没有明确端面、或非圆边没有更明确局部路径时,后端仍可能进入整体/轴向几何缩放 fallback,但当前参数表会优先隐藏未稳定的复杂曲线长度入口。 + - 该 fallback 会影响所属零件/Solid的其它尺寸,因此必须按高风险处理,并依赖失败回滚/撤销兜底。 - 这仍不是通用参数化边长编辑;复杂拓扑、斜面过渡、圆角链或布尔失败场景仍可能不能稳定修改。 - 支持零件 / Solid 级平移: - 输入 X/Y/Z 平移量后,可以平移当前选中零件。 @@ -564,8 +719,8 @@ vertices: 3262 - 复杂特征识别:已经有平面、圆柱、边、孔/槽候选、槽/半孔候选、已有圆角候选、圆柱凸台候选等基础候选列表,但还不是完整 CAD 语义级识别。 - 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面 Face的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。 -- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度/深度/圆弧长度/圆弧角度/开口角度调整、槽宽/槽深/弧长的整体缩放所属对象语义、长圆槽总长度/中心距执行时自动配对和手动配对端 Face ID、完整圆柱凸台直径/半径/高度/轴心坐标修改、普通完整圆柱高度兜底拉伸/切除或通过 `建模意图` 做轴向整体缩放,以及平面壳体相对面厚度估算、局部厚度拉伸/切除和通过 `建模意图` 做厚度方向整体缩放;复杂槽、复杂凸台和完整壳体区域仍需要继续做。 -- 已有圆角/倒圆面半径:已经有当前版本的受限修改实现,使用 defeature + refillet 路线,但复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。 +- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度/深度/圆弧长度/圆弧角度/开口角度调整、槽宽/槽深/弧长的整体缩放所属对象语义、长圆槽总长度/中心距执行时自动配对和手动配对端 Face ID、完整圆柱凸台直径/半径/高度/轴心坐标修改、规则矩形凸台/口袋长度/宽度/中心/高度/深度局部重建、多台阶矩形凸台顶层规则台阶独立编辑、普通完整圆柱高度兜底拉伸/切除或通过 `建模意图` 做轴向整体缩放,以及平面壳体相对面厚度估算、局部厚度拉伸/切除和通过 `建模意图` 做厚度方向整体缩放;复杂槽、复杂凸台整体联动和完整壳体区域仍需要继续做。 +- 已有圆角/倒圆面半径:已经有当前版本的受限修改实现,使用 defeature + refillet 路线;直接相连的同半径圆角链会在识别层标成受限能力,不作为可编辑圆角项列出,选中后也会在计划阶段说明暂未实现稳定重建;复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。 - 局部偏差分析:已经有编辑前后体积、面积、包围盒、距离热力图和差异报告,但还不是工程公差级的局部区域偏差报告。 - 错误恢复和模型修复:已经有编辑前快照、失败回滚、基础 B-Rep 校验、同域面合并、布尔结果自动修复,以及一键修复当前模型或选中零件/solid,但还不是完整的模型修复流水线。 - 多零件装配:已经有装配/零件树和单独导出入口,但还没有做大规模、多来源 STEP 装配测试。 @@ -577,14 +732,7 @@ vertices: 3262 ## 下一步要实现什么 -当前已经有按阶段跑通的一级编辑基线,但这还不等于 CAD 级完成。后续继续按这个顺序深化:每一类先用小模型和默认大模型保持回归通过,再拿模型工程师的真实 STEP 失败项补识别、补守门、补局部重建策略。 - -1. 继续打磨 Face 一级拓扑关系编辑:面积、U向尺寸/V向尺寸、中心、偏移变换和壳体厚度,都要明确 `局部重建`、`拉伸/切除`、`移动特征`、`缩放特征` 等语义;当前阶段只传播当前 Face/同域碎片和共享边相邻 Face,不递归处理二级、三级关系。 -2. 继续打磨槽/孔类特征的一级关系:槽宽、槽深、弧长、弧角、开口角、轴心、长圆槽总长度、中心距、孔径、孔轴心和盲孔/盲槽深度要统一验证。 -3. 继续打磨 Edge 一级关系:直线 Edge 长度、起点、中心、终点和倒圆/倒角语义先做稳定,再处理圆弧 Edge、椭圆 Edge 和高风险 fallback;当前 Edge 编辑计划已经开始携带统一的一级端点/相邻边/相邻 Face 事实包。 -4. 继续打磨凸台、已有圆角、圆锥/球/环面、壳体局部区域等对象;每类都先补一级关系能力和验证,再进入下一类。 -5. 把更多“同一目标值有多种合理语义”的场景做成用户可选策略。每个策略都要说明改法、固定约束、影响对象、哪些相邻几何会跟随变化。 -6. 继续做性能和稳定性优化,重点是扫描分批刷新、局部显示重建缓存,以及高风险 OCC 操作隔离。 +当前已经有按阶段跑通的一级编辑基线,但这还不等于 CAD 级完成。后续按上面的“参数化编辑路线图”继续推进:`[x]` 项保持回归和真实 STEP 验证,`[~]` 项优先补守门、补失败解释、补更多模型样例,`[ ]` 项不能提前在 UI 里伪装成稳定能力。每一类先用小模型和默认大模型保持回归通过,再拿模型工程师的真实 STEP 失败项补识别、补守门、补局部重建策略。 当前整体验证基线: @@ -592,16 +740,16 @@ vertices: 3262 - 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage hole-slot`,覆盖孔/槽一级拓扑、局部重建和 isolated worker。 - 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage edge`,覆盖 Edge 一级拓扑、长度建模意图、坐标修改、圆角/倒角、椭圆 Edge 和 isolated worker。 - 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --stage boss --stage round-chamfer --stage shell --stage analytic`,覆盖凸台、圆角/倒角、壳体厚度和解析曲面。 -- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --quick`,覆盖 smoke test、属性表规格、参数卡片 UI、一级事实图、关联探测、显示网格预算和一级验收文档一致性。 +- 2026-08-06,在 `pyocc` 环境下已通过 `python scripts\verify_first_level_edit_suites.py --quick`,覆盖 smoke test、属性表规格、参数表 UI、特征识别优先级、一级事实图、关联探测、显示网格预算和一级验收文档一致性。 Face 阶段的当前验收口径: - 当前阶段的 `一级关系` 定义为:选中 Face 本身、必要的同域/共面碎片 Face、这些 Face 的边界 Edge/Vertex,以及与该区域共享边的直接相邻 Face。只共享顶点的对象、相邻 Face 再连出去的 Face 都不作为当前阶段的自动传播范围。 -- 选中平面 Face 时,`当前选中对象` 的参数表会显示 `一级关系` 说明行,直接列出当前 Face 区域数量、边界 Edge/Vertex 数量、共享边相邻 Face 数量,并提示二级、三级关系暂不自动传播。 -- `python scripts\verify_face_edit_suite.py` 必须通过,集中覆盖面积、U向尺寸/V向尺寸、中心、偏移变换、壳体厚度、拉伸/切除、局部变形禁用、目标值保护、属性回读、逻辑 Face ID 保持,以及 UI 里标题/复制 ID/按 ID 选择是否优先使用逻辑 Face ID。 -- `python scripts\verify_property_editor_specs.py` 必须通过,确保平面 Face 的属性表使用 `面积`、`U向尺寸`、`V向尺寸`、`中心`、`偏移变换` 这些用户可理解的名称,并递归检查提示文字里不再出现容易误解的旧词。 -- `python scripts\verify_face_first_level_topology.py` 必须通过,确保普通正方体 Face 的一级关系能识别 4 条边界 Edge、4 个边界 Vertex、4 个共享边相邻 Face,属性表能显示这份一级关系摘要,并证明局部移动后目标 Face 的边界 Vertex 与共享边 Edge 已到新位置、一级侧面跟随重建、二级底面不跟随移动。 -- `python scripts\verify_cylindrical_first_level_topology.py` 必须通过,确保孔/槽类圆柱特征的计划层已经能识别圆柱侧壁、边界 Edge/Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。特征参数表也会显示圆柱特征的 `一级关系` 摘要,让用户知道当前孔/槽修改会围绕哪些直接邻域执行;结果校验会拒绝虽然尺寸匹配但一级邻域明显弱于计划要求的圆柱结果。后续继续把这些一级关系用于更细的孔/槽实际重建策略。 +- 选中平面 Face 时,程序会生成 `一级关系` 事实包,记录当前 Face 区域数量、边界 Edge/Vertex 数量、共享边相邻 Face 数量,并明确二级、三级关系暂不自动传播;这些事实用于计划、校验和诊断,不再作为特征参数行显示。 +- `python scripts\verify_face_edit_suite.py` 必须通过,集中覆盖面内长度/面内宽度、中心、偏移、壳体厚度、拉伸/切除、局部变形禁用、目标值保护、属性回读、逻辑 Face ID 保持,以及 UI 里标题/复制 ID/按 ID 选择是否优先使用逻辑 Face ID。 +- `python scripts\verify_property_editor_specs.py` 必须通过,确保平面 Face 的特征参数表使用 `面内长度`、`面内宽度`、`中心`、`偏移` 这些用户可理解的名称,且不把面积暴露成可编辑驱动参数,并递归检查提示文字里不再出现容易误解的旧词。 +- `python scripts\verify_face_first_level_topology.py` 必须通过,确保普通正方体 Face 的一级关系能识别 4 条边界 Edge、4 个边界 Vertex、4 个共享边相邻 Face,并证明局部移动后目标 Face 的边界 Vertex 与共享边 Edge 已到新位置、一级侧面跟随重建、二级底面不跟随移动。 +- `python scripts\verify_cylindrical_first_level_topology.py` 必须通过,确保孔/槽类圆柱特征的计划层已经能识别圆柱侧壁、边界 Edge/Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。一级关系用于计划、校验和诊断,不再占用特征参数表;结果校验会拒绝虽然尺寸匹配但一级邻域明显弱于计划要求的圆柱结果。后续继续把这些一级关系用于更细的孔/槽实际重建策略。 - `python scripts\verify_hole_slot_edit_suite.py` 必须通过,集中覆盖孔/槽一级拓扑、孔径/孔轴心/孔封堵、盲孔/盲槽深度、槽宽/槽深/弧长/弧角、长圆槽总长度/中心距、隔离执行、逻辑 Face ID 保持和属性表分组。 - `python scripts\verify_boss_edit_suite.py` 必须通过,集中覆盖凸台一级拓扑、凸台直径/高度/轴心局部重建、圆 Edge 代理到凸台轴心移动,以及属性表分组。 - `python scripts\verify_round_chamfer_edit_suite.py` 必须通过,集中覆盖新增圆角/倒角、已有圆角重建、结果反查、失败回滚和一级邻域守门。 @@ -618,6 +766,7 @@ Face 阶段的当前验收口径: - 鼠标左键拖动:旋转模型。 - 鼠标滚轮:缩放模型。 - 鼠标中键或组合拖动:平移模型,具体取决于 VTK 默认交互方式。 +- 只有明确单击模型时才会选择对象;左键拖动旋转后即使释放点落在某个 Face 上,也不会自动改选。 左侧操作面板按功能分区排列,每个模块都有四边同色的独立边框和颜色提示,顺序大致按工作流排列:先打开和选择模型,再做编辑或导出,然后进行显示控制和测量,后面继续放可编辑对象、圆柱面候选、操作历史和对象信息。 @@ -679,7 +828,7 @@ Face 阶段的当前验收口径: - 点击 `深度扫描` 会用更高上限重新扫描,可能需要等待更久。 - 为了避免打开大模型时界面卡住,程序不会在加载 STEP 时自动扫描;需要编辑时再手动点击扫描。 - 当前默认显示一批代表性的可编辑对象,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。 -- `拉伸/切除平面` 行表示这个Face是平面;当前主要在 `当前选中对象` 的 `偏移变换` 行里把 `建模意图` 选为 `拉伸/切除` 来执行。 +- `拉伸/切除平面` 行表示这个Face是平面;当前主要在 `当前选中对象` 的 `偏移` 行里把 `建模意图` 选为 `拉伸/切除` 来执行。 - `调整圆柱孔径` 行表示这个 Face 是圆柱面候选,可以配合 `孔直径` 和 `调整圆柱孔径` 使用。 - `调整槽/半孔宽度` / `调整槽/半孔深度` / `调整槽/半孔圆弧长度` 行表示这个Face是槽/半孔候选,可以在当前选中对象表里修改槽宽、槽深或圆弧长度。 - `调整圆柱凸台直径` / `调整圆柱凸台高度` 行表示这个Face是较明确的完整圆柱凸台候选,可以在当前选中对象表里修改凸台直径、半径、高度或轴心坐标。 @@ -730,20 +879,20 @@ Face 阶段的当前验收口径: 编辑: - 编辑按钮会根据当前选择自动启用或禁用。例如未选中平面时不能点击 `拉伸/切除平面`,未选中直线Edge时不能点击 `给Edge添加圆角` / `给Edge添加倒角`,未选中明确孔/凸台/盲孔候选时对应圆柱编辑按钮会禁用。 -- `偏移变换`: +- `偏移`: - 先选择一个平面 Face。 - - 在当前选中对象表里修改 `偏移变换`,再用 `建模意图` 选择改法。 - - `偏移变换` 是沿当前面垂直方向测到的位置,不是面积,不是移动距离,也不是 X/Y/Z 坐标。 + - 在当前选中对象表里修改 `偏移`,再用 `建模意图` 选择改法。 + - `偏移` 是沿当前面垂直方向测到的位置,不是面积,不是移动距离,也不是 X/Y/Z 坐标。 - `拉伸/切除` 会把目标位置换算成这次要移动的距离,然后沿当前面的垂直方向加料或切削,适合改变厚度、拉出凸起或切入凹槽。 - `局部重建` 会把当前 Face 移到目标位置,并重建相邻平面;相邻面可能变斜。 - `移动特征` 会沿当前面垂直方向平移所属特征或 Solid;形状和内部尺寸不变。 - 如果选中Face周围有共面且相接/重叠的碎面,`拉伸/切除` 会自动把这些Face合成同一片拉伸/切除区域,属性表和历史记录会显示 `拉伸/切除共面区域 Face 数`。 - 当前拉伸/切除方向判断通过Solid的 inside / outside 采样得到;如果采样不明确,会退回到拓扑方向法向,并在属性里显示低置信度说明。 -- `面积` / `U向尺寸` / `V向尺寸`: +- `面内长度` / `面内宽度`: - 先选择一个 Face,在当前选中对象表里修改目标值,再用 `建模意图` 选择改法。 - `局部重建` 会在当前 Face 所在平面内移动顶点并重建周边相邻平面;当前只对简单全平面实体开放。 - `缩放特征` 会缩放所属特征或 Solid;同一对象上的孔、槽、凸台、厚度和其它间距会跟着变化。 - - `U向尺寸` / `V向尺寸` 是当前 Face 在自身平面内两个稳定方向上的投影尺寸,不是面积,不是模型整体高度,也不是全局 X/Y/Z 包围盒尺寸。 + - `面内长度` / `面内宽度` 是当前 Face 在自身平面内两个稳定方向上的投影尺寸,不是面积,不是模型整体高度,也不是全局 X/Y/Z 包围盒尺寸。面积会在诊断信息中作为结果值展示。 - `中心`: - 在当前选中对象表里输入目标 X, Y, Z 坐标,再用 `建模意图` 选择改法。 - `局部重建` 会移动当前 Face 的顶点并重建相邻平面;相邻面可能自然变斜,必要时非共面面会拆成三角面。 @@ -779,11 +928,11 @@ Face 阶段的当前验收口径: - 这一步仍是 B-Rep 几何近似;复杂槽、非圆柱槽或圆弧角度不稳定时可能失败并回滚。 - `槽/半孔深度`: - 先选择一个槽/半孔候选 Face。 - - 在当前选中对象表里修改 `槽/半孔深度`;目标值变化后,该行 `操作` 按钮会从 `未改动` 变成 `应用`,点击它或点击 `参数化建模`。 + - 在特征参数表里修改 `槽/半孔深度`,然后点击 `参数化建模`。 - 程序会按当前圆弧角度把目标槽深换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 - `槽/半孔圆弧长度`: - 先选择一个槽/半孔候选 Face。 - - 在当前选中对象表里修改 `槽/半孔圆弧长度`;目标值变化后,点击该行的 `应用` 或点击 `参数化建模`。 + - 在特征参数表里修改 `槽/半孔圆弧长度`,然后点击 `参数化建模`。 - 程序会按当前圆弧角度把目标圆弧长度换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 - `槽宽` / `槽深` / `弧长` 的 `缩放特征`: - 先选择一个槽/半孔候选 Face。 @@ -812,7 +961,7 @@ Face 阶段的当前验收口径: - `壳体厚度` 的 `拉伸/切除`: - 先选择一个已识别到相对平面的壳体平面 Face。 - 在当前选中对象表里修改 `壳体厚度`,并把 `建模意图` 设为 `拉伸/切除`。 - - 输入目标厚度后点击该行的 `应用` 或点击 `参数化建模`。 + - 输入目标厚度后点击 `参数化建模`。 - 程序会移动当前平面区域,让它和相对平面的距离接近目标厚度;相对平面保持不动。 - 如果相对平面重叠率低、厚度变化过大或拉伸/切除方向不稳定,会提高风险或阻止执行。 - 这一步仍是局部 B-Rep 平面拉伸/切除近似,不是完整 CAD 壳体特征参数编辑。 @@ -835,7 +984,7 @@ Face 阶段的当前验收口径: - 这会连带影响所属对象上的高度、厚度和其它尺寸,需要确认这正是你的建模意图。 - `凸台高度`: - 先选择一个完整圆柱凸台 Face。 - - 在当前选中对象表里修改 `高度`,并把 `建模意图` 设为 `拉伸/切除端盖`,点击该行的 `应用` 或点击 `参数化建模`。 + - 在当前选中对象表里修改 `高度`,并把 `建模意图` 设为 `拉伸/切除端盖`,然后点击 `参数化建模`。 - 程序会尝试找到凸台外端盖 Face,并通过拉伸/切除端盖改变高度。 - `高度` 的 `缩放特征`: - 先选择一个完整圆柱凸台或普通完整圆柱 Face。 @@ -844,7 +993,7 @@ Face 阶段的当前验收口径: - 这不是端盖拉伸/切除;同一对象上的孔距、台阶位置和其它轴向尺寸也会跟随变化。 - `轴心`: - 先选择一个完整圆柱凸台 Face。 - - 在当前选中对象表里修改 `轴心`,并把 `建模意图` 设为 `移动凸台`,点击该行的 `应用` 或点击 `参数化建模`。 + - 在当前选中对象表里修改 `轴心`,并把 `建模意图` 设为 `移动凸台`,然后点击 `参数化建模`。 - 程序会切掉旧凸台包络,再按原直径和原高度范围在目标轴心位置补出新凸台;目标位置离基体太远时风险会升高。 - `轴心` 的 `移动特征`: - 同样输入目标 X, Y, Z 坐标。 @@ -870,7 +1019,7 @@ Face 阶段的当前验收口径: - `盲孔/盲槽深度` 的 `改底面深度`: - 先选择一个盲孔或盲槽的圆柱面,建议从 `可编辑对象` 中点击 `调整盲孔深度` 行进入。 - 在当前选中对象表里修改 `盲孔/盲槽深度`,并把 `建模意图` 设为 `改底面深度`。 - - 输入目标深度后点击该行的 `应用` 或点击 `参数化建模`。 + - 输入目标深度后点击 `参数化建模`。 - 目标深度大于当前估算深度时,程序会用有限长度 cutter 沿孔方向加深切削,预览显示为红色。 - 目标深度小于当前估算深度时,程序会在旧底面方向补料,让孔变浅,预览显示为绿色。 - 目标深度必须大于 0;如果和当前估算深度几乎相同,会直接阻止。 @@ -895,7 +1044,7 @@ Face 阶段的当前验收口径: - `圆角半径` / `圆角弧长` 的 `重建圆角`: - 先切换到 `特征` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。 - 在当前选中对象表里修改 `圆角半径` 或 `圆角弧长`,并把 `建模意图` 设为 `重建圆角`。 - - 输入目标值后点击该行的 `应用` 或点击 `参数化建模`。 + - 输入目标值后点击 `参数化建模`。 - 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。 - 通过检查后,会显示蓝色半透明预览,表示将尝试移除并重建的已有圆角面。 - 真实编辑会先用 OCCT defeaturing 移除已有圆角面,再从恢复出的候选锐边中寻找可重新倒圆的边并调用 OCCT 倒圆 API。 @@ -922,18 +1071,18 @@ Face 阶段的当前验收口径: - 选中Edge后,`长度` 会自动填入当前Edge长度。 - 当前选中对象表中的 `长度基准` 行可以选择 `自动`、`中心`、`固定起点` 或 `固定终点`。 - `长度` 行的 `建模意图` 下拉框可以选择 `自动`、`只改当前Edge`、`移动端面`、`相邻圆柱` 或 `缩放所属`。 - - 把 `长度` 改成目标值后点击这一行的按钮。 - - 如果选中的是直线Edge,也可以在当前选中对象表中直接修改 `起点`、`中心` 或 `终点`,格式是 `X, Y, Z`,然后点击该行的 `应用` 按钮。 + - 把 `长度` 改成目标值后点击 `参数化建模`。 + - 如果选中的是直线Edge,也可以在当前选中对象表中直接修改 `起点`、`中心` 或 `终点`,格式是 `X, Y, Z`,然后点击 `参数化建模`。 - 修改 `中心` 会保持这条直线Edge长度不变,两个端点一起移动;修改起点或终点则只移动对应端点。 - 对 `assets/models/cube_10mm.step` 这类全平面多面体,选择 `只改当前Edge` 时会只移动被选Edge的端点并重建周边面。这时正方体不会变成长方体;相邻面会跟着被移动的边自然变形,四点非平面面会优先重建成连续曲面,不再默认拆出面内对角线。 - - 对同一个正方体,选择 `移动端面` 时会把改边长转成端面拉伸/切除,相关平行边会一起变化,结果更像从正方体变成长方体。 + - 对同一个正方体,选择 `移动端面` 时会把改边长转成端面拉伸/切除,相关平行边会一起变化,结果更像从正方体变成长方体;执行后会反查目标 Edge、相邻侧面和移动端面仍满足一级平面关系。 - 如果确认窗口里的策略是 `local-edge-only-deform`,说明走的就是“只改这条边”的局部形变路径。 - 如果确认窗口里的策略是 `local-edge-endpoint-deform`,说明走的是“移动起点/终点坐标”的局部形变路径。 - 如果确认窗口里的策略是 `local-edge-center-deform`,说明走的是“移动整条Edge中心”的局部形变路径。 - 如果模型不是简单全平面多面体,直线Edge会继续尝试端面拉伸/切除后备路径,并计算需要移动的端面Face和拉伸/切除距离。 - 圆形/圆弧Edge会优先尝试复用相邻圆柱面的孔/槽或凸台直径编辑,并在确认窗口显示换算出来的目标直径。 - 如果圆边没有可复用圆柱面,但能读取圆心和轴线,会走 `围绕圆边轴线径向缩放` 后备策略,尽量不改变轴向厚度/高度;执行后部分解析几何可能变成 B-spline。 - - 椭圆边、平面 B-spline / Bezier 边会尝试走 `围绕平面曲线法向径向缩放`;确认窗口会显示平面采样偏差,偏差不稳定时会回退到更保守的缩放 fallback。 + - 椭圆边优先通过 `椭圆主半径` / `椭圆小半径` 修改;B-spline / Bezier 等复杂曲线边暂不作为界面里的稳定长度修改入口。 - `固定起点` 会让起点不动、移动终点;`固定终点` 相反;`中心` 会让两端各移动一半,只有进入缩放 fallback 时才会变成以Edge中心为基准缩放。 - 如果没有明确端面或可复用圆柱面,程序会按所选基准尝试整体/轴向几何缩放 fallback;这会影响所属零件/Solid的其它尺寸,因此会弹出高风险确认。 - 执行成功后会做后置边长校验:在刷新后的零件/Solid范围内寻找最接近目标长度的Edge,并把实际最近边长、误差和相对误差写入操作结果。 @@ -1021,31 +1170,31 @@ pythonocc-step-editor/ verify_edge_intent_specs.py # 验证 Edge 长度建模意图下拉项和底层策略映射一致 verify_ellipse_edge_resize.py # 临时生成椭圆 STEP 并验证椭圆主半径/小半径单轴缩放 verify_edge_length_resize.py # 验证立方体 Edge 长度局部形变基线 - verify_edge_round_chamfer.py # 临时生成盒子 STEP 并验证 Edge 圆角/倒角/已有圆角半径修改 + verify_edge_round_chamfer.py # 临时生成盒子/已有圆角/圆角链 STEP 并验证 Edge 圆角、倒角、已有圆角修改和圆角链守门 verify_round_chamfer_edit_suite.py # 一键运行圆角/倒角专项修改验证 verify_edge_isolated_edit.py # 通过 isolated worker 真实验证 Edge 一级编辑隔离执行 verify_face_coplanar_push_pull.py # 验证共面碎面会作为一个 Face 区域一起拉伸/切除 verify_face_complex_boundary_guard.py # 验证带内孔平面 Face 会禁用局部重建的局部变形,但保留向外拉伸和向内切削 verify_face_edit_suite.py # 一键运行 Face 专项修改验证 - verify_face_extreme_target_guards.py # 验证 Face 面积、U向尺寸/V向尺寸、偏移变换和壳体厚度不能输入过大或过小的极端目标值 + verify_face_extreme_target_guards.py # 验证 Face 面内尺寸、偏移和壳体厚度不能输入过大或过小的极端目标值 verify_face_freeform_guard.py # 验证 B-spline 等自由曲面 Face 只显示只读限制说明,不暴露伪参数化编辑入口 - verify_face_invalid_target_guards.py # 验证 Face 面积、U向尺寸/V向尺寸 和壳体厚度不能输入非数字、0 或负数 + verify_face_invalid_target_guards.py # 验证 Face 面内尺寸和壳体厚度不能输入非数字、0 或负数 verify_large_stepped_cap_push_pull.py # 验证大 STEP 里的同轴阶梯环形端盖拉伸/切除走局部 shell 重建而不是 180s 布尔慢路径 verify_face_logical_selection_retention.py # 验证 Face 修改后逻辑 ID 仍指向被编辑的新面 verify_face_mixed_surface_guard.py # 验证圆柱端盖这类平面 Face 会禁用局部变形,但保留拉伸/切除 - verify_face_nonrectangular_local_edit.py # 验证三角平面 Face 的局部/整体中心、面积、U向尺寸/V向尺寸、偏移变换和拉伸/切除 + verify_face_nonrectangular_local_edit.py # 验证三角平面 Face 的局部/整体中心、面内尺寸、偏移和拉伸/切除 verify_face_noop_guards.py # 验证 Face 目标值不变时会被阻止 verify_first_level_fact_graph.py # 验证平面、孔、槽会输出统一一级拓扑事实包,且编辑计划会携带这份事实 - verify_feature_recognition_summary.py # 验证平面、孔、槽候选会输出统一识别摘要、置信度、风险和证据 + verify_feature_recognition_summary.py # 验证平面、孔、槽、凸台候选会输出统一识别摘要、常用优先级、置信度、风险和证据 verify_face_property_readback.py # 验证 Face 编辑完成后属性表当前值和目标值会刷新到新几何 verify_face_push_pull_limits.py # 验证 Face 向内切除不会允许切穿实体厚度 verify_face_ui_isolation_contract.py # 验证 Face UI 入口和 isolated worker 的隔离操作列表一致 - verify_face_rectangular_axes.py # 验证矩形 Face 的U向尺寸 和V向尺寸 方向不会混淆 + verify_face_rectangular_axes.py # 验证矩形 Face 的面内长度和面内宽度方向不会混淆 verify_face_resize_semantics.py # 验证 Face 当前面/整体尺寸编辑语义 verify_hole_slot_edit_suite.py # 一键运行孔/槽专项修改验证 verify_hole_resize.py # 临时生成通孔/盲孔 STEP 并验证孔径/孔轴心/盲孔深度修改 verify_property_editor_specs.py # 验证属性表不会把通用 Face 编辑混入孔/槽/凸台/圆角/解析曲面特征 - verify_property_card_editor_ui.py # 验证参数卡片 UI 能构建、检测目标值修改并展开诊断信息 + verify_property_card_editor_ui.py # 验证参数表 UI 能构建、检测目标值修改并展开完整参数 verify_shell_edit_suite.py # 一键运行壳体厚度专项修改验证 verify_shell_thickness_resize.py # 临时生成薄板 STEP 并验证壳体厚度局部/整体修改 verify_slot_resize.py # 临时生成槽 STEP 并验证槽宽/槽深/弧长/弧角/半圆槽轴心/长圆槽轴心/总长度/中心距修改 @@ -1161,7 +1310,7 @@ git diff --check - `assets/screenshots/`:调试截图和问题截图,默认忽略,不提交。 - `scripts/generate_cube_step.py`:生成 `assets/models/cube_10mm.step`。 - `scripts/verify_first_level_acceptance_docs.py`:验证 README 里声明的一级验收口径、阶段命令和脚本清单仍然能对上 `verify_first_level_edit_suites.py`,避免目标文档和实际验证入口脱节。 -- `scripts/verify_first_level_edit_suites.py`:一级编辑总验证入口。默认按 Face、孔/槽、Edge、凸台、圆角/倒角、壳体和解析曲面阶段串起现有专项套件;`--quick` 跑烟测、属性表、参数卡片 UI、一级事实图、关联探测、显示网格预算和验收文档一致性;`--stage edge` 这类参数可只跑某个阶段。 +- `scripts/verify_first_level_edit_suites.py`:一级编辑总验证入口。默认按 Face、孔/槽、Edge、凸台、圆角/倒角、壳体和解析曲面阶段串起现有专项套件;`--quick` 跑烟测、属性表、参数表 UI、一级事实图、关联探测、显示网格预算和验收文档一致性;`--stage edge` 这类参数可只跑某个阶段。 - `scripts/verify_analytic_surface_resize.py`:临时生成圆锥、球面和环面 STEP,验证简单圆锥参考半径/半角解析重建,以及球面半径、环面主半径和环面小半径整体缩放。 - `scripts/verify_cone_semi_angle_isolation.py`:临时生成简单圆锥、旋转圆锥、嵌入式沉头锥孔和带圆柱通孔的沉头锥孔,验证圆锥半角/参考半径的大参数修改会走解析重建或锥孔局部重切;同时覆盖非端面参考半径的局部重切推导、圆锥特征的小端/大端/高度/半角信息、真实模型复杂浅锥大幅半角修改的提前阻断、`1499.5 -> 1700` 这类参考半径修改的快速阻止,以及隔离子进程保护主界面。 - `scripts/verify_boss_resize.py`:临时生成底板加圆柱凸台 STEP,验证凸台直径扩大、直径缩小、高度修改和轴心移动的局部重建链路,并断言不会把单 Solid 拆成多个 Solid。 @@ -1173,40 +1322,40 @@ git diff --check - `scripts/verify_edge_coordinate_edit.py`:加载 `cube_10mm.step`,分别验证直线 Edge 起点、中心和终点坐标修改,确认局部重建后仍能找到目标端点位置的直线 Edge,并保持单 Solid。 - `scripts/verify_edge_target_guards.py`:验证 Edge 长度、端点、中心、圆角、倒角、椭圆半径和圆 Edge 轴心这几类参数在目标值等于当前值、0/负数、退化或明显过大时会直接返回 blocked,并给出阻止原因,不进入真实几何计算。 - `scripts/verify_edge_intent_specs.py`:验证当前选中对象表里 Edge `长度` 行的 `自动`、`只改当前Edge`、`移动端面`、`相邻圆柱`、`缩放所属` 五种建模意图都能正确同步到底层 `strategy_mode`,并确认计划摘要会解释“邻面变斜”“端面跟随/类似长方体”“其它尺寸跟随”等执行影响。 -- `scripts/verify_edge_length_resize.py`:加载 `cube_10mm.step`,把一条 10mm 直线 Edge 改到 15mm,并断言策略、目标边长和形态分布都正确:局部变形只应有 1 条目标长度边;移动端面或缩放所属应让同向 4 条边一起变成目标长度。 -- `scripts/verify_edge_round_chamfer.py`:临时生成盒子和已有圆角盒子 STEP,验证直线 Edge 新增圆角、对称倒角、不等距倒角、距离+角度倒角,以及已有圆角半径修改。 +- `scripts/verify_edge_length_resize.py`:加载 `cube_10mm.step`,把一条 10mm 直线 Edge 改到 15mm,并断言策略、目标边长和形态分布都正确:局部变形只应有 1 条目标长度边;移动端面或缩放所属应让同向 4 条边一起变成目标长度;移动端面还会检查相邻侧面和移动端面保持可验证的垂直/平行关系。 +- `scripts/verify_edge_round_chamfer.py`:临时生成盒子、已有圆角盒子和圆角链盒子 STEP,验证直线 Edge 新增圆角、对称倒角、不等距倒角、距离+角度倒角、已有圆角半径/弧长修改,以及复杂圆角链会在计划阶段阻止。 - `scripts/verify_edge_isolated_edit.py`:通过 `python -m step_editor.isolated_edit_worker` 的真实 worker 入口验证 Edge 长度、直线 Edge 起点/中心移动、圆形 Edge 相邻轴心移动、椭圆 Edge 主半径、直线 Edge 新增圆角、三类倒角和已有圆角半径修改可以在隔离执行通道里完成,输出 STEP 能重新加载并保持目标几何语义。 -- `scripts/verify_face_edit_suite.py`:集中运行当前 Face 阶段验证,覆盖 Face 一级共享边拓扑、U向尺寸/V向尺寸、矩形 Face 两个面内方向区分、面积、中心、偏移变换、共面碎面一起拉伸/切除/切削、向内切除切穿保护、带内孔平面 Face 的局部变形保护、圆柱端盖这类含曲面 Solid 的平面 Face 局部变形保护、B-spline 自由曲面只读守卫、大 STEP 同轴阶梯环形端盖局部 shell 拉伸/切除、非矩形平面 Face 局部编辑、编辑后属性值回读、修改后逻辑 Face ID 仍指向被编辑的新面、壳体厚度增厚/减厚、目标值等于当前值时必须阻止修改,以及偏移变换/面积/U向尺寸/V向尺寸/壳体厚度目标值为非数字、0、负数或极端过大/过小时必须阻止修改。 +- `scripts/verify_face_edit_suite.py`:集中运行当前 Face 阶段验证,覆盖 Face 一级共享边拓扑、面内长度/面内宽度、矩形 Face 两个面内方向区分、中心、偏移、共面碎面一起拉伸/切除/切削、向内切除切穿保护、带内孔平面 Face 的局部变形保护、圆柱端盖这类含曲面 Solid 的平面 Face 局部变形保护、B-spline 自由曲面只读守卫、大 STEP 同轴阶梯环形端盖局部 shell 拉伸/切除、非矩形平面 Face 局部编辑、编辑后属性值回读、修改后逻辑 Face ID 仍指向被编辑的新面、壳体厚度增厚/减厚、目标值等于当前值时必须阻止修改,以及面内尺寸/偏移/壳体厚度目标值为非数字、0、负数或极端过大/过小时必须阻止修改。面积相关后端回归仍保留作结果校验,不作为特征参数表里的可编辑行。 - `scripts/verify_face_edit_integrity_guards.py`:验证 Face 编辑结果不能丢 Solid、不能新增无效 B-Rep、不能只达到目标数值却破坏一级拓扑;若目标检查、一级拓扑计数或一级事实图校验失败,模型层会恢复编辑前快照。 - `scripts/verify_face_first_level_topology.py`:加载 `cube_10mm.step`,验证正方体上表面 Face 的一级关系只包含当前 Face、4 条边界 Edge、4 个边界 Vertex 和 4 个共享边相邻侧面;局部移动上表面后,目标 Face 的边界 Vertex 与共享边 Edge 会到达新高度,4 个一级侧面会跟随重建,二级底面保持不动。 - `scripts/verify_face_coplanar_push_pull.py`:临时生成顶部被拆成两片共面 Face 的单 Solid,验证选中其中一片时会把整片共面区域作为一个 profile 拉伸/切除,并确认拉伸/切除后原逻辑 Face ID 会定位到新生成的整片顶面。 -- `scripts/verify_face_complex_boundary_guard.py`:临时生成带通孔板件,验证带内孔的平面 Face 会报告边界环数量,并禁用 `局部重建` 的局部面积、U向尺寸/V向尺寸、中心和偏移变换计划;同时确认 `拉伸/切除` 的向外补料和向内切削都仍可用并保持单 Solid,且同一 Solid 的局部变形可用性判断会复用缓存。 -- `scripts/verify_face_mixed_surface_guard.py`:临时生成简单圆柱,验证圆柱端盖这个平面 Face 会因为所属 Solid 含有曲面而禁用 `局部重建` 的局部面积、中心和偏移变换计划,属性提示会说明具体原因;同时确认 `拉伸/切除` 仍可用并让圆柱高度增加。 +- `scripts/verify_face_complex_boundary_guard.py`:临时生成带通孔板件,验证带内孔的平面 Face 会报告边界环数量,并禁用 `局部重建` 的面内长度/面内宽度、中心和偏移计划;同时确认 `拉伸/切除` 的向外补料和向内切削都仍可用并保持单 Solid,且同一 Solid 的局部变形可用性判断会复用缓存。 +- `scripts/verify_face_mixed_surface_guard.py`:临时生成简单圆柱,验证圆柱端盖这个平面 Face 会因为所属 Solid 含有曲面而禁用 `局部重建` 的中心和偏移计划,属性提示会说明具体原因;同时确认 `拉伸/切除` 仍可用并让圆柱高度增加。 -- `scripts/verify_face_freeform_guard.py`:临时生成 B-spline 自由曲面 STEP,验证它会被识别为只读受限 Face,识别风险为 blocked;属性表只显示限制说明和只读诊断,不暴露面积、中心、偏移变换、半径、半角等伪参数化编辑入口。 -- `scripts/verify_face_invalid_target_guards.py`:验证 Face 面积、U向尺寸/V向尺寸 和壳体厚度的局部/整体计划在目标值为非数字、0 或负数时都会返回 blocked,并提示目标值必须是有效正数。 -- `scripts/verify_face_extreme_target_guards.py`:验证 Face 面积、U向尺寸/V向尺寸、偏移变换和壳体厚度的局部/整体计划在目标值会把几何缩到当前 5% 以下、放大到当前 5 倍以上,或把偏移变换移动到远超模型尺寸时都会返回 blocked,避免退化面、拉穿相邻几何或长时间无效计算。 -- `scripts/verify_face_logical_selection_retention.py`:验证 Face 的局部/整体U向尺寸、局部/整体面积、局部/整体中心、局部/整体偏移变换和拉伸/切除正负方向完成后,原逻辑 Face ID 会排他地指向被编辑后的新 Face,避免 UI 改完参数后选中跑到别的面。 +- `scripts/verify_face_freeform_guard.py`:临时生成 B-spline 自由曲面 STEP,验证它会被识别为只读受限 Face,识别风险为 blocked;属性表只显示限制说明和只读诊断,不暴露面积、中心、偏移、半径、半角等伪参数化编辑入口。 +- `scripts/verify_face_invalid_target_guards.py`:验证 Face 面内尺寸和壳体厚度的局部/整体计划在目标值为非数字、0 或负数时都会返回 blocked,并提示目标值必须是有效正数;历史面积路径只作为后端守门回归保留。 +- `scripts/verify_face_extreme_target_guards.py`:验证 Face 面内尺寸、偏移和壳体厚度的局部/整体计划在目标值会把几何缩到当前 5% 以下、放大到当前 5 倍以上,或把偏移移动到远超模型尺寸时都会返回 blocked,避免退化面、拉穿相邻几何或长时间无效计算。 +- `scripts/verify_face_logical_selection_retention.py`:验证 Face 的局部/整体面内尺寸、局部/整体中心、局部/整体偏移和拉伸/切除正负方向完成后,原逻辑 Face ID 会排他地指向被编辑后的新 Face,避免 UI 改完参数后选中跑到别的面;历史面积路径只作为后端结果绑定回归保留。 - `scripts/verify_selection_identity_ui.py`:验证编辑后当前拓扑 Face ID 改变时,Face / 特征标题会显示 `逻辑 Face ID(拓扑 Face ID)`,复制 ID 会优先复制可再次选择的逻辑 Face ID,避免把用户带到重建后的临时拓扑编号上。 - `scripts/verify_face_feature_parameter_consistency.py`:加载默认模型 Face 594,验证轻量 `当前特征` 选择在 full feature 缓存前后都显示同一组基础 Face 参数,避免先改其它对象或触发深层识别后,同一个 Face 的属性列表突然缩水或膨胀。 -- `scripts/verify_face_nonrectangular_local_edit.py`:临时生成三角柱 STEP,验证非矩形平面 Face 的 `局部重建`、`缩放特征`、`移动特征`、`拉伸/切除` 语义,在中心、面积、U向尺寸/V向尺寸 和偏移变换场景下都能保持单 Solid。 -- `scripts/verify_face_resize_semantics.py`:加载 `cube_10mm.step`,验证 Face 的U向尺寸/V向尺寸、面积、中心坐标和偏移变换在 `当前面` 局部编辑、`整体` 所属对象编辑与 `拉伸/切除` 之间不会互相混淆。 -- `scripts/verify_face_noop_guards.py`:验证 Face 的拉伸/切除、偏移变换、中心、面积、U向尺寸/V向尺寸 和壳体厚度在目标值等于当前值时都会返回 blocked,并给出“不需要修改”的说明。 +- `scripts/verify_face_nonrectangular_local_edit.py`:临时生成三角柱 STEP,验证非矩形平面 Face 的 `局部重建`、`缩放特征`、`移动特征`、`拉伸/切除` 语义,在中心、面内尺寸和偏移场景下都能保持单 Solid。 +- `scripts/verify_face_resize_semantics.py`:加载 `cube_10mm.step`,验证 Face 的面内长度/面内宽度、中心坐标和偏移在 `当前面` 局部编辑、`整体` 所属对象编辑与 `拉伸/切除` 之间不会互相混淆;面积路径只作为后端缩放语义回归保留。 +- `scripts/verify_face_noop_guards.py`:验证 Face 的拉伸/切除、偏移、中心、面内尺寸和壳体厚度在目标值等于当前值时都会返回 blocked,并给出“不需要修改”的说明。 - `scripts/verify_first_level_fact_graph.py`:验证正方体平面 Face、通孔圆柱面和槽/半孔候选都会输出统一一级拓扑事实包,包含主体 Face、边界 Edge/Vertex、共享边一级相邻 Face、角色分组,以及明确不自动传播的二级/三级关系;同时验证 Face 拉伸/切除/面积/中心移动、孔径修改和槽宽修改的编辑计划都会携带这份事实。 -- `scripts/verify_feature_recognition_summary.py`:验证平面 Face、带内孔平面 Face、通孔圆柱面和槽/半孔候选都会输出统一的 `识别摘要`、候选类型、置信度、风险、证据项、`当前可改` 和 `当前受限修改`;这些摘要只使用已有识别结果生成,不额外触发深层几何扫描。 -- `scripts/verify_face_property_readback.py`:验证 Face 的U向尺寸、面积、中心和偏移变换编辑完成后,当前选中对象表里的当前值和目标值会重新读到新几何;特别覆盖 `偏移变换` 的 `局部重建`、`移动特征` 和 `拉伸/切除` 三种语义。 +- `scripts/verify_feature_recognition_summary.py`:验证平面 Face、带内孔平面 Face、通孔圆柱面、槽/半孔和凸台候选都会输出统一的 `识别摘要`、候选类型、常用优先级、置信度、风险、证据项、`当前可改` 和 `当前受限修改`;全局可编辑对象扫描也会按 Face、孔、槽、凸台、圆角/倒角、壳体、Edge 的常用顺序排序。 +- `scripts/verify_face_property_readback.py`:验证 Face 的面内尺寸、中心和偏移编辑完成后,当前选中对象表里的当前值和目标值会重新读到新几何;特别覆盖 `偏移` 的 `局部重建`、`移动特征` 和 `拉伸/切除` 三种语义。面积读回只作为诊断结果验证。 - `scripts/verify_face_push_pull_limits.py`:验证平面 Face 向内切除时会估算当前面背后的材料厚度;浅范围切除允许执行,接近切穿会标为高风险,达到或超过材料厚度会直接 blocked;向外一次性拉伸/切除较大时会标为高风险并通过隔离子进程执行,而不是按绝对距离直接拒绝。 -- `scripts/verify_face_ui_isolation_contract.py`:验证 Face、孔槽和 Edge 阶段使用的隔离操作集合、`WindowActionMixin._isolation_for_plan()` 的 UI 隔离列表,以及 `isolated_edit_worker` 真正支持的 worker 操作完全一致;脚本还会反向追踪属性表暴露的 Face 修改动作、孔槽按钮和 Edge 按钮是否最终进入隔离 worker,防止后续新增参数行时忘记接入后台隔离执行,导致 UI 卡死或主进程被危险 OCCT 计算拖崩。没有 pyocc 时可以先运行 `python scripts\verify_face_ui_isolation_contract.py --static-only` 做 AST 级契约检查。 -- `scripts/verify_face_rectangular_axes.py`:临时生成 20 x 10 x 6 的矩形盒子,验证 10 x 20 的矩形 Face 在 `U向尺寸` 和 `V向尺寸` 两个方向上可以分别做 `局部重建` 与 `缩放特征`,另一个方向不会被误改,属性表也会读回新值。 +- `scripts/verify_face_ui_isolation_contract.py`:验证 Face、孔槽和 Edge 阶段使用的隔离操作集合、`WindowActionMixin._isolation_for_plan()` 的 UI 隔离列表,以及 `isolated_edit_worker` 真正支持的 worker 操作完全一致;脚本还会反向追踪属性表暴露的 Face 修改动作、孔槽入口和 Edge 入口是否最终进入隔离 worker,防止后续新增参数行时忘记接入后台隔离执行,导致 UI 卡死或主进程被危险 OCCT 计算拖崩。没有 pyocc 时可以先运行 `python scripts\verify_face_ui_isolation_contract.py --static-only` 做 AST 级契约检查。 +- `scripts/verify_face_rectangular_axes.py`:临时生成 20 x 10 x 6 的矩形盒子,验证 10 x 20 的矩形 Face 在 `面内长度` 和 `面内宽度` 两个方向上可以分别做 `局部重建` 与 `缩放特征`,另一个方向不会被误改,属性表也会读回新值。 - `scripts/verify_cylindrical_height_resize.py`:临时生成简单圆柱、同轴空心圆柱、多孔圆柱和顶端带缺口圆柱,验证选中圆柱侧面修改高度时会走端盖拉伸/切除并校验目标高度、半径和一级拓扑;同时覆盖 isolated worker 子进程路径,确认这些高频 Face 修改不会把主界面拖进危险计算;`缩放特征` 的轴向缩放会按轴向跨度校验结果。 - `scripts/verify_large_stepped_cap_push_pull.py`:加载 `assets/models/geom_extract.step`,自动找到 y=-39 的同轴阶梯环形端盖和 y=-57.5 的多内孔大平面端盖,分别验证 `-39 -> 50` 走 `local-shell-rebuild`、`57.5 -> 92` 走 `boundary-shell-rebuild`,并验证多内孔大端面浅范围向内收缩也走 `boundary-shell-rebuild`,深向内切除会快速阻止且说明材料厚度或二级关系原因;成功路径保持单 Solid,原逻辑 Face ID 会继续指向移动后的新端面,一级边界 Edge/Vertex、共享边相邻 Face 和内孔线圈数量不能丢,并同时覆盖隔离子进程路径,避免 UI 操作退回 180s 布尔/全模型结果扫描慢路径。 - `scripts/verify_hole_slot_edit_suite.py`:集中运行当前孔/槽阶段验证,串起孔/槽一级圆柱拓扑、通孔/盲孔局部重建、半圆槽/长圆槽局部重建、隔离 worker 执行、逻辑 Face ID 保持、识别摘要、一级事实图和属性表分组。后续孔/槽能力新增或重构时优先跑这个套件,而不是只跑单个孔径或槽宽脚本。 - `scripts/verify_hole_resize.py`:临时生成通孔和盲孔 STEP,验证孔径扩大、孔径缩小、通孔轴心移动、完整通孔封堵、盲孔加深和盲孔变浅的局部重建链路;盲孔/盲槽深度修改现在会在操作内部确认目标深度和一级邻域,失败会回滚。 - `scripts/verify_hole_slot_isolated_edit.py`:临时生成通孔、盲孔、半圆槽和长圆槽 STEP,通过 `python -m step_editor.isolated_edit_worker` 的真实 worker 入口验证孔径、孔径缩放特征、孔轴心、孔封堵、盲孔深度、槽宽、槽宽缩放特征、槽深、弧长、弧角、槽轴心、长圆槽总长度和中心距可以在隔离执行通道里完成,输出 STEP 能重新加载并达到目标值;同时验证会破坏盲孔圆柱语义的整体缩放会被干净拒绝并回滚。窗口侧会额外验证孔径、孔轴心、槽宽、槽轴心、盲孔/盲槽深度、长圆槽总长度和长圆槽轴心隔离任务不会被误套用通用 Face 参数校验,并会把原逻辑 Face ID 继续绑定到修改后的孔壁、槽壁或长圆槽端部 Face。 -- `scripts/verify_isolated_face_edit.py`:验证 high-risk Face 的偏移变换、面积、U向尺寸/V向尺寸、中心和壳体厚度局部/整体修改可以在隔离子进程里执行,子进程导出结果 STEP 后主流程仍能加载出单 Solid;同时验证复杂大 STEP 多内孔端面的 UI 操作计划会延后完整几何计划,避免按钮点击阶段先卡住主界面。 -- `scripts/verify_property_editor_specs.py`:轻量验证当前选中对象表的规格生成逻辑,确保平面 Face 显示通用 Face 编辑,而孔、槽、凸台、已有圆角、圆锥、球面和环面不会混入通用 Face 的面积、中心、U向尺寸/V向尺寸 或偏移变换编辑;同时验证目标值等于当前值时不会误判为需要修改,带内孔 Face 禁用 `局部重建` 时会把具体原因写进提示,并确认 UI 不再单独暴露容易混淆的 额外的 `移动量` 行。 -- `scripts/verify_property_card_editor_ui.py`:Qt-only 轻量验证当前选中对象表的参数卡片显示层,确认可编辑卡片、目标值输入、建模意图下拉框、单项应用按钮和 `更多诊断信息` 展开都能正常工作。 -- `scripts/verify_associated_features.py`:验证 `探测相邻特征` 能在共享边拓扑范围内找到关联孔/凸台,把它们的可改尺寸以 `关联 Face` 行合并到当前特征参数表,并显示 `关联探测` 说明行;同时验证大模型全量显示网格不会退回百万级三角面,而单个圆柱 Face 仍可局部细化,防止用户误以为探测级别切换没有生效或加载显示卡住。 +- `scripts/verify_isolated_face_edit.py`:验证 high-risk Face 的偏移、面内尺寸、中心和壳体厚度局部/整体修改可以在隔离子进程里执行,子进程导出结果 STEP 后主流程仍能加载出单 Solid;面积路径保留为后端隔离守门回归。同时验证复杂大 STEP 多内孔端面的 UI 操作计划会延后完整几何计划,避免按钮点击阶段先卡住主界面。 +- `scripts/verify_property_editor_specs.py`:轻量验证当前选中对象表的规格生成逻辑,确保平面 Face 显示面内长度、面内宽度、中心和偏移,而孔、槽、凸台、已有圆角、圆锥、球面和环面不会混入通用 Face 编辑;同时验证面积不会作为特征参数表驱动变量出现,目标值等于当前值时不会误判为需要修改,带内孔 Face 禁用 `局部重建` 时会把具体原因写进提示,并确认 UI 不再单独暴露容易混淆的额外 `移动量` 行。 +- `scripts/verify_property_card_editor_ui.py`:Qt-only 轻量验证当前选中对象表的参数表显示层,确认四列表格、目标值输入、建模意图下拉框、统一 `参数化建模` 按钮和展开完整参数都能正常工作,且诊断行不会混入特征参数表。 +- `scripts/verify_associated_features.py`:验证 `探测相邻特征` 能在共享边拓扑范围内找到关联孔/凸台,把它们的可改尺寸以 `关联 Face` 行合并到当前特征参数表;`关联探测` 本身保留为诊断信息,不再混入参数表。同时验证大模型全量显示网格不会退回百万级三角面,而单个圆柱 Face 仍可局部细化,防止用户误以为探测级别切换没有生效或加载显示卡住。 - `scripts/verify_shell_edit_suite.py`:集中运行壳体厚度阶段验证,串起壳体厚度局部拉伸/切除、缩放特征、Face 一级事实图、结果校验和属性表分组。 - `scripts/verify_shell_thickness_resize.py`:临时生成薄板 STEP,验证壳体厚度局部拉伸/切除和整体调整两种语义,目标厚度可以大于或小于当前厚度;同时确认修改后原逻辑 Face ID 仍指向被编辑后的平面 Face,简单全平面薄板不会被整体调整变成 B-spline 曲面。 - `scripts/verify_slot_resize.py`:临时生成半圆槽和长圆槽 STEP,验证槽宽、槽深、弧长、弧角、半圆槽局部轴心、长圆槽整槽轴心、槽孔总长度和两端中心距的局部重建链路。 @@ -1237,16 +1386,16 @@ git diff --check - STEP 显示:默认使用中等清晰度网格,避免粗网格长期显示导致画质发糊;复杂模型旋转时保持边线和静态画质一致,并可隐藏同域内部边。 - 模型展示区域右上角会显示最近渲染窗口的 `FPS`,用于观察旋转、缩放和复杂模型查看时的实际渲染帧率;静止后会用一次性 idle 刷新归零,不会持续空转重绘。 - 模型展示区域左下角会显示 CAD 风格的 view cube + 彩色 `XYZ` 方向标;主视图前景层会在 STEP 文件真实全局原点 `(0,0,0)` 显示一组 `XYZ` 坐标基准,带原点球、粗箭头、负方向短轴和 3D 标签,且不参与拾取或初始相机适配,避免打开模型时被坐标轴拉远缩小。 -- 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮;悬停和普通点选只做轻量拾取,不触发完整特征识别。 +- 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮;左键拖动旋转不会触发选择;悬停和普通点选只做轻量拾取,不触发完整特征识别。 - 同域面:同域合并仍用于编辑计划、扫描候选、历史定位和必要的模型修复;普通交互不再实时扫描整片同域区域。 -- 编辑:偏移变换、面积、U向尺寸/V向尺寸、中心、孔径/半径、孔/槽轴心、槽宽、槽深、弧长、弧角、开口角、总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心、普通完整圆柱高度、已有圆角半径/圆弧长度、圆锥参考半径/直径/半角、球面半径/直径(缩放特征)、环面主/小半径或直径(缩放特征)、Edge圆角、Edge倒角、圆Edge半径/直径、椭圆Edge主半径/小半径、壳体厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。 -- 编辑语义:Face 的偏移变换、面积、U向尺寸/V向尺寸 和中心通过 `建模意图` 选择“拉伸/切除”“局部重建”“移动特征”或“缩放特征”;圆锥 Face 会优先区分简单圆锥解析重建、嵌入式锥孔局部重切和复杂兜底整体缩放;孔/槽、凸台、盲孔/盲槽、壳体、圆角和 Edge 长度也会通过 `建模意图` 或计划策略说明本次修改会局部重建、拉伸/切除、移动还是整体缩放。 +- 编辑:偏移、面内长度/面内宽度、中心、孔径/半径、孔/槽轴心、槽宽、槽深、弧长、弧角、开口角、总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心、普通完整圆柱高度、已有圆角半径/圆弧长度、圆锥参考半径/直径/半角、球面半径/直径(缩放特征)、环面主/小半径或直径(缩放特征)、Edge圆角、Edge倒角、圆Edge半径/直径、椭圆Edge主半径/小半径、壳体厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。Face 面积作为诊断结果只读展示。 +- 编辑语义:Face 的偏移、面内长度/面内宽度和中心通过 `建模意图` 选择“拉伸/切除”“局部重建”“移动特征”或“缩放特征”;面积不作为特征参数表里的驱动变量。圆锥 Face 会优先区分简单圆锥解析重建、嵌入式锥孔局部重切和复杂兜底整体缩放;孔/槽、凸台、盲孔/盲槽、壳体、圆角和 Edge 长度也会通过 `建模意图` 或计划策略说明本次修改会局部重建、拉伸/切除、移动还是整体缩放。 - 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。 - 导出:完整模型、零件、Solid、Face、特征区域和 Edge,导出前做基础质量检查。 ### 编辑语义 -所有几何参数修改都必须先明确语义、建模意图、执行影响和风险。修改 Face、孔、槽、圆角、凸台、Solid 或 Edge 时,都不能只看“目标值能不能算出来”;要区分 Face 拉伸/切除、切削补料、特征重建、局部形变、端面移动、对象平移、整体缩放等不同策略,并在确认窗口、属性提示或操作历史里尽量说明本次采用的策略。Edge 的 `长度` 行已经通过 `建模意图` 提供显式改法选择;Face 当前把 `偏移变换`、`面积`、`U向尺寸`、`V向尺寸` 和 `中心` 显示为单行属性,并通过 `建模意图` 下拉框表达“拉伸/切除”“局部重建”“移动特征”或“缩放特征”;孔/槽直径和半径也通过 `建模意图` 区分“局部重切/重建孔壁”和“缩放所属对象”;凸台直径、半径、高度和轴心也通过 `建模意图` 区分“局部重建/端盖拉伸/切除/移动凸台”和“整体缩放/移动所属对象”;普通完整圆柱的直径、半径和高度也通过 `建模意图` 区分“兜底重切/端盖拉伸/切除”和“整体缩放所属对象”;槽宽、槽深和弧长也通过 `建模意图` 区分“局部重建槽”和“缩放所属对象”;盲孔/盲槽深度也通过 `建模意图` 区分“局部切削/补料”和“轴向缩放所属对象”;壳体厚度也通过 `建模意图` 区分“局部拉伸/切除当前壳体平面”和“沿厚度方向缩放所属对象”;已有圆角半径/弧长也通过 `建模意图` 区分“移除并重建圆角”和“整体缩放所属对象”;孔、槽的轴心坐标已经开始区分“移动/重建局部特征”和“平移所属对象”;孔、槽、凸台、盲孔/盲槽深度、壳体厚度现在会显示并记录当前采用的建模意图,后续还要继续把更多多语义场景做成用户可选策略。 +所有几何参数修改都必须先明确语义、建模意图、执行影响和风险。修改 Face、孔、槽、圆角、凸台、Solid 或 Edge 时,都不能只看“目标值能不能算出来”;要区分 Face 拉伸/切除、切削补料、特征重建、局部形变、端面移动、对象平移、整体缩放等不同策略,并在确认窗口、属性提示或操作历史里尽量说明本次采用的策略。Edge 的 `长度` 行已经通过 `建模意图` 提供显式改法选择;Face 当前把 `偏移`、`面内长度`、`面内宽度` 和 `中心` 显示为单行属性,并通过 `建模意图` 下拉框表达“拉伸/切除”“局部重建”“移动特征”或“缩放特征”;面积只作为诊断结果展示。孔/槽直径和半径也通过 `建模意图` 区分“局部重切/重建孔壁”和“缩放所属对象”;凸台直径、半径、高度和轴心也通过 `建模意图` 区分“局部重建/端盖拉伸/切除/移动凸台”和“整体缩放/移动所属对象”;普通完整圆柱的直径、半径和高度也通过 `建模意图` 区分“兜底重切/端盖拉伸/切除”和“整体缩放所属对象”;槽宽、槽深和弧长也通过 `建模意图` 区分“局部重建槽”和“缩放所属对象”;盲孔/盲槽深度也通过 `建模意图` 区分“局部切削/补料”和“轴向缩放所属对象”;壳体厚度也通过 `建模意图` 区分“局部拉伸/切除当前壳体平面”和“沿厚度方向缩放所属对象”;已有圆角半径/弧长也通过 `建模意图` 区分“移除并重建圆角”和“整体缩放所属对象”;孔、槽的轴心坐标已经开始区分“移动/重建局部特征”和“平移所属对象”;孔、槽、凸台、盲孔/盲槽深度、壳体厚度现在会显示并记录当前采用的建模意图,后续还要继续把更多多语义场景做成用户可选策略。 必须记住:用户不是只要“数值改掉”,而是要选择建模意图。正方体改一条 Edge 只是例子,实际要求覆盖所有对象:用户应该能选择只改当前边、移动端面/保持垂直、固定某些边/点、让哪些相邻几何跟随变化,或整体缩放/重建所属对象。Face、孔、槽、凸台和圆角也要按同样思路设计。当多种结果都合理时,不要让程序暗自决定;要把“改法、约束、影响对象、跟随关系”做成当前选中对象里的策略行、下拉框或清晰按钮,并用普通用户能理解的话解释。 @@ -1258,9 +1407,9 @@ git diff --check 1. `auto`:自动策略。按局部边形变、端面拉伸/切除、相邻圆柱直径、几何缩放的顺序寻找可用路径。 2. `local-edge-only-deform`:只改当前Edge。直线Edge位于简单全平面多面体时,只移动被选Edge的端点并重建所属Solid/特征。对立方体测试模型,默认固定起点移动终点;如果 `长度基准` 选择 `中心`,才会两端各移动一半以保持 Edge 中心不动。相邻面会自然变形,四点非平面面会优先重建成连续曲面,而不是把整个正方体缩放成长方体,也不会默认拆出面内对角线。 -3. `move-edge-end-plane-by-push-pull`:移动端面/保持垂直。尝试找端点附近、方向匹配的平面端面,把改边长转成端面拉伸/切除;端面和相关边跟随移动,相邻平面尽量保持垂直,对正方体会更像变成长方体。 +3. `move-edge-end-plane-by-push-pull`:移动端面/保持垂直。尝试找端点附近、方向匹配的平面端面,把改边长转成端面拉伸/切除;端面和相关边跟随移动。计划阶段会确认当前 Edge 的直接相邻面和移动端面都是可验证平面,执行后会反查侧面与端面仍保持垂直、侧面之间的平行/垂直关系没有丢失;对正方体会更像变成长方体。 4. `resize-adjacent-cylinder-from-circular-edge-length`:圆形/圆弧Edge尝试复用相邻圆柱孔/槽或凸台直径编辑。 -5. `scale-owning-shape-from-edge`:缩放所属对象。圆边、椭圆边和可采样为平面的非直线Edge会优先用 `radial-affine` 围绕曲线平面法向做径向缩放;其他无明确局部路径的Edge才按Edge轴向或整体做几何缩放 fallback,风险高,会影响其它尺寸。 +5. `scale-owning-shape-from-edge`:缩放所属对象。当前界面主要对直线Edge和圆Edge开放该长度语义;圆边会优先用相邻圆柱或径向缩放处理。椭圆边改用主半径/小半径入口,B-spline / Bezier 等复杂曲线边暂不作为稳定长度入口,避免高风险 fallback 误导用户。 直线Edge起点/终点/中心坐标编辑是另一类显式语义:`local-edge-endpoint-deform` 和 `local-edge-center-deform` 会在简单全平面多面体上移动端点或整条Edge,并重建周边平面。 @@ -1268,21 +1417,21 @@ git diff --check 最近一次基线验证:在 `assets/models/cube_10mm.step` 上选第一条直线Edge,将 10mm 改为 15mm,计划策略为 `local-edge-only-deform`,内存执行成功。执行后边长分布为 `10:9, 11.1803:2, 15:1`,Face 数保持 `6 -> 6`,Edge 数保持 `12 -> 12`,说明没有整体拉成长方体,也没有再把非平面四边面拆成两张三角半面。 -移动端面基线验证:在同一个立方体模型上将第一条 10mm 直线 Edge 改为 15mm,并强制选择 `move-edge-end-plane-by-push-pull`、`keep-start`,内存执行成功。执行后 Face 数仍为 6、Edge 数仍为 12,边长分布为 `10:8, 15:4`,说明相关端面和同向边一起移动,结果更接近长方体语义。 +移动端面基线验证:在同一个立方体模型上将第一条 10mm 直线 Edge 改为 15mm,并强制选择 `move-edge-end-plane-by-push-pull`、`keep-start`,内存执行成功。执行后 Face 数仍为 6、Edge 数仍为 12,边长分布为 `10:8, 15:4`,并通过 `Planar relation check: ok` 反查侧面、端面和目标 Edge 的一级平面关系,说明相关端面和同向边一起移动,结果更接近长方体语义。 Face 局部移动基线验证:在 `assets/models/cube_10mm.step` 上选上表面 Face,将中心从 `(5, 5, 10)` 移到 `(7, 5, 13)`,计划策略为 `local-face-only-deform`,内存执行成功。执行后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,说明只移动了当前 Face 的 4 个顶点并重建相邻平面,不是平移整个所属对象。 Face 一级关系基线验证:在 `assets/models/cube_10mm.step` 上选上表面 Face,`face_first_level_topology()` 会返回 `topology_relation_depth=1`、4 条边界 Edge、4 个边界 Vertex、4 个共享边相邻 Face,并明确二级/三级关系暂不递归传播。将上表面中心沿 Z 方向从 `10` 移到 `12` 后,4 个一级侧面中心 Z 从 `5` 变为 `6`,二级底面中心 Z 仍为 `0`,说明当前局部 Face 编辑只处理一级共享边拓扑。 -Face 偏移变换基线验证:在 `assets/models/cube_10mm.step` 上选一个平面 Face,将 `偏移变换` 正向或负向改变 `1mm`。`局部重建` 会走 `local-face-plane-offset-deform`,移动当前 Face 顶点并重建相邻面;`移动特征` 会走 `translate-owning-shape-from-plane-offset`,整体平移所属对象且所有面尺寸保持不变;`拉伸/切除` 会走 `push-pull-planar-face`,正向执行加料,负向执行切削。三条路径都会验证目标平面中心位置,但结果形态不同,不能混为一个“偏移变换”操作。 +Face 偏移基线验证:在 `assets/models/cube_10mm.step` 上选一个平面 Face,将 `偏移` 正向或负向改变 `1mm`。`局部重建` 会走 `local-face-plane-offset-deform`,移动当前 Face 顶点并重建相邻面;`移动特征` 会走 `translate-owning-shape-from-plane-offset`,整体平移所属对象且所有面尺寸保持不变;`拉伸/切除` 会走 `push-pull-planar-face`,正向执行加料,负向执行切削。三条路径都会验证目标平面中心位置,但结果形态不同,不能混为一个“偏移”操作。 -Face 局部面积基线验证:在 `assets/models/cube_10mm.step` 上选上表面 Face,将面积从 `100` 改为 `144`,计划策略为 `local-face-area-only-deform`,面内缩放比例为 `1.2`,内存执行成功。执行后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,说明只缩放了当前 Face 的 4 个顶点并重建相邻平面,不是缩放整个所属对象。 +Face 面积后端基线验证:面积是结果变量,不再显示为特征参数表里的驱动行;后端仍保留 `local-face-area-only-deform` 等历史回归,用来确认旧路径不会破坏 B-Rep、逻辑 ID 和结果校验。 -Face 局部U向尺寸/V向尺寸基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `U向尺寸` 或 `V向尺寸` 的建模意图设为 `局部重建` 并从 `10` 改为 `15`,计划策略分别为 `local-face-width-only-deform` / `local-face-height-only-deform`,内存执行成功。执行后能检测到目标尺寸的平面 Face,说明它只沿当前 Face 的一个平面内方向缩放顶点,不是整体拉成长方体。 +Face 局部面内长度/面内宽度基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `面内长度` 或 `面内宽度` 的建模意图设为 `局部重建` 并从 `10` 改为 `15`,计划策略分别为 `local-face-width-only-deform` / `local-face-height-only-deform`,内存执行成功。执行后能检测到目标尺寸的平面 Face,说明它只沿当前 Face 的一个平面内方向缩放顶点,不是整体拉成长方体。 -Face 整体U向尺寸/V向尺寸基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `U向尺寸` 或 `V向尺寸` 的建模意图设为 `缩放特征` 并从 `10` 改为 `15`,计划策略分别为 `axis-scale-owning-shape-from-face-width` / `axis-scale-owning-shape-from-face-height`,简单全平面实体优先使用 `planar-rebuild`。内存执行成功后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,并能检测到多个面积 `150` 的平面 Face,说明所属特征沿该面内方向整体跟随变化,同时避免通用仿射把平面盒子变成样条面。 +Face 整体面内长度/面内宽度基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `面内长度` 或 `面内宽度` 的建模意图设为 `缩放特征` 并从 `10` 改为 `15`,计划策略分别为 `axis-scale-owning-shape-from-face-width` / `axis-scale-owning-shape-from-face-height`,简单全平面实体优先使用 `planar-rebuild`。内存执行成功后仍是 1 个 Solid、6 个 Face、12 条 Edge、8 个 Vertex,并能检测到多个面积 `150` 的平面 Face,说明所属特征沿该面内方向整体跟随变化,同时避免通用仿射把平面盒子变成样条面。 -Face 面积基线验证:在 `assets/models/cube_10mm.step` 上选一个 10mm x 10mm 平面 Face,将 `面积` 从 `100` 改为 `144`。`局部重建` 会走 `local-face-area-only-deform`,只把当前 Face 在自身平面内缩放到 12mm x 12mm 并重建相邻面;`缩放特征` 会走 `uniform-scale-face-area-fallback`,把所属对象整体等比缩放到所有 10mm x 10mm 面都变为约 12mm x 12mm。 +Face 面积结果校验:表格不再要求用户输入目标面积;面积用于诊断信息、历史差异和后端结果校验,避免把结果变量伪装成直接可控的设计参数。 Face 中心基线验证:在 `assets/models/cube_10mm.step` 上选一个平面 Face,将中心按 `(2, 0, 3)` 偏移。`局部重建` 会走 `local-face-only-deform`,当前 Face 被移到目标中心并重建相邻面,Face 自身尺寸保持约 10mm x 10mm;`移动特征` 会走 `translate-part`,整个所属特征平移,所有面尺寸保持 10mm x 10mm。 @@ -1292,7 +1441,7 @@ Face 中心基线验证:在 `assets/models/cube_10mm.step` 上选一个平面 槽/半孔局部重建基线验证:`scripts/verify_slot_resize.py` 会临时生成一个 30 x 20 x 10 的块体并在上表面切出半径 3 的半圆槽,还会生成一个 40 x 24 x 6 的长圆槽板件。当前验证通过八条局部槽修改路线:槽宽 `6 -> 8`、槽深 `3 -> 4`、弧长 `9.42478 -> 12`、弧角 `180° -> 126.051°`、半圆槽轴心偏移 `1mm`、长圆槽整槽轴心偏移 `1mm`、槽孔总长度 `16 -> 20`、两端中心距 `10 -> 14`。槽宽/槽深/弧长会保持当前圆弧角度并换算目标圆柱直径;弧角会先补旧扇形槽,再按目标角度重切;半圆槽轴心会先填旧槽再按同尺寸切新槽,并丢弃极小布尔毛刺;长圆槽轴心会填旧胶囊槽再整体切到新位置;槽孔总长度和中心距会保持槽宽不变并调整两端中心距。槽扇形和长圆槽胶囊工具体必须使用真实圆弧边拉伸,不能用多边形近似,否则结果会变成小平面,后续识别不到目标圆柱槽面。 -Edge 圆角/倒角基线验证:`scripts/verify_edge_round_chamfer.py` 会临时生成普通盒子和已有圆角盒子。当前验证通过五条路线:直线 Edge 新增圆角半径 `1`、对称倒角距离 `1`、不等距倒角 `0.8 / 1.2`、距离+角度倒角 `1 / 45°`、已有圆角半径 `1 -> 1.5`。已有圆角修改走 defeature + refillet;普通 90° 圆角恢复出的锐边距离圆角轴线约 `sqrt(2) * R`,候选搜索半径需要覆盖这个距离。 +Edge 圆角/倒角基线验证:`scripts/verify_edge_round_chamfer.py` 会临时生成普通盒子、已有圆角盒子和圆角链盒子。当前验证通过七条路线:直线 Edge 新增圆角半径 `1`、对称倒角距离 `1`、不等距倒角 `0.8 / 1.2`、距离+角度倒角 `1 / 45°`、已有圆角半径 `1 -> 1.5`、已有圆角弧长 `2.35619`、复杂圆角链计划阶段阻止。已有简单圆角修改走 defeature + refillet;圆角弧长会按当前圆弧角换算成目标半径;普通 90° 圆角恢复出的锐边距离圆角轴线约 `sqrt(2) * R`,候选搜索半径需要覆盖这个距离;圆角链暂不尝试重建,避免进入高风险 blend 计算。 Edge 隔离执行基线验证:`scripts/verify_edge_isolated_edit.py` 会通过真实 isolated worker 请求覆盖 Edge 长度、端点/中心移动、圆形 Edge 相邻轴心移动、椭圆 Edge 主半径、Edge 圆角、三类倒角和已有圆角半径修改。这个脚本的目标不是替代内存执行套件,而是确认 UI 交给 worker 的操作名和参数能在子进程里独立完成,避免危险 OCCT 倒圆、倒角或局部重建卡死主程序。 @@ -1302,7 +1451,7 @@ Edge 隔离执行基线验证:`scripts/verify_edge_isolated_edit.py` 会通过 椭圆Edge基线验证:`scripts/verify_ellipse_edge_resize.py` 会临时生成一个 5 x 2 的椭圆面。当前验证通过两条路线:主半径 `5 -> 7.5` 时小半径保持约 `2`,小半径 `2 -> 3` 时主半径保持约 `5`。执行路径为 `ellipse-edge-major-axis-affine` 或 `ellipse-edge-minor-axis-affine`;刷新后 OCCT 可能把解析 ellipse 变成 B-spline,所以脚本用主/小轴采样半径校验几何结果。 -新增基线验证:内存构造椭圆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通用长度缩放和圆边变换后 B-spline 长度缩放的几何回归,用来约束后端 fallback 不退化;但这不代表界面把椭圆长度或 B-spline 长度作为当前稳定参数开放,用户入口仍以椭圆主/小半径和明确的直线/圆Edge语义为准。 后置校验基线:修改Edge长度完成后,结果消息会包含 `match`、`nearest_edge`、`nearest_length`、`target_error`、`relative_error`、`endpoint_error` 和校验范围;局部边形变、端面拉伸/切除和几何缩放会优先按预期端点匹配目标Edge,长度匹配只作为兜底。操作历史摘要也会带上 `actual_length` 和 `target_error`,用于提醒用户真实 B-Rep 刷新后的最近Edge长度是否贴近目标值。 @@ -1324,7 +1473,7 @@ Edge 隔离执行基线验证:`scripts/verify_edge_isolated_edit.py` 会通过 ### 2026-08-03 工程师返还代码合并记录 - 已合并特征参数策略:Feature 模式优先显示可靠的独立尺寸,不再把面积、中心、包围盒、底层曲面参数直接伪装成设计特征。 -- 已合并矩形平面轮廓识别:规则四直边平面可以识别为矩形平面、矩形棱柱、矩形口袋或矩形凸台候选,并估算长度、宽度、高度/深度。 +- 已合并矩形平面轮廓识别:规则四直边平面可以识别为矩形平面、矩形棱柱、矩形口袋或矩形凸台候选,并估算长度、宽度、中心和高度/深度;规则矩形凸台/口袋已开放长度、宽度、中心和高度/深度修改,多台阶矩形凸台的顶层规则台阶也可按独立矩形凸台编辑。长宽和中心移动会局部重建矩形包络,当前不在一次编辑中交换长宽方向,也不把中心沿高度/深度方向移动。 - 已合并关联特征扫描:选中一个 Face 时,可在有限共享边拓扑范围内关联附近孔、槽、凸台、圆角、圆锥/球/环面等候选特征,并把关联特征的可改尺寸显示到当前特征参数表中。 - 已合并显示质量修复:精细网格只重点细化解析曲面;法线平滑不再合并不同 B-Rep Face 的边界点,减少平面被相邻曲面污染后发黑的问题。 -- 新增验证脚本:`scripts/verify_feature_parameter_policy.py`、`scripts/verify_prismatic_feature.py`、`scripts/verify_associated_features.py`。 +- 新增验证脚本:`scripts/verify_feature_parameter_policy.py`、`scripts/verify_prismatic_feature.py`、`scripts/verify_associated_features.py`;其中 `verify_prismatic_feature.py` 会同时覆盖矩形凸台/口袋长度/宽度、中心、高度/深度和多台阶矩形凸台顶层台阶编辑。 diff --git a/scripts/verify_associated_features.py b/scripts/verify_associated_features.py index f7c244d..3e64ae7 100644 --- a/scripts/verify_associated_features.py +++ b/scripts/verify_associated_features.py @@ -54,24 +54,18 @@ def main() -> int: specs, _used = state._editable_property_specs(context) rows = state._feature_context_property_specs(specs, context) context_row = next((row for row in rows if row.get("key") == "feature_context_note"), None) - if context_row is None: - raise AssertionError("feature context should expose an associated-feature detection summary row") - context_text = str(context_row.get("current_text") or "") - context_scope = str(context_row.get("scope_text") or "") - if "相邻特征" not in context_text or "局部关联特征" not in context_text: - raise AssertionError(f"associated detection summary is not clear enough: {context_row}") - if "相邻特征" not in context_scope or "关联特征" not in context_scope: - raise AssertionError(f"associated detection summary should expose level and count: {context_row}") + if context_row is not None: + raise AssertionError("associated-feature detection summary should stay in diagnostics, not feature parameters") editable = { (str(row.get("label")), int(row.get("source_face_id", -1)), str(row.get("action"))) for row in rows if row.get("parameter_role") == "dimension" and row.get("source_face_id") is not None } expected = { - ("凸台/外圆候选 · 直径", 394, "resize_boss"), - ("凸台/外圆候选 · 高度", 394, "resize_cylindrical_height_owning_scale"), - ("圆柱孔候选 · 直径", 1591, "resize_hole"), - ("圆柱孔候选 · 盲孔/盲槽深度", 1591, "resize_hole_depth"), + ("凸台/外圆 · 直径", 394, "resize_boss"), + ("凸台/外圆 · 高度", 394, "resize_cylindrical_height_owning_scale"), + ("圆柱孔 · 直径", 1591, "resize_hole"), + ("圆柱孔 · 盲孔/盲槽深度", 1591, "resize_hole_depth"), } if not expected.issubset(editable): raise AssertionError(f"missing associated editable rows: {expected - editable}") diff --git a/scripts/verify_boss_edit_suite.py b/scripts/verify_boss_edit_suite.py index 0d0abb0..e466533 100644 --- a/scripts/verify_boss_edit_suite.py +++ b/scripts/verify_boss_edit_suite.py @@ -19,6 +19,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = ( "Boss diameter, height and axis local rebuilds", ("verify_boss_resize.py",), ), + ( + "Rectangular boss/pocket size, depth, center and top-step edits", + ("verify_prismatic_feature.py",), + ), ( "Property editor keeps generic Face edits out of boss features", ("verify_property_editor_specs.py",), diff --git a/scripts/verify_edge_length_resize.py b/scripts/verify_edge_length_resize.py index 9c2e3d0..7c0baa6 100644 --- a/scripts/verify_edge_length_resize.py +++ b/scripts/verify_edge_length_resize.py @@ -4,12 +4,18 @@ import argparse from collections import Counter from pathlib import Path import sys +import tempfile + +from OCC.Core.BRepAdaptor import BRepAdaptor_Surface +from OCC.Core.GeomAbs import GeomAbs_Plane 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.geometry_utils import _dir_tuple, _tuple_dot, _tuple_normalized, _tuple_or_none, _tuple_sub from step_editor.model import StepModel +from verify_edge_round_chamfer import _first_editable_line_edge, _write_box_model DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step" @@ -38,6 +44,93 @@ def _count_near(values: list[float], target: float, tolerance: float) -> int: return sum(1 for value in values if abs(value - target) <= tolerance) +def _planar_face_normal(model: StepModel, face_id: int) -> tuple[float, float, float] | None: + surf = BRepAdaptor_Surface(model.faces[face_id]) + if surf.GetType() != GeomAbs_Plane: + return None + return _tuple_normalized(_dir_tuple(surf.Plane().Axis().Direction())) + + +def _edge_axis(model: StepModel, edge_id: int) -> tuple[float, float, float]: + info = model.edge_info(edge_id) + start = _tuple_or_none(info.get("start_point")) + end = _tuple_or_none(info.get("end_point")) + axis = _tuple_normalized(_tuple_sub(end, start)) if start is not None and end is not None else None + if axis is None: + raise SystemExit(f"edge {edge_id} has no stable line direction") + return axis + + +def _assert_cube_push_pull_planar_constraints( + model: StepModel, + target_length: float, + tolerance: float, + result: str, +) -> None: + if "Planar relation check: ok" not in result: + raise SystemExit(f"end-face push/pull result should include a passing planar relation check: {result}") + relation_tolerance = max(tolerance, 1e-4) + target_edge_ids = _line_edge_ids_near_length(model, target_length, relation_tolerance) + if len(target_edge_ids) != 4: + raise SystemExit(f"end-face push/pull should leave four target-length edges, got {target_edge_ids}") + for edge_id in target_edge_ids: + axis = _edge_axis(model, edge_id) + adjacent_face_ids = tuple(int(item) for item in model.edge_info(edge_id).get("adjacent_face_ids", ())) + normals: list[tuple[int, tuple[float, float, float]]] = [] + for face_id in adjacent_face_ids: + normal = _planar_face_normal(model, face_id) + if normal is not None: + normals.append((face_id, normal)) + if len(normals) != 2: + raise SystemExit(f"target edge {edge_id} should still have two planar adjacent faces, got {adjacent_face_ids}") + side_axis_dots = [abs(_tuple_dot(normal, axis)) for _face_id, normal in normals] + if any(value > relation_tolerance for value in side_axis_dots): + raise SystemExit(f"target edge {edge_id} side faces are no longer parallel to the edge: {side_axis_dots}") + side_pair_dot = abs(_tuple_dot(normals[0][1], normals[1][1])) + if side_pair_dot > relation_tolerance: + raise SystemExit(f"target edge {edge_id} adjacent side faces are no longer perpendicular: {side_pair_dot:g}") + + +def _verify_nonorthogonal_push_pull_guard() -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_edge_push_pull_guard_") as temp_dir: + model_path = Path(temp_dir) / "chamfered_box.step" + _write_box_model(model_path) + model = StepModel.load(model_path) + source_edge_id = _first_editable_line_edge(model) + model.chamfer_edge(source_edge_id, 1.0) + + for edge_id in range(len(model.edges)): + info = model.edge_info(edge_id) + if info.get("curve") != "line": + continue + normals: list[tuple[float, float, float]] = [] + for face_id in tuple(int(item) for item in info.get("adjacent_face_ids", ())): + normal = _planar_face_normal(model, face_id) + if normal is not None: + normals.append(normal) + if len(normals) != 2: + continue + side_dot = abs(_tuple_dot(normals[0], normals[1])) + if not 0.2 < side_dot < 0.9: + continue + current_length = float(info.get("length") or 0.0) + plan = model.general_edge_length_plan( + edge_id, + current_length + 2.0, + anchor_mode="keep-start", + strategy_mode="move-edge-end-plane-by-push-pull", + ) + message = str(plan.get("message") or "") + blockers = str(plan.get("edge_length_planar_constraint_blockers") or "") + if plan.get("status") != "blocked": + raise SystemExit(f"non-orthogonal chamfer Edge push/pull should be blocked: {plan}") + if "neither parallel nor perpendicular" not in f"{message} {blockers}": + raise SystemExit(f"non-orthogonal chamfer Edge should explain the planar relation blocker: {plan}") + print(f"non_orthogonal_push_pull_guard=edge {edge_id}, side_dot={side_dot:.6f}") + return + raise SystemExit("no non-orthogonal chamfer Edge was found for push/pull guard verification") + + def _assert_cube_edge_intent_geometry( *, source_length: float, @@ -129,6 +222,12 @@ def main() -> int: expected_strategy = strategy if strategy != expected_strategy: raise SystemExit(f"expected {expected_strategy}, got {strategy or ''}") + if strategy == "move-edge-end-plane-by-push-pull" and plan.get("edge_length_planar_constraint_status") != "ready": + raise SystemExit( + "end-face push/pull plan should expose a ready planar relation constraint; " + f"status={plan.get('edge_length_planar_constraint_status')}, " + f"blockers={plan.get('edge_length_planar_constraint_blockers')}" + ) result = model.resize_general_edge_length( edge_id, @@ -162,6 +261,15 @@ def main() -> int: lengths=lengths, tolerance=max(args.tolerance, 1e-5), ) + if strategy == "move-edge-end-plane-by-push-pull": + _assert_cube_push_pull_planar_constraints( + model, + args.target_length, + max(args.tolerance, 1e-5), + result, + ) + if args.anchor == "keep-start": + _verify_nonorthogonal_push_pull_guard() print(f"model={Path(args.model)}") print(f"edge_id={edge_id}") diff --git a/scripts/verify_edge_round_chamfer.py b/scripts/verify_edge_round_chamfer.py index fa3f2c2..1f44f74 100644 --- a/scripts/verify_edge_round_chamfer.py +++ b/scripts/verify_edge_round_chamfer.py @@ -41,6 +41,28 @@ def _first_line_edge(shape: TopoDS_Shape, minimum_length: float = 5.0) -> TopoDS raise SystemExit("no line edge found in generated box") +def _line_edge_endpoint_records(shape: TopoDS_Shape) -> list[dict[str, object]]: + records: list[dict[str, object]] = [] + for edge in TopologyExplorer(shape, ignore_orientation=True).edges(): + curve = BRepAdaptor_Curve(edge) + if curve.GetType() != GeomAbs_Line: + continue + props = GProp_GProps() + brepgprop.LinearProperties(edge, props) + start = curve.Value(curve.FirstParameter()) + end = curve.Value(curve.LastParameter()) + records.append( + { + "edge": topods.Edge(edge), + "length": float(props.Mass()), + "start": (round(start.X(), 6), round(start.Y(), 6), round(start.Z(), 6)), + "end": (round(end.X(), 6), round(end.Y(), 6), round(end.Z(), 6)), + } + ) + records.sort(key=lambda item: (float(item["length"]), item["start"], item["end"])) + return records + + def _write_filleted_box_model(path: Path, radius: float) -> None: shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape() maker = BRepFilletAPI_MakeFillet(shape) @@ -49,6 +71,27 @@ def _write_filleted_box_model(path: Path, radius: float) -> None: _write_step(result, path) +def _write_chained_filleted_box_model(path: Path, radius: float) -> None: + shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape() + records = _line_edge_endpoint_records(shape) + if not records: + raise SystemExit("no line edges found in generated box") + first = records[0] + first_endpoints = {first["start"], first["end"]} + second = None + for candidate in records[1:]: + if candidate["start"] in first_endpoints or candidate["end"] in first_endpoints: + second = candidate + break + if second is None: + raise SystemExit("no adjacent line Edge pair found for chained fillet source") + maker = BRepFilletAPI_MakeFillet(shape) + maker.Add(float(radius), first["edge"]) + maker.Add(float(radius), second["edge"]) + result = _finalize_builder_result(maker, "verify source box fillet chain") + _write_step(result, path) + + def _first_editable_line_edge(model: StepModel) -> int: candidates: list[tuple[float, int]] = [] for edge_id in range(len(model.edges)): @@ -299,12 +342,138 @@ def _run_existing_fillet_case(source_radius: float, target_radius: float, tolera print(result.encode("ascii", "backslashreplace").decode("ascii")) +def _run_existing_fillet_arc_length_case( + source_radius: float, + target_arc_length: float, + tolerance: float, +) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_arc_") as temp_dir: + model_path = Path(temp_dir) / "filleted_box.step" + _write_filleted_box_model(model_path, source_radius) + model = StepModel.load(model_path) + face_id = _first_existing_fillet_face(model, source_radius, tolerance) + source_info = model.feature_info(face_id) + angular_span = float( + source_info.get("existing_fillet_angular_span") + or source_info.get("angular_span") + or 0.0 + ) + if angular_span <= 1e-6: + raise SystemExit(f"existing fillet has no stable angular span: {source_info}") + target_radius = float(target_arc_length) / angular_span + before = model.stats() + plan = model.existing_fillet_resize_plan(face_id, target_radius) + if plan["status"] == "blocked": + raise SystemExit(f"existing fillet arc-length plan was blocked: {plan['message']}") + _assert_existing_fillet_plan_topology(plan) + result = model.resize_existing_fillet(face_id, target_radius) + after = model.stats() + matches = _cylindrical_faces_near_radius(model, target_radius, tolerance) + if not matches: + raise SystemExit( + f"existing fillet arc-length verification failed: no cylindrical face near radius {target_radius:g}" + ) + verified_face_id = matches[0][0] + verified_info = model.feature_info(verified_face_id) + verified_arc = float( + verified_info.get("existing_fillet_arc_length_estimate") + or target_radius * angular_span + ) + arc_error = abs(verified_arc - target_arc_length) + if after.solids != before.solids: + raise SystemExit( + f"existing fillet arc-length resize changed solid count: before={before.solids}, after={after.solids}" + ) + if arc_error > max(tolerance * 4.0, target_arc_length * 5e-4): + raise SystemExit( + f"existing fillet arc-length verification failed: " + f"target={target_arc_length:g}, value={verified_arc:g}, error={arc_error:g}" + ) + if "Existing fillet result check" not in result: + raise SystemExit(f"existing fillet arc-length result did not report result check: {result}") + if "first_level_topology_matched=True" not in result: + raise SystemExit(f"existing fillet arc-length result did not verify first-level topology: {result}") + print("mode=existing_fillet_arc_length") + print(f"face_id={face_id}") + print(f"strategy={plan.get('resize_strategy')}") + print(f"before={before}") + print(f"after={after}") + print(f"source_radius={source_radius:.6f}") + print(f"target_arc_length={target_arc_length:.6f} target_radius={target_radius:.6f}") + print(f"verified_face={verified_face_id} verified_arc_length={verified_arc:.6f} error={arc_error:.6g}") + print(result.encode("ascii", "backslashreplace").decode("ascii")) + + +def _run_existing_fillet_chain_guard_case(source_radius: float, target_radius: float, tolerance: float) -> None: + with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_chain_") as temp_dir: + model_path = Path(temp_dir) / "fillet_chain_box.step" + _write_chained_filleted_box_model(model_path, source_radius) + model = StepModel.load(model_path) + face_id = _first_existing_fillet_face(model, source_radius, tolerance) + feature = model.feature_info(face_id) + chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ()) + chain_adjacent_face_ids = tuple(feature.get("feature_existing_fillet_chain_adjacent_face_ids") or ()) + support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids") or ()) + if feature.get("existing_fillet_status") != "blocked" or feature.get("existing_fillet_risk") != "blocked": + raise SystemExit(f"fillet chain should be blocked at recognition level: {feature}") + recognition_blockers = str(feature.get("recognition_blockers") or "") + recognition_ready_actions = str(feature.get("recognition_ready_actions") or "") + recognition_limited_actions = str(feature.get("recognition_limited_actions") or "") + if feature.get("recognition_decision") != "已阻止" or "圆角链" not in recognition_blockers: + raise SystemExit(f"fillet chain recognition summary should explain the blocker: {feature}") + if "已有圆角半径" in recognition_ready_actions: + raise SystemExit(f"fillet chain should not expose existing fillet radius as ready: {feature}") + if "已有圆角半径" not in recognition_limited_actions: + raise SystemExit(f"fillet chain should list existing fillet radius as a limited action: {feature}") + if len(chain_face_ids) < 2 or not chain_adjacent_face_ids: + raise SystemExit(f"fillet chain should expose connected fillet faces: {feature}") + if set(chain_adjacent_face_ids) & set(support_face_ids): + raise SystemExit( + "connected fillet faces should not be counted as support faces: " + f"chain_adjacent={chain_adjacent_face_ids}, support={support_face_ids}" + ) + + plan = model.existing_fillet_resize_plan(face_id, target_radius) + message = str(plan.get("message") or "") + blockers = str(plan.get("blockers") or "") + if plan.get("status") != "blocked": + raise SystemExit(f"existing fillet chain resize should be blocked before geometry execution: {plan}") + if "圆角链" not in f"{message} {blockers}" or "暂未实现" not in f"{message} {blockers}": + raise SystemExit(f"fillet chain blocker should explain the unsupported capability: {plan}") + if tuple(plan.get("feature_existing_fillet_chain_face_ids") or ()) != chain_face_ids: + raise SystemExit(f"fillet chain plan should retain chain face ids: {plan}") + scan_candidates = model.editable_feature_candidates(limit=20, detailed=False) + leaked_chain_candidates = [ + item + for item in scan_candidates + if item.get("operation_key") == "inspect_existing_fillet" and int(item.get("target_id", -1)) in chain_face_ids + ] + if leaked_chain_candidates: + raise SystemExit(f"fillet chain should not be listed as an editable fillet operation: {leaked_chain_candidates}") + + print("mode=existing_fillet_chain_guard") + print(f"face_id={face_id}") + print(f"chain_face_ids={chain_face_ids}") + print(f"chain_adjacent_face_ids={chain_adjacent_face_ids}") + print(f"support_face_ids={support_face_ids}") + print(message.encode("ascii", "backslashreplace").decode("ascii")) + + def main() -> int: parser = argparse.ArgumentParser(description="Verify Edge fillet/chamfer and existing fillet resize operations.") parser.add_argument( "--mode", default="all", - choices=["all", "fillet", "chamfer", "asymmetric_chamfer", "distance_angle_chamfer", "existing_fillet"], + choices=[ + "all", + "fillet", + "chamfer", + "asymmetric_chamfer", + "distance_angle_chamfer", + "existing_fillet", + "existing_fillet_arc_length", + "existing_fillet_chain_guard", + ], help="Edge rounding/chamfering edit mode to verify.", ) parser.add_argument("--fillet-radius", type=float, default=1.0) @@ -315,11 +484,20 @@ def main() -> int: parser.add_argument("--distance-angle-degrees", type=float, default=45.0) parser.add_argument("--source-fillet-radius", type=float, default=1.0) parser.add_argument("--target-fillet-radius", type=float, default=1.5) + parser.add_argument("--target-fillet-arc-length", type=float, default=2.356194490192345) parser.add_argument("--tolerance", type=float, default=2e-4) args = parser.parse_args() modes = ( - ["fillet", "chamfer", "asymmetric_chamfer", "distance_angle_chamfer", "existing_fillet"] + [ + "fillet", + "chamfer", + "asymmetric_chamfer", + "distance_angle_chamfer", + "existing_fillet", + "existing_fillet_arc_length", + "existing_fillet_chain_guard", + ] if args.mode == "all" else [args.mode] ) @@ -334,6 +512,14 @@ def main() -> int: _run_distance_angle_chamfer_case(args.distance_angle_distance, args.distance_angle_degrees) elif mode == "existing_fillet": _run_existing_fillet_case(args.source_fillet_radius, args.target_fillet_radius, args.tolerance) + elif mode == "existing_fillet_arc_length": + _run_existing_fillet_arc_length_case( + args.source_fillet_radius, + args.target_fillet_arc_length, + args.tolerance, + ) + elif mode == "existing_fillet_chain_guard": + _run_existing_fillet_chain_guard_case(args.source_fillet_radius, args.target_fillet_radius, args.tolerance) else: raise SystemExit(f"unsupported mode: {mode}") return 0 diff --git a/scripts/verify_face_property_readback.py b/scripts/verify_face_property_readback.py index d796a72..6829d0a 100644 --- a/scripts/verify_face_property_readback.py +++ b/scripts/verify_face_property_readback.py @@ -129,7 +129,10 @@ def main() -> int: model.resize_face_area_local(face_id, 144.0) for candidate_id, area, _width, _height, _center in _plane_metrics(model): if abs(area - 144.0) <= 1e-5: - _assert_spec_readback(model, candidate_id, "area", 144.0) + info = model.face_info(candidate_id) + _assert_close("area diagnostic value", info.get("area"), 144.0) + if any(spec.get("key") == "area" for spec in _specs(candidate_id, info)): + raise SystemExit("area should stay a diagnostic result, not an editable property spec") break else: raise SystemExit("no plane Face near area 144 after local area resize") diff --git a/scripts/verify_feature_recognition_summary.py b/scripts/verify_feature_recognition_summary.py index 9d4148a..3cda2f5 100644 --- a/scripts/verify_feature_recognition_summary.py +++ b/scripts/verify_feature_recognition_summary.py @@ -16,8 +16,10 @@ from step_editor.model import StepModel from step_editor.step_io import _write_step from step_editor.ui_helpers import INFO_LABELS -from verify_hole_resize import _first_hole_face, _write_through_hole_model # noqa: E402 +from verify_hole_resize import _first_hole_face, _write_blind_hole_model, _write_through_hole_model # noqa: E402 from verify_slot_resize import _first_slot_face, _write_half_round_slot_model # noqa: E402 +from verify_boss_resize import _first_boss_face, _write_boss_model # noqa: E402 +from verify_ellipse_edge_resize import _write_ellipse_face_model # noqa: E402 def _assert(condition: bool, message: str) -> None: @@ -57,6 +59,19 @@ def _first_surface_face(model: StepModel, surface: str) -> int: raise AssertionError(f"no {surface} Face was found") +def _first_quick_candidate( + model: StepModel, + *, + feature_guess: str, + feature_type: str, +) -> tuple[int, dict[str, object]]: + for face_id in range(len(model.faces)): + info = model.quick_face_info(face_id) + if info.get("feature_guess") == feature_guess and info.get("feature_type") == feature_type: + return face_id, info + raise AssertionError(f"no quick {feature_type} was found") + + def _assert_summary(info: dict[str, object], label: str, required_keys: set[str]) -> None: summary = str(info.get("recognition_summary") or "") candidate = str(info.get("recognition_candidate") or "") @@ -66,12 +81,18 @@ def _assert_summary(info: dict[str, object], label: str, required_keys: set[str] evidence_keys = set(str(item) for item in tuple(info.get("recognition_evidence_keys") or ())) evidence = str(info.get("recognition_evidence") or "") score = int(info.get("recognition_score", -1)) + user_priority = int(info.get("recognition_user_priority", -1)) + user_priority_label = str(info.get("recognition_user_priority_label") or "") + user_priority_reason = str(info.get("recognition_user_priority_reason") or "") _assert(summary, f"{label}: recognition_summary is missing") _assert(candidate, f"{label}: recognition_candidate is missing") _assert(confidence in {"unchecked", "none", "low", "medium", "high"}, f"{label}: bad confidence {confidence!r}") _assert(risk in {"low", "medium", "high", "blocked"}, f"{label}: bad risk {risk!r}") _assert(0 <= score <= 100, f"{label}: bad recognition_score {score!r}") + _assert(1 <= user_priority <= 99, f"{label}: bad user priority {user_priority!r}") + _assert(user_priority_label, f"{label}: recognition_user_priority_label is missing") + _assert(user_priority_reason, f"{label}: recognition_user_priority_reason is missing") _assert( decision in {"高可信候选", "可尝试候选", "需人工确认", "不建议自动修改", "已阻止"}, f"{label}: bad recognition_decision {decision!r}", @@ -79,6 +100,7 @@ def _assert_summary(info: dict[str, object], label: str, required_keys: set[str] _assert(required_keys <= evidence_keys, f"{label}: evidence keys missing {required_keys - evidence_keys}") _assert(evidence, f"{label}: recognition_evidence is missing") _assert(candidate in summary, f"{label}: summary should mention candidate {candidate!r}: {summary!r}") + _assert("优先级=" in summary, f"{label}: summary should mention user priority: {summary!r}") _assert(f"评分={score}" in summary, f"{label}: summary should mention score {score}: {summary!r}") _assert(decision in summary, f"{label}: summary should mention decision {decision!r}: {summary!r}") @@ -92,6 +114,7 @@ def _verify_planar_summary(root: Path) -> None: full_info = model.feature_info(face_id) _assert_summary(quick_info, "quick planar Face", {"surface", "boundary_edges"}) _assert_summary(full_info, "feature planar Face", {"surface", "boundary_edges", "first_level_topology"}) + _assert(int(full_info.get("recognition_user_priority", 99)) == 10, f"planar Face should be first priority: {full_info}") def _verify_hole_summary(root: Path) -> None: @@ -105,6 +128,7 @@ def _verify_hole_summary(root: Path) -> None: _assert(info.get("resize_status") == "ready", f"hole diameter resize should be ready: {info}") _assert(info.get("recognition_risk") != "blocked", f"ready hole candidate should not be globally blocked: {info}") _assert(info.get("recognition_decision") != "已阻止", f"ready hole candidate should not be marked blocked: {info}") + _assert(int(info.get("recognition_user_priority", 99)) == 20, f"hole should use hole priority: {info}") _assert(not str(info.get("recognition_blockers") or ""), f"ready hole blockers should stay empty: {info}") ready_actions = str(info.get("recognition_ready_actions") or "") limited_actions = str(info.get("recognition_limited_actions") or "") @@ -116,6 +140,64 @@ def _verify_hole_summary(root: Path) -> None: _assert("可改:" in str(info.get("recognition_summary") or ""), f"hole summary should show ready actions: {info}") +def _verify_quick_cylinder_recognition(root: Path) -> None: + hole_path = root / "quick_through_hole.step" + _write_through_hole_model(hole_path) + hole_model = StepModel.load(hole_path) + _hole_face_id, hole_info = _first_quick_candidate( + hole_model, + feature_guess="hole/groove candidate", + feature_type="圆柱孔候选", + ) + _assert_summary(hole_info, "quick through-hole feature", {"surface", "user_operation_priority"}) + _assert(int(hole_info.get("recognition_user_priority", 99)) == 20, f"quick hole should use hole priority: {hole_info}") + _assert("孔/槽/圆柱直径" in str(hole_info.get("recognition_ready_actions") or ""), f"quick hole should expose diameter: {hole_info}") + + blind_path = root / "quick_blind_hole_cached.step" + _write_blind_hole_model(blind_path) + blind_model = StepModel.load(blind_path) + blind_face_id = _first_hole_face(blind_model, blind=True) + cached_quick = blind_model.quick_face_info(blind_face_id) + _assert( + cached_quick.get("feature_type") == "圆柱孔候选", + f"quick hole should keep its feature label after face_info cache is populated: {cached_quick}", + ) + _assert(cached_quick.get("cylinder_end_type") == "blind", f"quick blind hole should expose end type: {cached_quick}") + _assert(cached_quick.get("depth_status") == "ready", f"quick blind hole depth should be ready: {cached_quick}") + _assert( + "盲孔/盲槽深度" in str(cached_quick.get("recognition_ready_actions") or ""), + f"quick blind hole should expose depth in ready actions: {cached_quick}", + ) + + slot_path = root / "quick_half_round_slot.step" + _write_half_round_slot_model(slot_path) + slot_model = StepModel.load(slot_path) + _slot_face_id, slot_info = _first_quick_candidate( + slot_model, + feature_guess="hole/groove candidate", + feature_type="槽/半孔候选", + ) + _assert_summary(slot_info, "quick half-round slot feature", {"surface", "slot_geometry", "user_operation_priority"}) + _assert(int(slot_info.get("recognition_user_priority", 99)) == 30, f"quick slot should use slot priority: {slot_info}") + _assert(slot_info.get("slot_status") == "candidate", f"quick slot should expose slot candidate fields: {slot_info}") + + boss_path = root / "quick_boss.step" + _write_boss_model(boss_path) + boss_model = StepModel.load(boss_path) + _boss_face_id, boss_info = _first_quick_candidate( + boss_model, + feature_guess="boss/outer-round candidate", + feature_type="凸台/外圆候选", + ) + _assert_summary(boss_info, "quick boss feature", {"surface", "user_operation_priority"}) + _assert(int(boss_info.get("recognition_user_priority", 99)) == 40, f"quick boss should use boss priority: {boss_info}") + ready_actions = str(boss_info.get("recognition_ready_actions") or "") + limited_actions = str(boss_info.get("recognition_limited_actions") or "") + _assert("圆柱凸台直径/高度/轴心" in ready_actions, f"quick boss should expose boss editing: {boss_info}") + _assert("孔/槽/圆柱直径" not in ready_actions, f"quick boss should not be exposed as hole resize: {boss_info}") + _assert("孔/槽/圆柱直径" not in limited_actions, f"quick boss should not show cross-feature hole limits: {boss_info}") + + def _verify_holed_planar_summary(root: Path) -> None: path = root / "holed_plate.step" _write_through_hole_model(path) @@ -144,6 +226,18 @@ def _verify_slot_summary(root: Path) -> None: info = model.feature_info(face_id) _assert(info.get("slot_status") == "candidate", f"slot was not recognized: {info}") _assert_summary(info, "half-round slot feature", {"surface", "material_votes", "slot_geometry"}) + _assert(int(info.get("recognition_user_priority", 99)) == 30, f"slot should use slot priority: {info}") + + +def _verify_boss_summary(root: Path) -> None: + path = root / "boss.step" + _write_boss_model(path) + model = StepModel.load(path) + face_id = _first_boss_face(model) + info = model.feature_info(face_id) + _assert(info.get("feature_guess") == "boss/outer-round candidate", f"boss was not recognized: {info}") + _assert_summary(info, "boss feature", {"surface", "material_votes", "first_level_topology"}) + _assert(int(info.get("recognition_user_priority", 99)) == 40, f"boss should use boss priority: {info}") def _verify_torus_summary(root: Path) -> None: @@ -155,6 +249,43 @@ def _verify_torus_summary(root: Path) -> None: _assert(info.get("feature_type") == "环面候选", f"torus was not recognized safely: {info}") _assert(info.get("feature_highlight_face_ids") == (face_id,), f"torus highlight should stay on source Face: {info}") _assert_summary(info, "torus feature", {"surface", "boundary_edges"}) + _assert(int(info.get("recognition_user_priority", 0)) == 80, f"torus should be lower-priority analytic surface: {info}") + + +def _verify_user_priority_scan_order(root: Path) -> None: + path = root / "through_hole_scan.step" + _write_through_hole_model(path) + model = StepModel.load(path) + candidates = model.editable_feature_candidates(limit=12, detailed=False) + operations = tuple(str(item.get("operation_key")) for item in candidates) + _assert(operations, "editable feature scan returned no candidates") + _assert(operations[0] == "push_pull_plane", f"Face push/pull should be first in common-user scan order: {operations}") + if "resize_cylinder" in operations: + _assert( + operations.index("push_pull_plane") < operations.index("resize_cylinder"), + f"Face push/pull should rank before hole diameter: {operations}", + ) + + +def _verify_ellipse_edge_scan_entries(root: Path) -> None: + path = root / "ellipse_edge_scan.step" + _write_ellipse_face_model(path) + model = StepModel.load(path) + candidates = model.editable_feature_candidates(limit=20, detailed=False) + operations = tuple(str(item.get("operation_key")) for item in candidates) + labels = {str(item.get("current_value_label")) for item in candidates} + _assert( + "resize_ellipse_edge_major_radius" in operations, + f"ellipse Edge scan should expose major radius instead of generic length: {operations}", + ) + _assert( + "resize_ellipse_edge_minor_radius" in operations, + f"ellipse Edge scan should expose minor radius instead of generic length: {operations}", + ) + _assert("major_radius" in labels and "minor_radius" in labels, f"ellipse Edge scan labels are unclear: {labels}") + for item in candidates: + if item.get("operation_key") == "resize_edge_length" and item.get("current_value_label") == "length": + raise AssertionError(f"ellipse Edge scan should not expose generic length editing: {item}") def main() -> int: @@ -165,6 +296,9 @@ def main() -> int: "recognition_risk", "recognition_score", "recognition_decision", + "recognition_user_priority", + "recognition_user_priority_label", + "recognition_user_priority_reason", "recognition_evidence", "recognition_ready_actions", "recognition_limited_actions", @@ -177,9 +311,13 @@ def main() -> int: root = Path(temp_dir) _verify_planar_summary(root) _verify_holed_planar_summary(root) + _verify_quick_cylinder_recognition(root) _verify_hole_summary(root) _verify_slot_summary(root) + _verify_boss_summary(root) _verify_torus_summary(root) + _verify_user_priority_scan_order(root) + _verify_ellipse_edge_scan_entries(root) print("feature recognition summary ok") return 0 diff --git a/scripts/verify_first_level_acceptance_docs.py b/scripts/verify_first_level_acceptance_docs.py index fd952d1..fb4ce23 100644 --- a/scripts/verify_first_level_acceptance_docs.py +++ b/scripts/verify_first_level_acceptance_docs.py @@ -47,6 +47,14 @@ def _verify_readme_mentions(readme: str) -> None: "当前整体验证基线", "不等于 CAD 级完成", "Face 阶段的当前验收口径", + "参数化编辑路线图", + "用户最常用优先 > B-Rep 上稳定可实现 > 参数语义清楚", + "`[x]` 已实现", + "`[~]` 部分实现/进行中", + "`[ ]` 未实现", + "STEP/B-Rep 参数化编辑主线", + "[不能修改 -> 立即说明原因]", + "[一级影响范围 -> 明确显示]", "verify_first_level_edit_suites.py --quick", "verify_first_level_edit_suites.py --stage face", "verify_first_level_edit_suites.py --stage hole-slot", @@ -70,10 +78,50 @@ def _verify_readme_mentions(readme: str) -> None: _assert(script_name in readme, f"README should mention {stage_name} command script {script_name}") +def _verify_roadmap_scope(readme: str) -> None: + start_marker = "STEP/B-Rep 参数化编辑主线" + end_marker = "└── 8. 二级 / 三级关系" + start = readme.find(start_marker) + end = readme.find(end_marker) + _assert(start >= 0 and end > start, "README roadmap should contain a 0~7 active scope before stage 8") + active_scope = readme[start:end] + deferred_scope = readme[end:] + + required_active_fragments = ( + "├── 0. 先让用户知道“能不能改”", + "│ ├── [x] [不能修改 -> 立即说明原因]", + "│ └── [x] [路线图 -> 验收脚本守门]", + "├── 1. 平面 Face,第一条主线", + "├── 2. 孔,第二条主线", + "├── 3. 槽 / 长圆孔,从孔扩展到组合切除特征", + "├── 4. 凸台 / Boss,从切除特征扩展到加料特征", + "├── 5. 圆角 / 倒角,从主形体扩展到边修饰", + "├── 6. Edge 一级编辑,补齐底层直接改边能力", + "├── 7. 壳体 / 解析曲面,补齐高价值但边界更窄的能力", + ) + for fragment in required_active_fragments: + _assert(fragment in active_scope, f"README active roadmap missing: {fragment}") + + forbidden_active_fragments = ( + "[Face 二级传播", + "[孔组 ->", + "多槽组 ->", + "二级传播 ->", + "三级传播 ->", + "二级 / 三级关系", + ) + for fragment in forbidden_active_fragments: + _assert(fragment not in active_scope, f"README 0~7 roadmap should defer this to stage 8: {fragment}") + + _assert("[Face 二级传播 -> 孔底/槽底/台阶联动]" in deferred_scope, "README should keep Face deeper propagation in stage 8") + _assert("0~7 不混入二级/三级传播任务" in active_scope, "README should document the active roadmap guard") + + def main() -> int: readme = README_PATH.read_text(encoding="utf-8") _verify_script_inventory() _verify_readme_mentions(readme) + _verify_roadmap_scope(readme) print("first-level acceptance docs ok") return 0 diff --git a/scripts/verify_first_level_edit_suites.py b/scripts/verify_first_level_edit_suites.py index 15690c7..1b26569 100644 --- a/scripts/verify_first_level_edit_suites.py +++ b/scripts/verify_first_level_edit_suites.py @@ -69,7 +69,8 @@ STAGES: tuple[tuple[str, str, tuple[tuple[str, tuple[str, ...]], ...]], ...] = ( QUICK_COMMANDS: tuple[tuple[str, tuple[str, ...]], ...] = ( ("Smoke test", ("main.py", "--smoke-test")), ("Property editor specs", ("verify_property_editor_specs.py",)), - ("Property card editor UI", ("verify_property_card_editor_ui.py",)), + ("Property table editor UI", ("verify_property_card_editor_ui.py",)), + ("Feature recognition priority", ("verify_feature_recognition_summary.py",)), ("First-level fact graph", ("verify_first_level_fact_graph.py",)), ("Associated feature probe and display budget", ("verify_associated_features.py",)), ("First-level acceptance docs", ("verify_first_level_acceptance_docs.py",)), diff --git a/scripts/verify_isolated_face_edit.py b/scripts/verify_isolated_face_edit.py index 5b37486..f5c223b 100644 --- a/scripts/verify_isolated_face_edit.py +++ b/scripts/verify_isolated_face_edit.py @@ -374,7 +374,7 @@ def main() -> int: input_path=hollow_path, ) _run_worker_case( - label="偏移变换(局部重建)", + label="偏移(局部重建)", operation="move_face_plane_offset_local", args=[0, 10.0], validator=_assert_plane_position, @@ -392,25 +392,25 @@ def main() -> int: validator=lambda label, model: _assert_face_area(label, model, 400.0), ) _run_worker_case( - label="U向尺寸(局部重建)", + label="面内长度(局部重建)", operation="resize_face_size_local", args=[0, 25.0, "width"], validator=_assert_face_width, ) _run_worker_case( - label="V向尺寸(局部重建)", + label="面内宽度(局部重建)", operation="resize_face_size_local", args=[0, 25.0, "height"], validator=_assert_face_height, ) _run_worker_case( - label="U向尺寸(缩放特征)", + label="面内长度(缩放特征)", operation="resize_face_size_owning_scale", args=[0, 25.0, "width"], validator=lambda label, model: _assert_face_width(label, model, 25.0), ) _run_worker_case( - label="V向尺寸(缩放特征)", + label="面内宽度(缩放特征)", operation="resize_face_size_owning_scale", args=[0, 25.0, "height"], validator=lambda label, model: _assert_face_height(label, model, 25.0), @@ -534,16 +534,16 @@ def main() -> int: delattr(sys, "frozen") for title in ( - "U向尺寸(局部重建)", - "V向尺寸(局部重建)", - "U向尺寸(缩放特征)", - "V向尺寸(缩放特征)", + "面内长度(局部重建)", + "面内宽度(局部重建)", + "面内长度(缩放特征)", + "面内宽度(缩放特征)", "壳体厚度(缩放特征)缩放所属对象", ): if not probe._quick_edit_title_supports_isolation(title): raise SystemExit(f"{title}: quick edit title should support isolated execution") context = { - "operation_name": "U向尺寸(局部重建)", + "operation_name": "面内长度(局部重建)", "target": "Face 0", "parameters": {"part_id": 1, "face_id": 0}, "target_kind": "face", @@ -596,7 +596,7 @@ def main() -> int: owning_result = owning_probe._run_isolated_edit_job( context={ **context, - "operation_name": "U向尺寸(缩放特征)", + "operation_name": "面内长度(缩放特征)", }, isolation=owning_isolation, snapshot=owning_probe.model.snapshot(), @@ -616,7 +616,7 @@ def main() -> int: raise SystemExit("isolated owning window job replaced the window model before returning to the UI thread") if owning_result.get("model_polydata") is None or owning_result.get("edge_polydata") is None: raise SystemExit("isolated owning window job should return prebuilt display polydata") - _assert_face_width("U向尺寸(整体窗口任务)", owning_after_model, 25.0) + _assert_face_width("面内长度(整体窗口任务)", owning_after_model, 25.0) if not _logical_region_has_width(owning_after_model, 0, 25.0): raise SystemExit("isolated owning window job did not preserve the original logical Face ID on the edited width") print("isolated owning window job ok") diff --git a/scripts/verify_prismatic_feature.py b/scripts/verify_prismatic_feature.py index 9007fa3..1f5c0fe 100644 --- a/scripts/verify_prismatic_feature.py +++ b/scripts/verify_prismatic_feature.py @@ -12,9 +12,9 @@ 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.step_io import _write_step from step_editor.model import StepModel from step_editor.window_state import WindowStateMixin, _feature_dimension_keys -from step_editor.step_io import _write_step DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step" @@ -27,15 +27,36 @@ def close_to(value: object, expected: float, tolerance: float = 1e-6) -> bool: return False -def find_feature(model: StepModel, feature_type: str) -> dict[str, object]: - matches = [model.feature_info(face_id) for face_id in range(len(model.faces))] - matches = [info for info in matches if info.get("feature_type") == feature_type] +def find_feature(model: StepModel, feature_type: str) -> tuple[int, dict[str, object]]: + matches = [ + (face_id, model.feature_info(face_id)) + for face_id in range(len(model.faces)) + if model.feature_info(face_id).get("feature_type") == feature_type + ] if not matches: observed = sorted({str(model.feature_info(face_id).get("feature_type")) for face_id in range(len(model.faces))}) raise AssertionError(f"expected {feature_type}, observed {observed}") return matches[0] +def find_prismatic_semantics(model: StepModel, semantics: str) -> tuple[int, dict[str, object]]: + matches = [ + (face_id, model.feature_info(face_id)) + for face_id in range(len(model.faces)) + if model.feature_info(face_id).get("prismatic_feature_semantics") == semantics + and model.feature_info(face_id).get("prismatic_profile_status") == "candidate" + ] + if not matches: + observed = sorted( + { + str(model.feature_info(face_id).get("prismatic_feature_semantics")) + for face_id in range(len(model.faces)) + } + ) + raise AssertionError(f"expected prismatic semantics {semantics}, observed {observed}") + return matches[0] + + def assert_prismatic_sizes( info: dict[str, object], length: float, width: float, depth: float ) -> None: @@ -49,6 +70,250 @@ def assert_prismatic_sizes( raise AssertionError(f"{key}={info.get(key)!r}, expected {value}") +def assert_vector_close( + value: object, + expected: tuple[float, float, float], + tolerance: float = 2e-4, +) -> None: + if value is None: + raise AssertionError(f"missing vector, expected {expected}") + try: + actual = tuple(float(item) for item in value) # type: ignore[iteration-over-annotation] + except (TypeError, ValueError): + raise AssertionError(f"invalid vector {value!r}, expected {expected}") from None + if len(actual) != 3: + raise AssertionError(f"invalid vector {value!r}, expected {expected}") + errors = tuple(abs(actual_item - expected_item) for actual_item, expected_item in zip(actual, expected)) + if max(errors) > tolerance: + raise AssertionError(f"vector={actual!r}, expected={expected!r}, errors={errors!r}") + + +def write_rectangular_pocket_model(path: Path) -> None: + base = BRepPrimAPI_MakeBox(30.0, 20.0, 5.0).Shape() + pocket_tool = BRepPrimAPI_MakeBox(gp_Pnt(10.0, 6.0, 2.0), 10.0, 8.0, 4.0).Shape() + _write_step(BRepAlgoAPI_Cut(base, pocket_tool).Shape(), path) + + +def write_rectangular_boss_model(path: Path) -> None: + base = BRepPrimAPI_MakeBox(30.0, 20.0, 5.0).Shape() + boss_tool = BRepPrimAPI_MakeBox(gp_Pnt(10.0, 6.0, 5.0), 10.0, 8.0, 3.0).Shape() + _write_step(BRepAlgoAPI_Fuse(base, boss_tool).Shape(), path) + + +def write_rectangular_multistep_boss_model(path: Path) -> None: + base = BRepPrimAPI_MakeBox(30.0, 20.0, 5.0).Shape() + lower = BRepPrimAPI_MakeBox(gp_Pnt(8.0, 5.0, 5.0), 14.0, 10.0, 3.0).Shape() + upper = BRepPrimAPI_MakeBox(gp_Pnt(12.0, 8.0, 8.0), 6.0, 4.0, 2.0).Shape() + _write_step(BRepAlgoAPI_Fuse(BRepAlgoAPI_Fuse(base, lower).Shape(), upper).Shape(), path) + + +def feature_dimension_rows( + model: StepModel, + face_id: int, + info: dict[str, object], +) -> tuple[tuple[str, str, str], ...]: + state = object.__new__(WindowStateMixin) + state.model = model + state.operation_in_progress = False + state.scan_in_progress = False + state.load_in_progress = False + state.selected_face_id = face_id + state.selected_edge_id = None + state.selected_kind = "feature" + state.selected_part_id = int(info["part_id"]) + state.selected_solid_id = int(info["solid_id"]) + specs, _used = state._editable_property_specs(info) + filtered = state._feature_property_specs(specs, info) + dimensions = [spec for spec in filtered if spec.get("parameter_role") == "dimension"] + return tuple( + ( + str(spec.get("key")), + str(spec.get("label")), + str(spec.get("current_text")), + ) + for spec in dimensions + ) + + +def assert_rectangular_feature_ui( + model: StepModel, + face_id: int, + info: dict[str, object], + expected_rows: tuple[tuple[str, str, str], ...], +) -> None: + actual = feature_dimension_rows(model, face_id, info) + if actual != expected_rows: + raise AssertionError(f"unexpected rectangular feature UI parameters: {actual}") + + +def assert_prismatic_feature_display_labels() -> None: + state = object.__new__(WindowStateMixin) + if state._feature_display_label("矩形口袋候选") != "矩形槽/口袋": + raise AssertionError("rectangular pocket should be displayed as a rectangular slot/pocket") + if state._feature_display_label("矩形凸台候选") != "矩形凸台": + raise AssertionError("rectangular boss display label should not expose candidate wording") + + +def assert_shell_depth_edit( + model_path: Path, + feature_type: str, + target_depth: float, + tolerance: float = 2e-4, +) -> None: + model = StepModel.load(model_path) + face_id, info = find_feature(model, feature_type) + before = model.stats() + assert_prismatic_sizes(info, 10.0, 8.0, 3.0) + plan = model.shell_thickness_plan(face_id, target_depth) + if plan["status"] == "blocked": + raise AssertionError(f"{feature_type} depth plan was blocked: {plan['message']}") + result = model.resize_shell_thickness(face_id, target_depth) + after = model.stats() + if after.solids != before.solids: + raise AssertionError(f"{feature_type} depth edit changed solid count: before={before}, after={after}") + + _verified_face_id, verified = find_feature(model, feature_type) + assert_prismatic_sizes(verified, 10.0, 8.0, target_depth) + error = abs(float(verified["prismatic_extrusion_estimate"]) - target_depth) + if error > tolerance: + raise AssertionError( + f"{feature_type} depth verification failed: target={target_depth:g}, " + f"value={verified['prismatic_extrusion_estimate']!r}, error={error:g}" + ) + if "resize completed" not in result.lower(): + raise AssertionError(f"{feature_type} depth result did not report completion: {result}") + + +def assert_rectangular_size_edit( + model_path: Path, + semantics: str, + axis: str, + target_size: float, + expected_length: float, + expected_width: float, + expected_depth: float = 3.0, + tolerance: float = 2e-4, + initial_length: float = 10.0, + initial_width: float = 8.0, + initial_depth: float = 3.0, +) -> None: + model = StepModel.load(model_path) + face_id, info = find_prismatic_semantics(model, semantics) + before = model.stats() + assert_prismatic_sizes(info, initial_length, initial_width, initial_depth) + axis_key = "height" if axis == "width" else "width" + plan = model.face_size_local_resize_plan(face_id, target_size, axis_key) + if plan["status"] == "blocked": + raise AssertionError(f"{semantics} {axis} plan was blocked: {plan['message']}") + if plan.get("resize_strategy") != "rectangular-prismatic-profile-rebuild": + raise AssertionError(f"unexpected {semantics} {axis} strategy: {plan.get('resize_strategy')}") + result = model.resize_face_size_local(face_id, target_size, axis_key) + after = model.stats() + if after.solids != before.solids: + raise AssertionError(f"{semantics} {axis} edit changed solid count: before={before}, after={after}") + + _verified_face_id, verified = find_prismatic_semantics(model, semantics) + assert_prismatic_sizes(verified, expected_length, expected_width, expected_depth) + length_error = abs(float(verified["prismatic_length"]) - expected_length) + width_error = abs(float(verified["prismatic_width"]) - expected_width) + if length_error > tolerance or width_error > tolerance: + raise AssertionError( + f"{semantics} {axis} verification failed: " + f"length={verified['prismatic_length']!r}, width={verified['prismatic_width']!r}, " + f"expected=({expected_length:g}, {expected_width:g})" + ) + if "rectangular prismatic feature size resize completed" not in result.lower(): + raise AssertionError(f"{semantics} {axis} result did not report completion: {result}") + + +def assert_rectangular_center_edit( + model_path: Path, + semantics: str, + target_center: tuple[float, float, float], + expected_center: tuple[float, float, float], + expected_length: float = 10.0, + expected_width: float = 8.0, + expected_depth: float = 3.0, + initial_length: float = 10.0, + initial_width: float = 8.0, + initial_depth: float = 3.0, +) -> None: + model = StepModel.load(model_path) + face_id, info = find_prismatic_semantics(model, semantics) + before = model.stats() + assert_prismatic_sizes(info, initial_length, initial_width, initial_depth) + plan = model.face_center_local_move_plan(face_id, target_center) + if plan["status"] == "blocked": + raise AssertionError(f"{semantics} center move plan was blocked: {plan['message']}") + if plan.get("resize_strategy") != "rectangular-prismatic-center-rebuild": + raise AssertionError(f"unexpected {semantics} center move strategy: {plan.get('resize_strategy')}") + result = model.move_face_center_local(face_id, target_center) + after = model.stats() + if after.solids != before.solids: + raise AssertionError(f"{semantics} center move changed solid count: before={before}, after={after}") + + _verified_face_id, verified = find_prismatic_semantics(model, semantics) + assert_prismatic_sizes(verified, expected_length, expected_width, expected_depth) + assert_vector_close(verified.get("area_center") or verified.get("bbox_center"), expected_center) + if "rectangular prismatic feature center move completed" not in result.lower(): + raise AssertionError(f"{semantics} center move result did not report completion: {result}") + + +def assert_rectangular_depth_edit_by_semantics( + model_path: Path, + semantics: str, + target_depth: float, + expected_length: float, + expected_width: float, + initial_depth: float, + tolerance: float = 2e-4, +) -> None: + model = StepModel.load(model_path) + face_id, info = find_prismatic_semantics(model, semantics) + before = model.stats() + assert_prismatic_sizes(info, expected_length, expected_width, initial_depth) + plan = model.shell_thickness_plan(face_id, target_depth) + if plan["status"] == "blocked": + raise AssertionError(f"{semantics} depth plan was blocked: {plan['message']}") + result = model.resize_shell_thickness(face_id, target_depth) + after = model.stats() + if after.solids != before.solids: + raise AssertionError(f"{semantics} depth edit changed solid count: before={before}, after={after}") + + _verified_face_id, verified = find_prismatic_semantics(model, semantics) + assert_prismatic_sizes(verified, expected_length, expected_width, target_depth) + error = abs(float(verified["prismatic_extrusion_estimate"]) - target_depth) + if error > tolerance: + raise AssertionError( + f"{semantics} depth verification failed: target={target_depth:g}, " + f"value={verified['prismatic_extrusion_estimate']!r}, error={error:g}" + ) + if "resize completed" not in result.lower(): + raise AssertionError(f"{semantics} depth result did not report completion: {result}") + + +def assert_rectangular_axial_center_move_blocked( + model_path: Path, + semantics: str, + target_center: tuple[float, float, float], +) -> None: + model = StepModel.load(model_path) + face_id, _info = find_prismatic_semantics(model, semantics) + plan = model.face_center_local_move_plan(face_id, target_center) + if plan["status"] != "blocked": + raise AssertionError(f"{semantics} axial center move should be blocked: {plan}") + if plan.get("resize_strategy") != "rectangular-prismatic-center-rebuild": + raise AssertionError(f"unexpected blocked {semantics} center strategy: {plan.get('resize_strategy')}") + + +def assert_rectangular_role_swap_blocked(model_path: Path, semantics: str) -> None: + model = StepModel.load(model_path) + face_id, _info = find_prismatic_semantics(model, semantics) + plan = model.face_size_local_resize_plan(face_id, 7.0, "width") + if plan["status"] != "blocked": + raise AssertionError(f"{semantics} length/width role swap should be blocked: {plan}") + + def main() -> int: model = StepModel.load(DEFAULT_MODEL) if len(model.faces) != 6: @@ -66,63 +331,186 @@ def main() -> int: if len(tuple(info.get("prismatic_connected_side_face_ids", ()))) != 4: raise AssertionError(f"Face {face_id} does not have four connected side faces") - info = model.feature_info(0) + face_id = 0 + info = model.feature_info(face_id) expected_keys = ("local_face_width", "local_face_height", "shell_thickness_estimate") if _feature_dimension_keys(info) != expected_keys: raise AssertionError(f"unexpected cube feature dimensions: {_feature_dimension_keys(info)}") - - state = object.__new__(WindowStateMixin) - state.model = model - state.operation_in_progress = False - state.scan_in_progress = False - state.load_in_progress = False - state.selected_face_id = 0 - state.selected_edge_id = None - state.selected_kind = "feature" - state.selected_part_id = int(info["part_id"]) - state.selected_solid_id = int(info["solid_id"]) - specs, _used = state._editable_property_specs(info) - filtered = state._feature_property_specs(specs, info) - dimensions = [spec for spec in filtered if spec.get("parameter_role") == "dimension"] - actual = tuple( - ( - str(spec.get("key")), - str(spec.get("label")), - str(spec.get("current_text")), - ) - for spec in dimensions - ) expected = ( ("local_face_width", "长度", "10"), ("local_face_height", "宽度", "10"), ("shell_thickness_estimate", "高度/深度", "10"), ) - if actual != expected: - raise AssertionError(f"unexpected prismatic UI parameters: {actual}") + assert_rectangular_feature_ui(model, face_id, info, expected) + assert_prismatic_feature_display_labels() with tempfile.TemporaryDirectory(prefix="step-editor-prismatic-") as temp_dir: temp_path = Path(temp_dir) - base = BRepPrimAPI_MakeBox(30.0, 20.0, 5.0).Shape() - - pocket_tool = BRepPrimAPI_MakeBox(gp_Pnt(10.0, 6.0, 2.0), 10.0, 8.0, 4.0).Shape() pocket_path = temp_path / "rectangular-pocket.step" - _write_step(BRepAlgoAPI_Cut(base, pocket_tool).Shape(), pocket_path) - pocket_info = find_feature(StepModel.load(pocket_path), "矩形口袋候选") + write_rectangular_pocket_model(pocket_path) + pocket_model = StepModel.load(pocket_path) + pocket_face_id, pocket_info = find_feature(pocket_model, "矩形口袋候选") assert_prismatic_sizes(pocket_info, 10.0, 8.0, 3.0) if pocket_info.get("prismatic_reference_source") != "side-wall-topology": raise AssertionError(f"unexpected pocket reference: {pocket_info.get('prismatic_reference_source')}") + assert_rectangular_feature_ui( + pocket_model, + pocket_face_id, + pocket_info, + ( + ("local_face_width", "长度", "10"), + ("local_face_height", "宽度", "8"), + ("shell_thickness_estimate", "高度/深度", "3"), + ("face_center_position", "中心", "(15, 10, 2)"), + ), + ) - boss_tool = BRepPrimAPI_MakeBox(gp_Pnt(10.0, 6.0, 5.0), 10.0, 8.0, 3.0).Shape() boss_path = temp_path / "rectangular-boss.step" - _write_step(BRepAlgoAPI_Fuse(base, boss_tool).Shape(), boss_path) - boss_info = find_feature(StepModel.load(boss_path), "矩形凸台候选") + write_rectangular_boss_model(boss_path) + boss_model = StepModel.load(boss_path) + boss_face_id, boss_info = find_feature(boss_model, "矩形凸台候选") assert_prismatic_sizes(boss_info, 10.0, 8.0, 3.0) if boss_info.get("prismatic_reference_source") not in { "side-wall-topology", "overlapping-plane" }: raise AssertionError(f"unexpected boss reference: {boss_info.get('prismatic_reference_source')}") + assert_rectangular_feature_ui( + boss_model, + boss_face_id, + boss_info, + ( + ("local_face_width", "长度", "10"), + ("local_face_height", "宽度", "8"), + ("shell_thickness_estimate", "高度/深度", "3"), + ("face_center_position", "中心", "(15, 10, 8)"), + ), + ) - print("prismatic feature recognition ok") + boss_edit_path = temp_path / "rectangular-boss-edit.step" + write_rectangular_boss_model(boss_edit_path) + assert_shell_depth_edit(boss_edit_path, "矩形凸台候选", 4.0) + + pocket_edit_path = temp_path / "rectangular-pocket-edit.step" + write_rectangular_pocket_model(pocket_edit_path) + assert_shell_depth_edit(pocket_edit_path, "矩形口袋候选", 4.0) + + for name, writer, semantics in ( + ("boss", write_rectangular_boss_model, "additive-boss"), + ("pocket", write_rectangular_pocket_model, "subtractive-pocket"), + ): + length_edit_path = temp_path / f"rectangular-{name}-length-edit.step" + writer(length_edit_path) + assert_rectangular_size_edit(length_edit_path, semantics, "length", 12.0, 12.0, 8.0) + + width_edit_path = temp_path / f"rectangular-{name}-width-edit.step" + writer(width_edit_path) + assert_rectangular_size_edit(width_edit_path, semantics, "width", 9.0, 10.0, 9.0) + + center_z = 8.0 if semantics == "additive-boss" else 2.0 + center_edit_path = temp_path / f"rectangular-{name}-center-edit.step" + writer(center_edit_path) + assert_rectangular_center_edit( + center_edit_path, + semantics, + (17.0, 11.0, center_z), + (17.0, 11.0, center_z), + ) + + axial_center_path = temp_path / f"rectangular-{name}-axial-center.step" + writer(axial_center_path) + axial_z = center_z + (1.0 if semantics == "additive-boss" else -1.0) + assert_rectangular_axial_center_move_blocked( + axial_center_path, + semantics, + (15.0, 10.0, axial_z), + ) + + blocked_path = temp_path / f"rectangular-{name}-role-swap.step" + writer(blocked_path) + assert_rectangular_role_swap_blocked(blocked_path, semantics) + + multistep_path = temp_path / "rectangular-multistep-boss.step" + write_rectangular_multistep_boss_model(multistep_path) + multistep_model = StepModel.load(multistep_path) + multistep_face_id, multistep_info = find_prismatic_semantics(multistep_model, "additive-boss") + assert_prismatic_sizes(multistep_info, 6.0, 4.0, 2.0) + assert_rectangular_feature_ui( + multistep_model, + multistep_face_id, + multistep_info, + ( + ("local_face_width", "长度", "6"), + ("local_face_height", "宽度", "4"), + ("shell_thickness_estimate", "高度/深度", "2"), + ("face_center_position", "中心", "(15, 10, 10)"), + ), + ) + + multistep_length_path = temp_path / "rectangular-multistep-boss-length.step" + write_rectangular_multistep_boss_model(multistep_length_path) + assert_rectangular_size_edit( + multistep_length_path, + "additive-boss", + "length", + 7.0, + 7.0, + 4.0, + 2.0, + initial_length=6.0, + initial_width=4.0, + initial_depth=2.0, + ) + + multistep_width_path = temp_path / "rectangular-multistep-boss-width.step" + write_rectangular_multistep_boss_model(multistep_width_path) + assert_rectangular_size_edit( + multistep_width_path, + "additive-boss", + "width", + 5.0, + 6.0, + 5.0, + 2.0, + initial_length=6.0, + initial_width=4.0, + initial_depth=2.0, + ) + + multistep_depth_path = temp_path / "rectangular-multistep-boss-depth.step" + write_rectangular_multistep_boss_model(multistep_depth_path) + assert_rectangular_depth_edit_by_semantics( + multistep_depth_path, + "additive-boss", + 2.5, + 6.0, + 4.0, + 2.0, + ) + + multistep_center_path = temp_path / "rectangular-multistep-boss-center.step" + write_rectangular_multistep_boss_model(multistep_center_path) + assert_rectangular_center_edit( + multistep_center_path, + "additive-boss", + (16.0, 10.5, 10.0), + (16.0, 10.5, 10.0), + expected_length=6.0, + expected_width=4.0, + expected_depth=2.0, + initial_length=6.0, + initial_width=4.0, + initial_depth=2.0, + ) + + multistep_axial_path = temp_path / "rectangular-multistep-boss-axial-center.step" + write_rectangular_multistep_boss_model(multistep_axial_path) + assert_rectangular_axial_center_move_blocked( + multistep_axial_path, + "additive-boss", + (15.0, 10.0, 10.5), + ) + + print("prismatic feature recognition and size/depth/center/multistep edit ok") return 0 diff --git a/scripts/verify_property_card_editor_ui.py b/scripts/verify_property_card_editor_ui.py index 6f4c4b8..f60f203 100644 --- a/scripts/verify_property_card_editor_ui.py +++ b/scripts/verify_property_card_editor_ui.py @@ -1,5 +1,6 @@ from __future__ import annotations +import math import os from pathlib import Path import sys @@ -25,7 +26,15 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from step_editor.widgets import NoWheelComboBox -from step_editor.window_state import WindowStateMixin +from step_editor.window_actions import WindowActionMixin +from step_editor.window_core import WindowCoreMixin +from step_editor.window_state import ( + PROPERTY_CURRENT_COLUMN, + PROPERTY_LABEL_COLUMN, + PROPERTY_SCOPE_COLUMN, + PROPERTY_TARGET_COLUMN, + WindowStateMixin, +) class _StatusBar: @@ -33,7 +42,7 @@ class _StatusBar: pass -class _PropertyCardProbe(QWidget, WindowStateMixin): +class _PropertyTableProbe(QWidget, WindowStateMixin): def __init__(self) -> None: super().__init__() self.model = object() @@ -42,30 +51,58 @@ class _PropertyCardProbe(QWidget, WindowStateMixin): self.load_in_progress = False self.property_editor_updating = False self.property_table_expanded = False - self.property_table_collapsed_rows = 4 + self.property_table_collapsed_rows = 5 + self.property_table_min_visible_rows = 5 self.property_editor_selected_row = None self.property_command_active_key = "" self.property_command_buttons = {} self.property_editor_specs = [] - self.selected_kind = "face" + self.selected_kind = "feature" self.selected_part_id = None self.selected_solid_id = None self.selected_face_id = 0 self.selected_edge_id = None - self.current_info_values = {"area": 100.0} - self.property_table = QTableWidget(0, 5) + self.current_info_values = self._plane_info() + + layout = QVBoxLayout(self) + self.object_edit_box = self + self.property_table = QTableWidget(0, 4) + self.property_table.setHorizontalHeaderLabels(["尺寸参数", "当前值", "建模意图", "目标值"]) + layout.addWidget(self.property_table) + self.property_card_scroll = QScrollArea() self.property_card_container = QWidget() self.property_card_layout = QVBoxLayout(self.property_card_container) self.property_card_scroll.setWidget(self.property_card_container) + layout.addWidget(self.property_card_scroll) + + self.property_expand_button = QPushButton() + layout.addWidget(self.property_expand_button) + self.property_command_summary_label = QLabel() self.property_command_bar = QFrame() self.property_command_layout = QHBoxLayout(self.property_command_bar) + self.property_command_help_label = QLabel() + self.current_capability_headline = QLabel() self.apply_property_button = QPushButton() + @staticmethod + def _plane_info() -> dict[str, object]: + return { + "area": 100.0, + "area_center": (5.0, 5.0, 0.0), + "bbox_center": (5.0, 5.0, 0.0), + "bbox_diagonal": 14.1421356237, + "local_face_width": 10.0, + "local_face_height": 10.0, + "plane_origin": (0.0, 0.0, 0.0), + "push_pull_outward_direction": (0.0, 0.0, 1.0), + "normal": (0.0, 0.0, 1.0), + } + def _selected_action_info(self) -> dict[str, object]: return { - "area": 100.0, + **self.current_info_values, "surface": "plane", "push_pull_status": "ready", "first_level_boundary_edge_count": 4, @@ -80,6 +117,82 @@ class _PropertyCardProbe(QWidget, WindowStateMixin): return _StatusBar() +class _ActionMessageProbe(WindowActionMixin): + pass + + +class _TimerProbe: + def stop(self) -> None: + pass + + +class _RenderWindowProbe: + def Render(self) -> None: + pass + + +class _StatusBarProbe: + def __init__(self) -> None: + self.messages: list[str] = [] + + def showMessage(self, message: str) -> None: + self.messages.append(str(message)) + + +class _MouseSelectionProbe(WindowCoreMixin): + def __init__(self) -> None: + self.pointer_button_down = False + self.left_button_press_position = None + self.left_button_press_camera_state = None + self.left_button_press_target = None + self.left_button_dragged = False + self.left_click_drag_threshold_px = 6 + self.left_click_camera_tolerance = 1e-7 + self.camera_interaction_active = False + self.pending_hover_position = None + self.last_hover_pick_position = None + self.hover_timer = _TimerProbe() + self.camera_state = (0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 10.0, 30.0) + self.load_in_progress = False + self.operation_in_progress = False + self.scan_in_progress = False + self.model = object() + self.selected_kind = None + self.render_window = _RenderWindowProbe() + self.status_bar = _StatusBarProbe() + self.pick_targets: list[dict[str, object] | None] = [ + {"kind": "face", "target_id": 1, "pick_position": (0.0, 0.0, 0.0)}, + {"kind": "face", "target_id": 1, "pick_position": (0.0, 0.0, 0.0)}, + ] + self.selected_targets: list[dict[str, object]] = [] + self.hover_clear_count = 0 + self.camera_end_count = 0 + + def _camera_state_signature(self): + return tuple(self.camera_state) + + def _current_selection_mode(self) -> str: + return "Face" + + def _pick_selection_target(self, _mode: str, _x: int, _y: int) -> dict[str, object] | None: + if self.pick_targets: + return self.pick_targets.pop(0) + return None + + def _select_pick_target(self, target: dict[str, object]) -> None: + self.selected_targets.append(dict(target)) + + def statusBar(self) -> _StatusBarProbe: + return self.status_bar + + def _clear_hover(self, render: bool = True) -> None: + self.hover_clear_count += 1 + + def _end_camera_interaction(self) -> None: + self.camera_interaction_active = False + self.camera_end_count += 1 + + class _TopLevelPropertyLabelProbe(QObject): def __init__(self) -> None: super().__init__() @@ -101,152 +214,291 @@ def _assert(condition: bool, message: str) -> None: raise AssertionError(message) +def _row_by_label(probe: _PropertyTableProbe, label: str) -> int: + for row in range(probe.property_table.rowCount()): + item = probe.property_table.item(row, PROPERTY_LABEL_COLUMN) + if item is not None and item.text() == label: + return row + labels = [ + probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text() + for row in range(probe.property_table.rowCount()) + if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None + ] + raise AssertionError(f"{label!r} row was not found; labels={labels}") + + +def _assert_property_table_editor(probe: _PropertyTableProbe) -> None: + _assert( + not WindowCoreMixin._should_suppress_transient_tooltip(probe, probe.property_table), + "property table tooltip events should not be suppressed", + ) + _assert( + not WindowCoreMixin._should_suppress_transient_tooltip(probe, probe.property_table.viewport()), + "property table viewport tooltip events should not be suppressed", + ) + headers = [ + probe.property_table.horizontalHeaderItem(column).text() + for column in range(probe.property_table.columnCount()) + ] + _assert(headers == ["尺寸参数", "当前值", "建模意图", "目标值"], f"unexpected table headers: {headers}") + _assert(probe.property_table.rowCount() == len(probe.property_editor_specs), "table row count should match specs") + header_height = int(probe.property_table.horizontalHeader().height()) + frame = int(probe.property_table.frameWidth()) * 2 + default_row_height = max(int(probe.property_table.verticalHeader().defaultSectionSize()), 22) + expected_five_row_height = header_height + frame + default_row_height * 5 + 8 + _assert( + probe.property_table.minimumHeight() >= expected_five_row_height, + "feature parameter table should reserve enough height for five default rows", + ) + _assert(not getattr(probe, "property_card_rows", {}), "property card rows should not be built in table mode") + _assert(not probe.property_card_scroll.isVisible(), "property card scroll area should stay hidden") + _assert(probe.property_card_scroll.maximumHeight() == 0, "property card scroll area should not reserve height") + _assert(not probe.property_command_buttons, "legacy command buttons should not be shown in the parameter table") + _assert(not probe.property_command_bar.isVisible(), "legacy command bar should be hidden") + + table_labels = [ + probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text() + for row in range(probe.property_table.rowCount()) + if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None + ] + actionable_labels = [str(spec.get("label", "")) for _row, spec in probe._actionable_property_rows()] + for label in ("面内长度", "面内宽度", "中心", "偏移"): + _assert(label in actionable_labels, f"feature parameter table did not expose {label}") + for legacy_label in ("面积", "U向尺寸", "V向尺寸", "偏移变换"): + _assert(legacy_label not in actionable_labels, f"{legacy_label} should not be exposed as an editable parameter") + _assert(legacy_label not in table_labels, f"{legacy_label} should not appear in the feature parameter table") + for diagnostic_label in ("建模形式", "推荐操作", "一级关系", "关联探测"): + _assert(diagnostic_label not in table_labels, f"{diagnostic_label} should stay out of the feature parameter table") + + target_row = _row_by_label(probe, "面内长度") + target_widget = probe.property_table.cellWidget(target_row, PROPERTY_TARGET_COLUMN) + scope_widget = probe.property_table.cellWidget(target_row, PROPERTY_SCOPE_COLUMN) + _assert(isinstance(target_widget, QLineEdit), "editable table row should have a target editor") + _assert(isinstance(scope_widget, NoWheelComboBox), "editable table row should have a modeling-intent combo") + _assert( + not probe.property_table.findChildren(QPushButton), + "feature parameter table should not contain per-row apply buttons", + ) + + target_widget.setText("12") + probe._update_property_apply_state() + changed = probe._changed_property_rows() + _assert(any(row == target_row for row, _spec, _text in changed), "table target edit was not detected") + _assert(probe.apply_property_button.isEnabled(), "single parametric modeling button should enable for one changed row") + + probe.toggle_property_table_expanded() + _assert(probe.property_table_expanded, "property table expand toggle failed") + _assert("收起" in probe.property_expand_button.text(), "expanded table button should offer to collapse") + preserved_editor = probe.property_table.cellWidget(target_row, PROPERTY_TARGET_COLUMN) + _assert(isinstance(preserved_editor, QLineEdit), "target editor disappeared after table expand") + _assert(preserved_editor.text().strip() == "12", "target value was not preserved after table expand") + + +def _assert_diagnostics_stay_out_of_parameter_table(probe: _PropertyTableProbe) -> None: + long_context = "已按“相邻特征”沿共享边拓扑探测当前特征及 3 个局部关联特征;关联尺寸可在同一参数表中直接修改。" + probe.current_info_values = { + **probe._plane_info(), + "feature_context_note": long_context, + "feature_detection_level": "相邻特征", + "associated_feature_count": 3, + } + probe._refresh_property_editor() + QApplication.processEvents() + table_labels = [ + probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text() + for row in range(probe.property_table.rowCount()) + if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None + ] + for diagnostic_label in ("建模形式", "推荐操作", "一级关系", "关联探测"): + _assert(diagnostic_label not in table_labels, f"{diagnostic_label} should not be shown as a feature parameter") + + +def _assert_mouse_selection_guards() -> None: + mouse_probe = _MouseSelectionProbe() + mouse_probe._handle_left_button_press(20, 20) + mouse_probe._handle_left_button_release(20, 20) + _assert( + [target.get("target_id") for target in mouse_probe.selected_targets] == [1], + "plain left click should still select", + ) + + mouse_probe = _MouseSelectionProbe() + mouse_probe._handle_left_button_press(20, 20) + mouse_probe._update_left_button_drag_state(40, 20) + mouse_probe._handle_left_button_release(40, 20) + _assert(not mouse_probe.selected_targets, "left-button drag should not select a face on release") + + mouse_probe = _MouseSelectionProbe() + mouse_probe._handle_left_button_press(20, 20) + mouse_probe.camera_state = (0.5, 0.0, 9.8, 0.0, 0.0, 0.0, 0.02, 1.0, 0.0, 1.0, 9.8, 30.0) + mouse_probe._handle_left_button_release(22, 21) + _assert( + not mouse_probe.selected_targets, + "left-button camera rotation should not select a face even when the cursor lands on the model", + ) + + mouse_probe = _MouseSelectionProbe() + mouse_probe.pick_targets = [None, {"kind": "face", "target_id": 1, "pick_position": (0.0, 0.0, 0.0)}] + mouse_probe._handle_left_button_press(20, 20) + mouse_probe._handle_left_button_release(20, 20) + _assert( + not mouse_probe.selected_targets, + "left-button press on the background should not select a face on release", + ) + + mouse_probe = _MouseSelectionProbe() + mouse_probe.pick_targets = [ + {"kind": "face", "target_id": 1, "pick_position": (0.0, 0.0, 0.0)}, + {"kind": "face", "target_id": 2, "pick_position": (0.0, 0.0, 0.0)}, + ] + mouse_probe._handle_left_button_press(20, 20) + mouse_probe._handle_left_button_release(20, 20) + _assert( + not mouse_probe.selected_targets, + "left-button press/release on different faces should not change selection", + ) + + +def _assert_quick_blind_depth_spec() -> None: + blind_probe = _PropertyTableProbe() + blind_probe.selected_kind = "feature" + blind_probe.selected_face_id = 6 + quick_blind_info = { + "surface": "cylinder", + "diameter": 4.0, + "radius": 2.0, + "axis_point": (0.0, 0.0, 0.0), + "axis": (0.0, 0.0, 1.0), + "angular_span": math.tau, + "feature_guess": "hole/groove candidate", + "feature_type": "圆柱孔候选", + "confidence": "medium", + "cylinder_end_type": "blind", + "hole_depth_estimate": 6.0, + "depth_status": "ready", + "recognition_ready_actions": "孔/槽/圆柱直径;盲孔/盲槽深度;封堵孔/槽", + } + blind_specs, _blind_used = blind_probe._editable_property_specs(quick_blind_info) + blind_feature_specs = blind_probe._feature_property_specs(blind_specs, quick_blind_info) + blind_depth_specs = [ + spec for spec in blind_feature_specs if str(spec.get("key", "")) == "hole_depth_estimate" + ] + _assert(blind_depth_specs, "quick blind hole depth estimate should be visible in feature parameters") + blind_depth_spec = blind_depth_specs[0] + blind_effective_depth = blind_probe._effective_property_spec(blind_depth_spec) + _assert(bool(blind_effective_depth.get("enabled")), "quick blind hole depth should be editable") + _assert( + str(blind_effective_depth.get("action", "")) == "resize_hole_depth", + f"quick blind hole depth should use local depth edit first: {blind_effective_depth}", + ) + _assert( + "重新确认底面" in str(blind_effective_depth.get("enabled_tip", "")), + "quick blind hole depth should explain execution-time bottom-face confirmation", + ) + owning_mode = dict(blind_depth_spec.get("scope_modes", {})).get("owning", {}) + _assert( + not bool(owning_mode.get("enabled")), + "quick blind hole without explicit bottom faces should not expose owning-scale depth as editable", + ) + + +def _assert_user_facing_failure_messages() -> None: + action_probe = _ActionMessageProbe() + illegal_context = { + "operation_name": "测试修改", + "target": "Face 1", + "parameters": {"resize_status": "blocked", "resize_blockers": "目标值必须大于 0。"}, + } + illegal_blocker = action_probe._edit_preflight_blocker(illegal_context) + _assert(illegal_blocker is not None, "blocked edit plan should be stopped before worker startup") + _assert(illegal_blocker[0] == "当前操作不合法", "illegal blocked edit should be classified clearly") + english_illegal_context = { + "operation_name": "调整槽宽", + "target": "Face 3", + "parameters": {"resize_status": "blocked", "resize_blockers": "Target slot value must be greater than 0."}, + } + english_illegal_blocker = action_probe._edit_preflight_blocker(english_illegal_context) + _assert( + english_illegal_blocker is not None and english_illegal_blocker[0] == "当前操作不合法", + "english geometry blockers should also be classified as illegal operations", + ) + risk_blocker = action_probe._plan_preflight_blocker( + {"status": "blocked", "risk": "blocked", "message": "目标壳体厚度会让几何风险过高。"} + ) + _assert(risk_blocker is not None and risk_blocker[0] == "风险过高", "blocked high-risk plans should be classified") + recognition_blocker = action_probe._plan_preflight_blocker( + {"status": "blocked", "message": "当前平面没有识别到相对壳体平面。"} + ) + _assert( + recognition_blocker is not None and recognition_blocker[0] == "识别不足", + "blocked recognition failures should be classified", + ) + unsupported_blocker = action_probe._plan_preflight_blocker( + {"status": "blocked", "message": "当前版本只对简单圆锥解析重建开放这类修改。"} + ) + _assert( + unsupported_blocker is not None and unsupported_blocker[0] == "暂未实现", + "blocked unsupported capability plans should be classified", + ) + auxiliary_context = { + "operation_name": "测试修改", + "target": "Face 4", + "parameters": {"quick_plan_status": "blocked", "resize_status": "ready", "resize_blockers": ""}, + } + _assert(action_probe._edit_preflight_blocker(auxiliary_context) is None, "auxiliary statuses should not block ready edits") + + deferred_context = { + "operation_name": "复杂 Face 修改", + "target": "Face 2", + "parameters": {"ui_deferred_model_plan": True, "message": "当前对象需要完整一级关系计划。"}, + } + deferred_blocker = action_probe._edit_preflight_blocker(deferred_context) + _assert(deferred_blocker is not None, "deferred model plan should be stopped before slow worker startup") + _assert(deferred_blocker[0] == "暂未实现", "deferred model plan should be classified as unsupported") + + title, user_message = action_probe._user_facing_edit_failure_message( + "隔离子进程执行失败;主程序没有崩溃,原模型保持不变。 当前版本暂不支持复杂链式圆角。", + deferred_context, + ) + _assert(title == "不能修改", "edit failure dialog title should be user-facing") + _assert("隔离子进程" not in user_message and "子进程" not in user_message, "failure message should not expose isolation details") + _assert("暂未实现" in user_message, "unsupported failure should state that the operation is not implemented yet") + _empty_title, empty_message = action_probe._user_facing_edit_failure_message( + "隔离子进程执行失败;主程序没有崩溃,原模型保持不变。", + deferred_context, + ) + _assert("隔离子进程" not in empty_message and "子进程" not in empty_message, "empty internal failure should stay user-facing") + + def main() -> int: app = QApplication.instance() or QApplication([]) top_level_label_probe = _TopLevelPropertyLabelProbe() app.installEventFilter(top_level_label_probe) - probe = _PropertyCardProbe() + + probe = _PropertyTableProbe() probe.show() QApplication.processEvents() probe._refresh_property_editor() QApplication.processEvents() _assert( not top_level_label_probe.shown_labels, - f"property card labels were shown as transient top-level windows: {top_level_label_probe.shown_labels}", + f"property labels were shown as transient top-level windows: {top_level_label_probe.shown_labels}", ) - - actionable_labels = [str(spec.get("label", "")) for _row, spec in probe._actionable_property_rows()] - _assert("面积" in actionable_labels, "modifiable feature list did not expose the editable area row") - _assert(not probe.property_command_buttons, "legacy command buttons should not be shown in the modifiable-feature panel") - _assert(not probe.property_command_bar.isVisible(), "legacy command bar should be hidden") - _assert("可修改项" in probe.property_command_summary_label.text(), "modifiable-feature summary is missing") - - rows = getattr(probe, "property_card_rows", {}) - _assert(rows, "property card rows were not built") + _assert_property_table_editor(probe) + _assert("当前支持" in probe.current_capability_headline.text(), "software progress panel did not show supported areas") + _assert("优先:" not in probe.current_capability_headline.text(), "software progress panel should not show priority copy") _assert( - not any(isinstance(widgets.get("target_editor"), QLineEdit) for widgets in rows.values()), - "modifiable feature rows should be compact until the user expands one", - ) - _assert( - all(str(widgets.get("status_label").text()) == "可修改" for widgets in rows.values()), - "compact modifiable rows should end with the editable status label", - ) - _assert( - all(not str(widgets.get("current_value").toolTip()) for widgets in rows.values() if widgets.get("current_value") is not None), - "compact modifiable rows should not show click-triggered tooltips", + "矩形槽口袋" in probe.current_capability_headline.toolTip() + and "多台阶矩形凸台顶层" in probe.current_capability_headline.toolTip(), + "software progress tooltip should name newly supported prismatic feature edits", ) - target_row = probe._actionable_property_rows()[0][0] - probe._select_property_card_row(target_row) - rows = getattr(probe, "property_card_rows", {}) - editable_rows = [row for row, widgets in rows.items() if isinstance(widgets.get("action_button"), QPushButton)] - target_rows = [row for row, widgets in rows.items() if isinstance(widgets.get("target_editor"), QLineEdit)] - scope_rows = [row for row, widgets in rows.items() if isinstance(widgets.get("scope_combo"), NoWheelComboBox)] + _assert_diagnostics_stay_out_of_parameter_table(probe) + _assert_mouse_selection_guards() + _assert_quick_blind_depth_spec() + _assert_user_facing_failure_messages() - _assert(editable_rows, "editable property card button is missing") - _assert(target_rows, "property card target editor is missing") - _assert(scope_rows, "property card modeling-intent combo is missing") - - probe._toggle_property_card_row(target_row) - QApplication.processEvents() - rows = getattr(probe, "property_card_rows", {}) - _assert(probe.property_editor_selected_row is None, "clicking an expanded row should collapse it") - _assert( - not any(isinstance(widgets.get("target_editor"), QLineEdit) for widgets in rows.values()), - "collapsed modifiable rows should return to compact display", - ) - _assert( - not any(editor.isVisible() for editor in probe.property_card_container.findChildren(QLineEdit)), - "collapsed modifiable rows should not leave stale target editors visible", - ) - _assert( - not any( - label.isVisible() and "建模意图:" in label.text() - for label in probe.property_card_container.findChildren(QLabel) - ), - "collapsed modifiable rows should not leave stale expanded hints visible", - ) - - probe._select_property_card_row(target_row) - rows = getattr(probe, "property_card_rows", {}) - target_rows = [row for row, widgets in rows.items() if isinstance(widgets.get("target_editor"), QLineEdit)] - _assert(target_rows, "target editor is missing after re-expanding the row") - - rows[target_rows[0]]["target_editor"].setText("144") - probe._update_property_apply_state() - changed = probe._changed_property_rows() - button = rows[target_rows[0]]["action_button"] - _assert(changed, "card target edit was not detected") - _assert(bool(button.property("changed")), "card button did not enter changed state") - _assert(button.isEnabled(), "card button should be enabled for a valid changed target") - - probe.toggle_property_table_expanded() - _assert(probe.property_table_expanded, "property card expand toggle failed") - _assert(len(probe.property_card_rows) > len(rows), "expanded property card list did not reveal more rows") - expanded_editor = probe.property_card_rows[target_rows[0]].get("target_editor") - _assert(isinstance(expanded_editor, QLineEdit), "expanded target editor is missing") - _assert(expanded_editor.text().strip() == "144", "target value was not preserved after card rebuild") - - long_context = "已按“相邻特征”沿共享边拓扑探测当前特征及 3 个局部关联特征;关联尺寸可在同一参数表中直接修改。" - probe.property_editor_specs = [ - { - "key": "feature_context_note", - "label": "关联探测", - "current_text": long_context, - "current_raw": long_context, - "target_text": "", - "editable": False, - "enabled": False, - "status_text": "说明", - "span_value_columns": True, - "pin_top": True, - }, - { - "key": "associated_face_center", - "label": "相邻平面 Face 1580 · 中心", - "current_text": "(-118.585, -23.3, -269.561)", - "current_raw": "(-118.585, -23.3, -269.561)", - "target_text": "(-118.585, -23.3, -269.561)", - "editable": True, - "enabled": True, - "action": "move_face_center", - "scope_text": "局部重建", - "status_text": "可修改", - }, - ] - probe.property_table_expanded = True - probe.property_editor_selected_row = None - probe._rebuild_property_cards() - QApplication.processEvents() - context_value = next( - ( - label - for label in probe.property_card_rows[0]["card"].findChildren(QLabel) - if label.objectName() == "propertyCardValue" - ), - None, - ) - _assert(isinstance(context_value, QLabel), "long associated detection note is missing") - _assert(context_value.wordWrap(), "long associated detection note should wrap in diagnostics view") - _assert("关联尺寸可在同一参数表中直接修改" in context_value.text(), "associated detection note lost its trailing text") - associated_widgets = probe.property_card_rows[1] - associated_card = associated_widgets["card"] - associated_title = next( - ( - label - for label in associated_card.findChildren(QLabel) - if label.objectName() == "propertyCardTitle" - ), - None, - ) - _assert(isinstance(associated_title, QLabel), "long associated row title is missing") - _assert(associated_title.wordWrap(), "long associated row title should use a two-line compact layout") - _assert(associated_card.height() > 24, "long associated row should be taller than a one-line compact row") - associated_value = associated_widgets.get("current_value") - _assert(isinstance(associated_value, QLabel), "long associated row value is missing") - _assert("(-118.585" in associated_value.text(), "long associated row value was hidden by the title") - - print("property card editor UI ok") + print("property table editor UI ok") if QApplication.instance() is app: app.quit() return 0 diff --git a/scripts/verify_property_editor_specs.py b/scripts/verify_property_editor_specs.py index fa092fd..afd3fe8 100644 --- a/scripts/verify_property_editor_specs.py +++ b/scripts/verify_property_editor_specs.py @@ -46,6 +46,15 @@ def _edge_specs(info: dict[str, object]) -> list[dict[str, object]]: return specs +def _edge_display_specs(info: dict[str, object]) -> list[dict[str, object]]: + probe = _PropertySpecProbe() + probe.selected_kind = "edge" + probe.selected_face_id = None + probe.selected_edge_id = 7 + specs = probe._property_editor_specs(info, info) + return probe._sort_property_specs_for_display(specs) + + def _display_specs(info: dict[str, object]) -> list[dict[str, object]]: probe = _PropertySpecProbe() probe.selected_kind = "face" @@ -105,7 +114,7 @@ def _assert_current_text_contains( def _assert_spans_value_columns(specs: list[dict[str, object]], key: str) -> None: spec = _spec(specs, key) if not bool(spec.get("span_value_columns")): - raise SystemExit(f"{key} should span the value/intent/target/action columns: {spec}") + raise SystemExit(f"{key} should span the value/intent/target columns: {spec}") def _assert_hard_range(specs: list[dict[str, object]], key: str, low: float, high: float) -> None: @@ -195,8 +204,6 @@ def _assert_actionable_rows_first(info: dict[str, object], expected_keys: tuple[ seen_non_actionable = False front_keys: list[str] = [] for spec in display_specs: - if bool(spec.get("pin_top")): - continue key = str(spec.get("key", "")) if _is_actionable_edit_spec(spec): if seen_non_actionable: @@ -210,6 +217,13 @@ def _assert_actionable_rows_first(info: dict[str, object], expected_keys: tuple[ raise SystemExit(f"{label}: expected editable rows at the front are missing: {missing}; front={front_keys}") +def _assert_keys_absent(specs: list[dict[str, object]], forbidden_keys: tuple[str, ...], label: str) -> None: + keys = {str(spec.get("key", "")) for spec in specs} + leaked = [key for key in forbidden_keys if key in keys] + if leaked: + raise SystemExit(f"{label} should not show diagnostic/non-editable rows in the parameter table: {leaked}") + + def _collect_legacy_face_terms(value: object, path: str = "specs") -> list[str]: legacy_terms = ( "面内尺寸 1/2", @@ -296,7 +310,7 @@ def _assert_target_change_detection() -> None: probe = _PropertySpecProbe() number_spec = { - "label": "面积", + "label": "面内长度", "current_raw": 100.0, "current_text": "100", "value_type": "positive", @@ -321,19 +335,17 @@ def _assert_target_change_detection() -> None: def _assert_property_table_column_widths() -> None: for width in (320, 340, 360, 400, 520): columns = _property_table_column_widths(width) - if len(columns) != 5: - raise SystemExit(f"property table should have five column widths, got {columns}") + if len(columns) != 4: + raise SystemExit(f"property table should have four column widths, got {columns}") if sum(columns) != width: raise SystemExit(f"property table widths should fill viewport {width}, got {columns} sum={sum(columns)}") - label_width, current_width, scope_width, target_width, action_width = columns - if current_width < label_width + 40: - raise SystemExit(f"current value column should be wider than parameter-name column at {width}: {columns}") - if action_width < 70: - raise SystemExit(f"operation column should keep row buttons visible at {width}: {columns}") + label_width, current_width, scope_width, target_width = columns + if current_width < 96: + raise SystemExit(f"current value column should stay readable at {width}: {columns}") if target_width < 56: raise SystemExit(f"target value column should stay usable at {width}: {columns}") - if scope_width > label_width + 8: - raise SystemExit(f"modeling-intent column should not take space from current values at {width}: {columns}") + if scope_width < 44: + raise SystemExit(f"modeling-intent column should stay usable at {width}: {columns}") def _assert_holed_plane_local_scopes_disabled() -> None: @@ -355,7 +367,7 @@ def _assert_holed_plane_local_scopes_disabled() -> None: "local_face_deform_blocker": "has inner boundary", } ) - for key in ("area", "local_face_width", "local_face_height", "face_center_position"): + for key in ("local_face_width", "local_face_height", "face_center_position"): local_mode = _scope_mode(specs, key, "local") if bool(local_mode.get("enabled", True)): raise SystemExit(f"{key} local Face scope should be disabled for a holed planar Face") @@ -429,26 +441,44 @@ def main() -> int: _assert_current_text_contains( plane_specs, "cad_modeling_form", - ("柔性建模", "拉伸切除", "偏移变换"), + ("柔性建模", "拉伸切除", "偏移"), "plane Face", ) + _assert_hint_fragments( + _spec(plane_specs, "cad_modeling_form").get("disabled_tip"), + ("CAD 语义判断", "这东西像什么建模对象"), + "CAD modeling form tooltip", + ) _assert_label(plane_specs, "cad_recommended_operation", "推荐操作") _assert_spans_value_columns(plane_specs, "cad_recommended_operation") _assert_current_text_contains( plane_specs, "cad_recommended_operation", - ("优先改偏移变换", "拉伸/切除"), + ("优先改偏移", "拉伸/切除"), "plane Face", ) + _assert_hint_fragments( + _spec(plane_specs, "cad_recommended_operation").get("disabled_tip"), + ("相对安全的改法建议", "建议你怎么改"), + "recommended operation tooltip", + ) plane_display_specs = _display_specs(plane_info) - if str(plane_display_specs[0].get("key", "")) != "cad_modeling_form": - raise SystemExit(f"plane Face should show CAD modeling form first: {plane_display_specs[0]}") - if str(plane_display_specs[1].get("key", "")) != "cad_recommended_operation": - raise SystemExit(f"plane Face should show recommended operation second: {plane_display_specs[1]}") + if any(str(spec.get("key", "")) == "area" for spec in plane_display_specs): + raise SystemExit("Face property table should keep area in diagnostics, not in the parameter table") + _assert_keys_absent( + plane_display_specs, + ( + "cad_modeling_form", + "cad_recommended_operation", + "face_first_level_topology", + "face_edit_semantics", + "feature_context_note", + ), + "plane Face display specs", + ) _assert_contains( plane_keys, { - "area", "local_face_width", "local_face_height", "face_center_position", @@ -456,13 +486,14 @@ def main() -> int: }, "plane Face", ) - _assert_label(plane_specs, "local_face_width", "U向尺寸") - _assert_label(plane_specs, "local_face_height", "V向尺寸") - _assert_label(plane_specs, "face_target_normal_position", "偏移变换") + if "area" in plane_keys: + raise SystemExit("plane Face should not expose area as a modifiable parameter") + _assert_label(plane_specs, "local_face_width", "面内长度") + _assert_label(plane_specs, "local_face_height", "面内宽度") + _assert_label(plane_specs, "face_target_normal_position", "偏移") _assert_actionable_rows_first( plane_info, ( - "area", "local_face_width", "local_face_height", "face_center_position", @@ -476,22 +507,18 @@ def main() -> int: plane_feature_specs, _used = plane_feature_probe._editable_property_specs(plane_info) plane_feature_rows = plane_feature_probe._feature_property_specs(plane_feature_specs, plane_info) plane_feature_keys = {str(spec.get("key", "")) for spec in plane_feature_rows} - topology_spec = _spec(plane_feature_rows, "face_first_level_topology") - _assert_spans_value_columns(plane_feature_rows, "face_first_level_topology") - topology_text = str(topology_spec.get("current_text") or "") - for fragment in ("Face 区域 1 个", "边界 Edge 4 条", "共享边相邻 Face 4 个"): - if fragment not in topology_text: - raise SystemExit(f"plane feature topology row should explain first-level counts, got {topology_spec}") - _assert_label(plane_feature_rows, "face_edit_semantics", "建模意图") - _assert_spans_value_columns(plane_feature_rows, "face_edit_semantics") - _assert_label(plane_feature_rows, "cad_modeling_form", "建模形式") - _assert_label(plane_feature_rows, "cad_recommended_operation", "推荐操作") - if str(plane_feature_rows[0].get("key", "")) != "cad_modeling_form": - raise SystemExit(f"feature mode should keep CAD modeling form first: {plane_feature_rows[0]}") - if str(plane_feature_rows[1].get("key", "")) != "cad_recommended_operation": - raise SystemExit(f"feature mode should keep recommended operation second: {plane_feature_rows[1]}") + _assert_keys_absent( + plane_feature_rows, + ( + "cad_modeling_form", + "cad_recommended_operation", + "face_first_level_topology", + "face_edit_semantics", + "feature_context_note", + ), + "plane feature mode display specs", + ) expected_plane_feature_keys = { - "area", "local_face_width", "local_face_height", "face_center_position", @@ -506,7 +533,6 @@ def main() -> int: ) if "no_editable_feature_dimensions" in plane_feature_keys: raise SystemExit("plane feature mode should not fall back to no editable dimensions") - _assert_hard_range(plane_specs, "area", 0.25, 2500.0) _assert_hard_range(plane_specs, "local_face_width", 0.5, 50.0) _assert_hard_range(plane_specs, "local_face_height", 0.5, 50.0) _assert_hard_range(plane_specs, "face_target_normal_position", -70.7106781185, 70.7106781185) @@ -514,8 +540,6 @@ def main() -> int: _assert_validation_error(probe, _spec(plane_specs, "local_face_width"), "0.49", True, "Face width below hard range") _assert_validation_error(probe, _spec(plane_specs, "local_face_width"), "0.5", False, "Face width lower boundary") _assert_validation_error(probe, _spec(plane_specs, "local_face_width"), "50.1", True, "Face width above hard range") - _assert_validation_error(probe, _spec(plane_specs, "area"), "0.2", True, "Face area below hard range") - _assert_validation_error(probe, _spec(plane_specs, "area"), "2500", False, "Face area upper boundary") _assert_validation_error( probe, _spec(plane_specs, "face_target_normal_position"), @@ -543,7 +567,7 @@ def main() -> int: ) if "push_pull_distance" in plane_keys: raise SystemExit("plane Face should not expose a separate push_pull_distance row") - for key in ("area", "local_face_width", "local_face_height"): + for key in ("local_face_width", "local_face_height"): for mode in ("local", "owning"): _assert_scoped_hint_fragments( plane_specs, @@ -664,7 +688,18 @@ def main() -> int: cylinder_feature_probe = _PropertySpecProbe() cylinder_feature_specs, _used = cylinder_feature_probe._editable_property_specs(cylinder_topology_info) cylinder_feature_rows = cylinder_feature_probe._feature_property_specs(cylinder_feature_specs, cylinder_topology_info) - _spec(cylinder_feature_rows, "cylindrical_feature_first_level_topology") + _assert_keys_absent( + cylinder_feature_rows, + ( + "cad_modeling_form", + "cad_recommended_operation", + "cylindrical_feature_first_level_topology", + "hole_edit_semantics", + "slot_edit_semantics", + "feature_context_note", + ), + "cylindrical feature mode display specs", + ) generic_cylinder_specs = _specs( { @@ -820,6 +855,8 @@ def main() -> int: "radius": 1.0, "angular_span": 1.5707963267948966, "existing_fillet_radius": 1.0, + "existing_fillet_arc_length_estimate": 1.5707963267948966, + "existing_fillet_angular_span": 1.5707963267948966, "feature_existing_fillet_support_face_ids": (1, 2), "area": 3.0, "area_center": (0.0, 0.0, 0.0), @@ -830,7 +867,11 @@ def main() -> int: fillet_specs = _specs(fillet_info) fillet_keys = {str(spec.get("key", "")) for spec in fillet_specs} _assert_no_generic_face_leak(fillet_keys, "existing fillet feature") - _assert_contains(fillet_keys, {"existing_fillet_radius_estimate"}, "existing fillet feature") + _assert_contains( + fillet_keys, + {"existing_fillet_radius_estimate", "existing_fillet_arc_length_estimate"}, + "existing fillet feature", + ) _assert_current_text_contains( fillet_specs, "cad_modeling_form", @@ -914,9 +955,7 @@ def main() -> int: _assert_label(specs, "cone_reference_diameter", "参考直径") _assert_label(specs, "cone_semi_angle_degrees", "圆锥半角") display_specs = _display_specs(info) - surface_spec = _spec(display_specs, "surface") - if surface_spec.get("current_text") != "圆锥面 / 拔模面": - raise SystemExit(f"cone surface should be displayed in user-facing Chinese, got {surface_spec}") + _assert_keys_absent(display_specs, ("surface",), "cone feature display specs") edge_specs = _edge_specs( { @@ -963,6 +1002,39 @@ def main() -> int: {"length", "edge_first_level_topology"}, "line Edge", ) + _assert_contains( + {str(spec.get("key", "")) for spec in _edge_display_specs({"curve": "line", "length": 10.0})}, + {"length", "edge_length_anchor_mode"}, + "line Edge display", + ) + + complex_edge_specs = _edge_specs({"curve": "b-spline curve", "length": 12.0}) + complex_length_spec = _spec(complex_edge_specs, "length") + if complex_length_spec.get("enabled"): + raise SystemExit(f"complex curve Edge length should not be enabled in the property specs: {complex_length_spec}") + complex_length_tip = str(complex_length_spec.get("disabled_tip") or "") + if "复杂曲线Edge暂未实现" not in complex_length_tip: + raise SystemExit(f"complex curve Edge length tip should explain unsupported editing: {complex_length_tip}") + complex_edge_keys = {str(spec.get("key", "")) for spec in _edge_display_specs({"curve": "b-spline curve", "length": 12.0})} + if "length" in complex_edge_keys or "edge_length_anchor_mode" in complex_edge_keys: + raise SystemExit(f"complex curve Edge display should hide length editing rows: {complex_edge_keys}") + + ellipse_display_specs = _edge_display_specs( + { + "curve": "ellipse", + "length": 23.0, + "major_radius": 5.0, + "minor_radius": 2.0, + } + ) + ellipse_display_keys = {str(spec.get("key", "")) for spec in ellipse_display_specs} + _assert_contains( + ellipse_display_keys, + {"ellipse_edge_major_radius", "ellipse_edge_minor_radius"}, + "ellipse Edge display", + ) + if "length" in ellipse_display_keys or "edge_length_anchor_mode" in ellipse_display_keys: + raise SystemExit(f"ellipse Edge display should prefer explicit axis radii over generic length: {ellipse_display_keys}") low_recognition_specs = _specs( { diff --git a/scripts/verify_selection_identity_ui.py b/scripts/verify_selection_identity_ui.py index 39eec5f..1b72eff 100644 --- a/scripts/verify_selection_identity_ui.py +++ b/scripts/verify_selection_identity_ui.py @@ -163,6 +163,8 @@ def main() -> int: "Face 594" in feature_title and "拓扑 637" in feature_title, f"feature title should show logical and topological IDs: {feature_title}", ) + _assert("候选" not in feature_title, f"feature title should use a user-facing label, got: {feature_title}") + _assert("可拉伸/切除平面" in feature_title, f"feature title should keep the editable feature name: {feature_title}") face_probe = _probe("face", 637, info) _assert( @@ -174,6 +176,7 @@ def main() -> int: "Face 594" in face_title and "拓扑 637" in face_title, f"Face title should show logical and topological IDs: {face_title}", ) + _assert("候选" not in face_title, f"Face title should not expose candidate wording: {face_title}") fallback_probe = _probe("feature", 637, {"face_region_logical_id": 594}) _assert(fallback_probe._selected_id_text() == "face 594", "logical Face ID fallback should be copied") diff --git a/step_editor/app.py b/step_editor/app.py index 39593de..b037654 100644 --- a/step_editor/app.py +++ b/step_editor/app.py @@ -190,8 +190,11 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.hover_after_camera_cooldown_ms = 420 self.pointer_button_down = False self.left_button_press_position: tuple[int, int] | None = None + self.left_button_press_camera_state: tuple[float, ...] | None = None + self.left_button_press_target: dict[str, object] | None = None self.left_button_dragged = False self.left_click_drag_threshold_px = 6 + self.left_click_camera_tolerance = 1e-7 self.skip_next_vtk_left_press = False self.skip_next_vtk_left_release = False self.pick_marker_actor = None @@ -270,7 +273,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self._selected_action_info_cache_value: dict[str, object] | None = None self.property_editor_specs: list[dict[str, object]] = [] self.property_table_expanded = False - self.property_table_collapsed_rows = 8 + self.property_table_collapsed_rows = 5 + self.property_table_min_visible_rows = 5 self.property_editor_selected_row: int | None = None self.property_command_active_key = "" self.property_command_buttons: dict[str, QPushButton] = {} @@ -472,6 +476,24 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf border-color: #cbd5e1; color: #475569; } + QGroupBox#capabilitySection { + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-left: 5px solid #16a34a; + } + QGroupBox#capabilitySection::title { + background: #dcfce7; + border-color: #bbf7d0; + color: #14532d; + } + QLabel#capabilityHeadline { + color: #14532d; + font-weight: 800; + } + QLabel#capabilityDetail { + color: #166534; + font-size: 11px; + } QLabel { color: #2f3846; } @@ -510,6 +532,59 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf color: #8f99a8; font-weight: 500; } + QPushButton#parametricModelButton { + background: #1f6feb; + border: 1px solid #1d4ed8; + border-bottom-color: #1e40af; + border-radius: 6px; + color: #ffffff; + font-weight: 800; + min-height: 30px; + padding: 6px 12px; + } + QPushButton#parametricModelButton:hover { + background: #2563eb; + border-color: #1e40af; + } + QPushButton#parametricModelButton:pressed { + background: #1e40af; + border-color: #1e3a8a; + padding-top: 7px; + padding-bottom: 5px; + } + QPushButton#parametricModelButton:disabled { + background: #dbe4ef; + border: 1px dashed #adb9c8; + color: #8b97a7; + font-weight: 700; + } + QPushButton#quickExportStepButton { + background: #f8fafc; + border: 1px solid #64748b; + border-bottom-color: #475569; + border-radius: 6px; + color: #1f2937; + font-weight: 700; + min-height: 30px; + padding: 6px 11px; + } + QPushButton#quickExportStepButton:hover { + background: #eef6ff; + border-color: #2563eb; + color: #1e3a8a; + } + QPushButton#quickExportStepButton:pressed { + background: #dbeafe; + border-color: #1d4ed8; + padding-top: 7px; + padding-bottom: 5px; + } + QPushButton#quickExportStepButton:disabled { + background: #eef2f6; + border: 1px dashed #bcc7d4; + color: #8f99a8; + font-weight: 650; + } QPushButton#propertyRowEditButton { background: #ea580c; border: 1px solid #c2410c; @@ -1052,20 +1127,20 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.object_edit_box.setObjectName("editSection") help_tip( self.object_edit_box, - "选择对象后,这里以参数卡片显示当前值、建模意图、目标值和应用按钮;诊断信息默认折叠在后面。", + "选择特征后,这里只列出可可靠修改的语义尺寸;结果属性和识别依据位于下方诊断信息。", ) object_edit_layout = QVBoxLayout(self.object_edit_box) object_edit_layout.setContentsMargins(0, 8, 0, 4) object_edit_layout.setSpacing(4) - self.property_table = QTableWidget(0, 5, self.object_edit_box) + self.property_table = QTableWidget(0, 4, self.object_edit_box) self.property_table.setObjectName("propertyTable") - self.property_table.setHorizontalHeaderLabels(["参数", "当前值", "建模意图", "目标值", "操作"]) + self.property_table.setHorizontalHeaderLabels(["尺寸参数", "当前值", "建模意图", "目标值"]) self.property_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.property_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.property_table.setAlternatingRowColors(True) self.property_table.setMinimumHeight(180) self.property_table.setWordWrap(False) - self.property_table.setTextElideMode(Qt.TextElideMode.ElideRight) + self.property_table.setTextElideMode(Qt.TextElideMode.ElideMiddle) self.property_table.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.property_table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.property_table.verticalHeader().setVisible(False) @@ -1078,22 +1153,26 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf property_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) property_header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) - property_header.setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) - self._resize_property_table_columns() + property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) + self.property_table.setColumnWidth(0, 148) + self.property_table.setColumnWidth(1, 92) + self.property_table.setColumnWidth(2, 112) + self.property_table.setColumnWidth(3, 96) self.property_table.installEventFilter(self) help_tip( self.property_table, "特征模式显示当前特征及局部关联特征的可变尺寸;建模意图决定这次修改是局部重建、拉伸/切除、端面移动还是整体缩放。", ) self.property_table.itemChanged.connect(self._on_property_table_item_changed) - self.property_table.setVisible(False) - self.property_table.setMaximumHeight(0) + object_edit_layout.addWidget(self.property_table) self.property_command_summary_label = QLabel("未选择可编辑对象") self.property_command_summary_label.setObjectName("propertyCommandSummary") self.property_command_summary_label.setWordWrap(True) + self.property_command_summary_label.setVisible(False) object_edit_layout.addWidget(self.property_command_summary_label) self.property_command_bar = QFrame() self.property_command_bar.setObjectName("propertyCommandBar") + self.property_command_bar.setVisible(False) self.property_command_layout = QHBoxLayout(self.property_command_bar) self.property_command_layout.setContentsMargins(3, 3, 3, 3) self.property_command_layout.setSpacing(2) @@ -1117,12 +1196,14 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.property_card_scroll.setWidget(self.property_card_container) help_tip( self.property_card_scroll, - "每张卡片对应一个参数;修改目标值后,点击卡片右上角的按钮只应用这一项。", + "旧展示层已停用;当前使用上方表格展示参数。", ) + self.property_card_scroll.setVisible(False) + self.property_card_scroll.setMaximumHeight(0) object_edit_layout.addWidget(self.property_card_scroll) - self.property_expand_button = QPushButton("更多诊断信息") + self.property_expand_button = QPushButton("展开全部参数") self.property_expand_button.setObjectName("propertyExpandBar") - help_tip(self.property_expand_button, "默认优先显示可编辑参数和关键说明;点击这里展开或收起完整诊断信息。") + help_tip(self.property_expand_button, "参数较多时默认只显示前几行;点击这里展开或收起完整参数列表。") self.property_expand_button.clicked.connect(self.toggle_property_table_expanded) self.property_expand_button.setVisible(False) self.property_expand_button.setMaximumHeight(22) @@ -1131,10 +1212,15 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf property_action_row = QHBoxLayout() property_action_row.setContentsMargins(0, 0, 0, 0) self.apply_property_button = QPushButton("参数化建模") - self.apply_property_button.setVisible(False) - help_tip(self.apply_property_button, "按当前属性表里修改过的目标值执行参数化建模;一次只执行一个几何修改,成功后可撤销。") + self.apply_property_button.setObjectName("parametricModelButton") + self.apply_property_button.setMinimumHeight(34) + self.apply_property_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + help_tip(self.apply_property_button, "应用当前被修改的一个参数;一次只执行一个几何修改,成功后可撤销。") self.apply_property_button.clicked.connect(self.apply_current_property_edit) self.quick_export_all_button = QPushButton("导出当前完整STEP") + self.quick_export_all_button.setObjectName("quickExportStepButton") + self.quick_export_all_button.setMinimumHeight(34) + self.quick_export_all_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) help_tip(self.quick_export_all_button, "把当前编辑后的完整模型导出为 STEP 文件。导出前会做基础质量检查。") self.quick_export_all_button.clicked.connect(self.export_all) property_action_row.addWidget(self.apply_property_button) @@ -1245,13 +1331,13 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf help_tip(self.torus_radius_input, "环面的目标半径。当前选中对象表会使用这个隐藏输入执行环面修改。") self.face_area_input = QLineEdit("", edit_box) self.face_area_input.setVisible(False) - help_tip(self.face_area_input, "目标面面积。当前选中对象表会使用这个隐藏输入执行所属特征或 Solid 的均匀缩放。") + help_tip(self.face_area_input, "面积是诊断结果,不作为特征参数表里的驱动变量;该隐藏输入仅保留给历史后端回归路径使用。") self.face_width_input = QLineEdit("", edit_box) self.face_width_input.setVisible(False) - help_tip(self.face_width_input, "目标U向尺寸。当前选中对象表会使用这个隐藏输入修改 Face 平面内第一个方向的尺寸。") + help_tip(self.face_width_input, "目标面内长度。当前选中对象表会使用这个隐藏输入修改 Face 平面内第一个方向的尺寸。") self.face_height_input = QLineEdit("", edit_box) self.face_height_input.setVisible(False) - help_tip(self.face_height_input, "目标V向尺寸。当前选中对象表会使用这个隐藏输入修改 Face 平面内第二个方向的尺寸。") + help_tip(self.face_height_input, "目标面内宽度。当前选中对象表会使用这个隐藏输入修改 Face 平面内第二个方向的尺寸。") self.resize_boss_button = QPushButton("调整圆柱凸台直径") help_tip(self.resize_boss_button, "修改完整圆柱凸台直径。变大会加料,变小会重建凸台区域。") self.resize_boss_button.clicked.connect(self.resize_boss) @@ -1458,6 +1544,16 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf edit_layout.addWidget(self.rotate_solid_button, 27, 0, 1, 2) panel_layout.addWidget(self.object_edit_box) + self.current_capability_box = QGroupBox("软件进度") + self.current_capability_box.setObjectName("capabilitySection") + capability_layout = QVBoxLayout(self.current_capability_box) + capability_layout.setContentsMargins(8, 8, 8, 7) + capability_layout.setSpacing(2) + self.current_capability_headline = QLabel("当前支持:Face、孔/槽、Edge、凸台、圆角/倒角、壳体") + self.current_capability_headline.setObjectName("capabilityHeadline") + self.current_capability_headline.setWordWrap(True) + capability_layout.addWidget(self.current_capability_headline) + panel_layout.addWidget(self.current_capability_box) if ENABLE_EXPORT_PANEL: panel_layout.addWidget(export_box) if ENABLE_VIEW_PANEL: diff --git a/step_editor/features.py b/step_editor/features.py index 9b75596..678ea53 100644 --- a/step_editor/features.py +++ b/step_editor/features.py @@ -61,6 +61,7 @@ from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES from .geometry_utils import * # noqa: F403 +from .recognition_priority import feature_recognition_sort_key class FeatureMixin: @@ -318,6 +319,9 @@ class FeatureMixin: feature_guess = str(item["feature_guess"]) if existing_fillet_count < existing_fillet_limit and feature_guess == "round/fillet candidate": feature = self.feature_info(int(item["face_id"])) + existing_fillet_status = str(feature.get("existing_fillet_status") or "candidate") + if existing_fillet_status == "blocked": + continue support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ())) support_note = ( f"支撑 Face: {support_face_ids}。" @@ -349,7 +353,10 @@ class FeatureMixin: ) existing_fillet_count += 1 - if diameter_count < per_type_limit and feature_guess != "round/fillet candidate": + if diameter_count < per_type_limit and feature_guess not in { + "round/fillet candidate", + "boss/outer-round candidate", + }: candidates.append( { "operation_key": "resize_cylinder", @@ -735,6 +742,8 @@ class FeatureMixin: chamfer_edge_count = 0 edge_length_count = 0 circle_edge_radius_count = 0 + ellipse_edge_major_radius_count = 0 + ellipse_edge_minor_radius_count = 0 edge_type_limit = max(1, per_type_limit // 3) edge_scan_limit = len(self.edges) if max_scan_edges is None else min(len(self.edges), max(0, int(max_scan_edges))) for edge_id, edge in enumerate(self.edges[:edge_scan_limit]): @@ -750,6 +759,7 @@ class FeatureMixin: curve = BRepAdaptor_Curve(edge) is_line_edge = curve.GetType() == GeomAbs_Line is_circle_edge = curve.GetType() == GeomAbs_Circle + is_ellipse_edge = curve.GetType() == GeomAbs_Ellipse props = GProp_GProps() brepgprop.LinearProperties(edge, props) length = props.Mass() @@ -799,7 +809,7 @@ class FeatureMixin: } ) chamfer_edge_count += 1 - if edge_length_count < edge_type_limit: + if (is_line_edge or is_circle_edge) and edge_length_count < edge_type_limit: candidates.append( { "operation_key": "resize_edge_length", @@ -814,9 +824,9 @@ class FeatureMixin: "current_value": length, "current_value_label": "length", "status": "caution", - "risk": "medium" if is_line_edge or is_circle_edge else "high", + "risk": "medium", "confidence": "pending", - "note": "快速扫描:直线边优先局部形变;圆边优先换算相邻圆柱直径;椭圆/平面曲线会尝试径向缩放;其他边使用后备策略。", + "note": "快速扫描:直线边优先局部形变或端面移动;圆边优先换算相邻圆柱直径。", } ) edge_length_count += 1 @@ -846,34 +856,58 @@ class FeatureMixin: } ) circle_edge_radius_count += 1 + if is_ellipse_edge: + ellipse = curve.Ellipse() + if ellipse_edge_major_radius_count < edge_type_limit: + major_radius = float(ellipse.MajorRadius()) + if major_radius > 1e-9: + candidates.append( + { + "operation_key": "resize_ellipse_edge_major_radius", + "operation": "修改椭圆Edge主半径", + "target_kind": "edge", + "target_id": edge_id, + "edge_id": edge_id, + "part_id": self.edge_part_ids[edge_id], + "solid_id": solid_id, + "surface": "edge", + "feature_guess": "elliptical edge major radius candidate", + "current_value": major_radius, + "current_value_label": "major_radius", + "status": "caution", + "risk": "medium", + "confidence": "pending", + "note": "快速扫描:点击后会选中椭圆Edge;在参数表里按主轴方向单轴缩放所属对象。", + } + ) + ellipse_edge_major_radius_count += 1 + if ellipse_edge_minor_radius_count < edge_type_limit: + minor_radius = float(ellipse.MinorRadius()) + if minor_radius > 1e-9: + candidates.append( + { + "operation_key": "resize_ellipse_edge_minor_radius", + "operation": "修改椭圆Edge小半径", + "target_kind": "edge", + "target_id": edge_id, + "edge_id": edge_id, + "part_id": self.edge_part_ids[edge_id], + "solid_id": solid_id, + "surface": "edge", + "feature_guess": "elliptical edge minor radius candidate", + "current_value": minor_radius, + "current_value_label": "minor_radius", + "status": "caution", + "risk": "medium", + "confidence": "pending", + "note": "快速扫描:点击后会选中椭圆Edge;在参数表里按小轴方向单轴缩放所属对象。", + } + ) + ellipse_edge_minor_radius_count += 1 - status_order = {"ready": 0, "caution": 1, "blocked": 2} - risk_order = {"low": 0, "medium": 1, "high": 2, "blocked": 3} - operation_order = { - "resize_cylinder": 0, - "resize_slot_width": 1, - "resize_slot_depth": 2, - "resize_slot_arc_length": 3, - "resize_slot_angular_span": 4, - "resize_boss": 5, - "resize_boss_height": 6, - "suppress_cylinder": 7, - "resize_depth": 8, - "inspect_existing_fillet": 9, - "resize_shell_thickness": 10, - "push_pull_plane": 11, - "fillet_edge": 12, - "chamfer_edge": 13, - "resize_edge_length": 14, - } - candidates.sort( - key=lambda item: ( - status_order.get(str(item["status"]), 9), - risk_order.get(str(item["risk"]), 9), - operation_order.get(str(item["operation_key"]), 9), - int(item.get("target_id", item.get("face_id", item.get("edge_id", -1)))), - ) - ) + for candidate in candidates: + candidate["recognition_user_priority"] = feature_recognition_sort_key(candidate)[0] + candidates.sort(key=feature_recognition_sort_key) return candidates[:limit] def cylindrical_feature_candidates( diff --git a/step_editor/model.py b/step_editor/model.py index 26ac267..df2865d 100644 --- a/step_editor/model.py +++ b/step_editor/model.py @@ -80,6 +80,12 @@ from .export import ExportMixin from .features import FeatureMixin from .operations import OperationMixin from .polydata import PolydataMixin +from .recognition_priority import ( + feature_recognition_priority, + feature_recognition_priority_label, + feature_recognition_priority_reason, + feature_recognition_sort_key, +) from .transforms import TransformMixin from .geometry_utils import * # noqa: F403 from .model_types import PartNode, TopologyStats @@ -326,7 +332,11 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd raise ValueError(f"Unknown face id {face_id}") full_info = self._face_info_cache.get(face_id) if full_info is not None: - return dict(full_info) + info = dict(full_info) + if str(info.get("surface") or "") == "cylinder" and not str(info.get("feature_type") or ""): + info.update(self._cylindrical_feature_label_fields(info)) + info.update(self._recognition_summary_fields(info)) + return info cached = self._quick_face_info_cache.get(face_id) if cached is not None: return dict(cached) @@ -419,6 +429,39 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd info["slot_chord_width_estimate"] = 2.0 * radius * math.sin(min(u_span, math.tau) * 0.5) info["slot_sagitta_depth_estimate"] = radius * (1.0 - math.cos(min(u_span, math.tau) * 0.5)) info["slot_arc_length_estimate"] = radius * u_span + info.update(self._quick_cylindrical_feature_hint(face_id, surf, info)) + if info.get("feature_guess") == "hole/groove candidate": + try: + side_face_ids = _int_values(info.get("same_domain_face_ids")) or [face_id] + axis_range = self._cylindrical_axis_range(face_id, surf, side_face_ids) + info.update(self._cylinder_end_opening_info(face_id, surf, axis_range)) + info.update(self._cylindrical_feature_label_fields(info)) + except Exception: + pass + info.update(_cylinder_resize_readiness(info)) + info.update(_cylinder_depth_readiness(info)) + info.update(_cylinder_suppress_readiness(info)) + elif info.get("feature_guess") == "boss/outer-round candidate": + info.update( + { + "resize_status": "blocked", + "resize_risk": "blocked", + "resize_blockers": "当前对象快速识别为凸台/外圆;请使用凸台直径/高度/轴心入口,不按孔径重切。", + "resize_warnings": "", + "resize_note": "当前对象快速识别为凸台/外圆;请使用凸台直径/高度/轴心入口,不按孔径重切。", + } + ) + info.update(_cylinder_boss_resize_readiness(info)) + elif info.get("feature_guess") == "round/fillet candidate": + info.update( + { + "resize_status": "blocked", + "resize_risk": "blocked", + "resize_blockers": "当前对象快速识别为圆角/倒圆;请使用已有圆角半径入口,不按孔径重切。", + "resize_warnings": "", + "resize_note": "当前对象快速识别为圆角/倒圆;请使用已有圆角半径入口,不按孔径重切。", + } + ) elif surface_type == GeomAbs_Cone: cone = surf.Cone() info["axis_point"] = _point_tuple(cone.Location()) @@ -449,6 +492,189 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self._quick_face_info_cache[face_id] = dict(info) return dict(info) + def _cylindrical_feature_label_fields(self, info: dict[str, object]) -> dict[str, object]: + guess = str(info.get("feature_guess", "cylindrical face")) + angular_span = _float_or_none(info.get("same_domain_angular_span")) + if angular_span is None: + angular_span = _float_or_none(info.get("angular_span")) or 0.0 + is_full = bool(info.get("is_full_cylinder")) or angular_span >= math.tau * 0.92 + if guess == "hole/groove candidate": + if not is_full: + return { + "feature_type": "槽/半孔候选", + "feature_edit_actions": ( + "调整槽/半孔宽度、深度、圆弧长度、圆弧角度;" + "完整槽孔配对会在执行时计算。" + ), + } + edit_actions = "调整圆柱孔径" + if info.get("cylinder_end_type") == "blind": + edit_actions += ";调整盲孔/盲槽深度" + else: + edit_actions += ";孔深调整需要明确盲孔底面" + edit_actions += ";封堵圆柱孔" + return { + "feature_type": "圆柱孔候选", + "feature_edit_actions": edit_actions, + } + if guess == "boss/outer-round candidate": + return { + "feature_type": "凸台/外圆候选", + "feature_edit_actions": "调整圆柱凸台直径;调整圆柱凸台高度;修改圆柱凸台轴心坐标。", + } + if guess == "round/fillet candidate": + return { + "feature_type": "圆角/倒圆候选", + "feature_edit_actions": "可尝试修改已有圆角半径;支撑面会在执行时计算。", + } + return { + "feature_type": "未明确圆柱特征", + "feature_edit_actions": "可查看圆柱直径/半径;复杂语义需要手动扫描或执行计划确认。", + } + + def _quick_cylindrical_feature_hint( + self, + face_id: int, + surf: BRepAdaptor_Surface, + info: dict[str, object], + ) -> dict[str, object]: + """Cheap cylinder labeling for immediate selection feedback. + + This intentionally avoids material-side sampling. It only combines + directly connected co-cylindrical fragments and uses face orientation as + a hint, so full edit plans still recompute and guard the real feature + semantics before changing geometry. + """ + radius = _float_or_none(info.get("radius")) or 0.0 + selected_span = _float_or_none(info.get("angular_span")) or 0.0 + orientation = str(info.get("orientation") or "") + try: + boundary_edges = int(info.get("boundary_edges", 0) or 0) + except (TypeError, ValueError): + boundary_edges = 0 + solid_id = self.face_solid_ids[face_id] if 0 <= face_id < len(self.face_solid_ids) else -1 + solid_shape = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else self.shape + solid_diagonal = _shape_diagonal(solid_shape) + + side_face_ids = [face_id] + combined_span = selected_span + same_domain_note = "快速识别:当前圆柱没有检测到直接相接的同域碎面。" + axis_range: dict[str, object] | None = None + try: + side_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id] + spans: list[float] = [] + for side_id in side_face_ids: + side_surf = BRepAdaptor_Surface(self.faces[side_id]) + if side_surf.GetType() == GeomAbs_Cylinder: + spans.append(abs(float(side_surf.LastUParameter()) - float(side_surf.FirstUParameter()))) + if spans: + combined_span = min(sum(spans), math.tau) + axis_range = self._cylindrical_axis_range(face_id, surf, side_face_ids) + same_domain_note = ( + f"快速识别:已把 {len(side_face_ids)} 个直接相接的同域圆柱碎面合并判断。" + if len(side_face_ids) > 1 + else same_domain_note + ) + except Exception: + axis_range = None + + result: dict[str, object] = { + "same_domain_face_ids": tuple(side_face_ids), + "same_domain_face_count": len(side_face_ids), + "angular_span": combined_span, + "same_domain_angular_span": combined_span, + "same_domain_note": same_domain_note, + "is_full_cylinder": combined_span >= math.tau * 0.92, + } + if axis_range is not None: + result.update( + { + "same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]), + "same_domain_height_estimate": axis_range["span"], + "same_domain_range_source": axis_range["range_source"], + "height_estimate": axis_range["span"], + } + ) + + is_full = bool(result["is_full_cylinder"]) + is_partial = not is_full and 1e-6 < combined_span < math.tau * 0.92 + is_small_radius = solid_diagonal > 0 and radius <= solid_diagonal * 0.04 + is_fillet_radius = solid_diagonal > 0 and radius <= solid_diagonal * 0.12 + is_quarter_roundish = 0.15 <= selected_span <= math.pi * 1.05 + is_fillet_like = is_partial and is_quarter_roundish and is_fillet_radius and boundary_edges >= 4 + + if is_full and orientation == "reversed": + result.update( + { + "feature_guess": "hole/groove candidate", + "feature_type": "圆柱孔候选", + "feature_edit_actions": "调整圆柱孔径;孔深、轴心或封堵会在执行时重新确认一级关系。", + "confidence": "medium" if len(side_face_ids) > 1 else "high", + "slot_kind": "", + "slot_status": "", + "slot_angular_span": "", + "slot_open_angle": "", + "slot_chord_width_estimate": "", + "slot_arc_length_estimate": "", + "slot_sagitta_depth_estimate": "", + "note": "快速识别:完整圆柱且 Face 方向为 reversed,先按孔候选处理;完整计划会再做材料采样确认。", + } + ) + elif is_full and orientation == "forward": + result.update( + { + "feature_guess": "boss/outer-round candidate", + "feature_type": "凸台/外圆候选", + "feature_edit_actions": "调整凸台/外圆直径;高度和轴心会在执行时重新确认一级关系。", + "confidence": "medium" if len(side_face_ids) > 1 else "high", + "slot_kind": "", + "slot_status": "", + "slot_angular_span": "", + "slot_open_angle": "", + "slot_chord_width_estimate": "", + "slot_arc_length_estimate": "", + "slot_sagitta_depth_estimate": "", + "note": "快速识别:完整圆柱且 Face 方向为 forward,先按凸台/外圆候选处理;完整计划会再做材料采样确认。", + } + ) + elif is_fillet_like and is_small_radius: + result.update( + { + "feature_guess": "round/fillet candidate", + "feature_type": "圆角/倒圆候选", + "feature_edit_actions": "可尝试修改已有圆角半径;支撑面会在执行时重新确认。", + "confidence": "medium", + "note": "快速识别:小半径部分圆柱,先按圆角/倒圆候选处理。", + } + ) + elif is_partial: + span = min(max(combined_span, 0.0), math.tau) + result.update( + { + "feature_guess": "hole/groove candidate", + "feature_type": "槽/半孔候选", + "feature_edit_actions": "调整槽/半孔宽度、深度、弧长或弧角;执行时会重新确认槽壁一级关系。", + "confidence": "medium" if orientation == "reversed" else "low", + "slot_kind": "partial-cylindrical-groove", + "slot_status": "candidate", + "slot_angular_span": combined_span, + "slot_open_angle": max(math.tau - span, 0.0), + "slot_chord_width_estimate": 2.0 * radius * math.sin(span * 0.5) if radius > 0 else 0.0, + "slot_arc_length_estimate": radius * span, + "slot_sagitta_depth_estimate": radius * (1.0 - math.cos(min(span, math.pi) * 0.5)) if radius > 0 else 0.0, + "note": "快速识别:部分圆柱先按槽/半孔候选处理;完整计划会再做材料采样和边界确认。", + } + ) + else: + result.update( + { + "feature_guess": "cylindrical face", + "confidence": "unchecked", + "note": "快速识别无法稳定判断孔、槽、凸台或圆角;执行具体修改时会生成完整计划。", + } + ) + return result + def face_info(self, face_id: int) -> dict[str, object]: if face_id in self._face_info_cache: return dict(self._face_info_cache[face_id]) @@ -525,6 +751,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd info.update(_cylinder_boss_resize_readiness(info)) info.update(_cylinder_depth_readiness(info)) info.update(_cylinder_suppress_readiness(info)) + info.update(self._cylindrical_feature_label_fields(info)) elif surface_type == GeomAbs_Cone: cone = surf.Cone() info["axis_point"] = _point_tuple(cone.Location()) @@ -601,6 +828,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd def _recognition_summary_fields(self, info: dict[str, object]) -> dict[str, object]: surface = str(info.get("surface") or "") + user_priority = feature_recognition_priority(info) + user_priority_label = feature_recognition_priority_label(info) + user_priority_reason = feature_recognition_priority_reason(info) candidate = ( str(info.get("feature_type") or "").strip() or str(info.get("feature_guess") or "").strip() @@ -637,12 +867,29 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd ("boss_resize_status", "boss_resize_risk", "boss_resize_blockers", "圆柱凸台直径/高度/轴心"), ("depth_status", "depth_risk", "depth_blockers", "盲孔/盲槽深度"), ("suppress_status", "suppress_risk", "suppress_blockers", "封堵孔/槽"), + ("existing_fillet_status", "existing_fillet_risk", "existing_fillet_blockers", "已有圆角半径"), ("fillet_status", "fillet_risk", "fillet_blockers", "已有圆角半径"), ("chamfer_status", "chamfer_risk", "chamfer_blockers", "倒角"), ) + feature_guess_for_capability = str(info.get("feature_guess") or "") + + def capability_is_relevant(status_key: str) -> bool: + if surface != "cylinder": + return True + if feature_guess_for_capability == "hole/groove candidate": + return status_key in {"resize_status", "depth_status", "suppress_status", "cylinder_resize_status"} + if feature_guess_for_capability == "boss/outer-round candidate": + return status_key in {"boss_resize_status", "cylinder_resize_status"} + if feature_guess_for_capability == "round/fillet candidate": + return status_key in {"existing_fillet_status", "fillet_status"} + return status_key in {"resize_status", "cylinder_resize_status"} + + relevant_capability_specs = tuple( + spec for spec in capability_specs if capability_is_relevant(spec[0]) + ) available_capabilities = { status_key - for status_key, _risk_key, _blocker_key, _label in capability_specs + for status_key, _risk_key, _blocker_key, _label in relevant_capability_specs if str(info.get(status_key) or "").strip() in {"ready", "caution", "candidate"} } feature_type_text = str(info.get("feature_type") or "") @@ -678,7 +925,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd risk = value if surface in FREEFORM_FACE_SURFACES: risk = "blocked" - for status_key, risk_key, _blocker_key, _label in capability_specs: + for status_key, risk_key, _blocker_key, _label in relevant_capability_specs: value = str(info.get(risk_key) or "").strip() if value == "blocked" and has_available_capability: continue @@ -743,6 +990,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd add("slot_geometry", "部分圆柱槽/半孔几何") if info.get("shell_region_status") == "candidate": add("shell_opposite_face", "找到相对平面/壳体候选") + add("user_operation_priority", f"常用操作优先级={user_priority_label}") ready_actions: list[str] = [] limited_actions: list[str] = [] @@ -757,7 +1005,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd if text and text not in items: items.append(text) - for status_key, _risk_key, blocker_key, label in capability_specs: + for status_key, _risk_key, blocker_key, label in relevant_capability_specs: status = str(info.get(status_key) or "").strip() blocker_text = str(info.get(blocker_key) or "").strip() if status in {"ready", "caution", "candidate"}: @@ -768,7 +1016,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd if has_planar_push_pull_candidate: add_action(ready_actions, "平面拉伸/切除") if has_local_face_deform: - add_action(ready_actions, "当前面面积/U向尺寸/V向尺寸/中心/偏移") + add_action(ready_actions, "当前面面内长度/面内宽度/中心/偏移") elif str(info.get("local_face_deform_blocker") or "").strip(): add_action(limited_actions, "局部重建尺寸/中心/偏移") if has_shell_candidate: @@ -790,7 +1038,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd add_unique(limitations, text) else: add_unique(blockers, text) - for status_key, _risk_key, blocker_key, _label in capability_specs: + for status_key, _risk_key, blocker_key, _label in relevant_capability_specs: text = str(info.get(blocker_key) or "").strip() if not text: continue @@ -830,7 +1078,14 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd else: decision = "不建议自动修改" - summary_parts = [candidate, f"置信度={confidence}", f"风险={risk}", f"评分={score}", f"结论={decision}"] + summary_parts = [ + candidate, + f"优先级={user_priority_label}", + f"置信度={confidence}", + f"风险={risk}", + f"评分={score}", + f"结论={decision}", + ] if evidence: summary_evidence = list(evidence[:5]) if "first_level_fact_graph" in evidence_keys: @@ -856,6 +1111,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "recognition_risk": risk, "recognition_score": score, "recognition_decision": decision, + "recognition_user_priority": user_priority, + "recognition_user_priority_label": user_priority_label, + "recognition_user_priority_reason": user_priority_reason, "recognition_evidence": ";".join(evidence), "recognition_evidence_keys": tuple(evidence_keys), "recognition_ready_actions": ";".join(ready_actions), @@ -1077,15 +1335,17 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd association_label = "" if lightweight and surface == "plane": association_label = f"相邻平面 Face {candidate_id}" + priority = feature_recognition_priority(related) related.update( { "association_source_face_id": candidate_id, "association_hop_count": hop_count, "association_relation": "shared-edge-topology", "association_label": association_label, - "association_priority": ( - 0 if surface == "cylinder" else (1 if surface in {"cone", "sphere", "torus"} else 2) - ), + "association_priority": priority, + "recognition_user_priority": priority, + "recognition_user_priority_label": feature_recognition_priority_label(related), + "recognition_user_priority_reason": feature_recognition_priority_reason(related), } ) results.append(related) @@ -1722,6 +1982,35 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd **fillet_info, } ) + scoped_readiness_info = dict(result) + scoped_readiness_info["angular_span"] = combined_angular_span + scoped_readiness_info["height_estimate"] = axis_range["span"] + scoped_readiness_info["feature_guess"] = guess + if guess == "hole/groove candidate": + result.update(_cylinder_resize_readiness(scoped_readiness_info)) + result.update(_cylinder_depth_readiness(scoped_readiness_info)) + result.update(_cylinder_suppress_readiness(scoped_readiness_info)) + elif guess == "boss/outer-round candidate": + result.update( + { + "resize_status": "blocked", + "resize_risk": "blocked", + "resize_blockers": "当前对象已识别为凸台/外圆;请使用凸台直径/高度/轴心入口,不按孔径重切。", + "resize_warnings": "", + "resize_note": "当前对象已识别为凸台/外圆;请使用凸台直径/高度/轴心入口,不按孔径重切。", + } + ) + result.update(_cylinder_boss_resize_readiness(scoped_readiness_info)) + elif guess == "round/fillet candidate": + result.update( + { + "resize_status": "blocked", + "resize_risk": "blocked", + "resize_blockers": "当前对象已识别为圆角/倒圆;请使用已有圆角半径入口,不按孔径重切。", + "resize_warnings": "", + "resize_note": "当前对象已识别为圆角/倒圆;请使用已有圆角半径入口,不按孔径重切。", + } + ) return result def _cylindrical_slot_info( @@ -1769,19 +2058,60 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd radius = max(float(info.get("radius", 0.0)), 0.0) angular_span = min(max(float(info.get("angular_span", 0.0)), 0.0), math.tau) - support_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids)) + adjacent_round_face_ids: list[int] = [] + radius_tolerance = max(radius * 0.12, _shape_diagonal(self.faces[face_id]) * 1e-5, 1e-4) + for adjacent_id in adjacent_face_ids: + if adjacent_id == face_id: + continue + try: + adjacent_info = self.face_info(adjacent_id) + except Exception: + continue + adjacent_radius = _float_or_none(adjacent_info.get("radius")) + if ( + adjacent_info.get("surface") == "cylinder" + and adjacent_info.get("feature_guess") == "round/fillet candidate" + and adjacent_radius is not None + and abs(adjacent_radius - radius) <= radius_tolerance + ): + adjacent_round_face_ids.append(adjacent_id) + adjacent_round_face_ids = sorted(set(adjacent_round_face_ids)) + support_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids) - set(adjacent_round_face_ids)) + chain_face_ids = tuple(sorted({face_id, *adjacent_round_face_ids})) + chain_blocker = ( + "当前圆角与其它圆角面直接相连,属于复杂圆角链或角部 blend;" + "当前版本暂未实现稳定圆角链重建。" + if adjacent_round_face_ids + else "" + ) return { "existing_fillet_kind": "cylindrical-round-face", - "existing_fillet_status": "candidate", + "existing_fillet_status": "blocked" if chain_blocker else "candidate", + "existing_fillet_risk": "blocked" if chain_blocker else "medium", + "existing_fillet_blockers": chain_blocker, "existing_fillet_radius_estimate": radius, "existing_fillet_angular_span": angular_span, "existing_fillet_arc_length_estimate": radius * angular_span, "feature_existing_fillet_face_ids": (face_id,), "feature_existing_fillet_support_face_ids": tuple(support_face_ids), + "feature_existing_fillet_chain_face_ids": chain_face_ids, + "feature_existing_fillet_chain_adjacent_face_ids": tuple(adjacent_round_face_ids), + "existing_fillet_chain_face_count": len(chain_face_ids), + "existing_fillet_chain_status": "chain-candidate" if adjacent_round_face_ids else "single-face", + "existing_fillet_chain_note": ( + "Detected directly connected round/fillet Faces with a matching radius; " + "this is treated as a fillet chain or corner blend, not a single isolated fillet." + if adjacent_round_face_ids + else "No directly connected same-radius round/fillet Face was detected." + ), "existing_fillet_note": ( - "这是由局部小半径圆柱面推断出的已有圆角/倒圆候选;" - "当前版本可尝试使用 defeature + 重新倒圆修改半径;" - "复杂 blend 或支撑面不明确时可能失败并回滚。" + chain_blocker + if chain_blocker + else ( + "这是由局部小半径圆柱面推断出的已有圆角/倒圆候选;" + "当前版本可尝试使用 defeature + 重新倒圆修改半径;" + "复杂 blend 或支撑面不明确时可能失败并回滚。" + ) ), } diff --git a/step_editor/operations.py b/step_editor/operations.py index 4328f01..27d048d 100644 --- a/step_editor/operations.py +++ b/step_editor/operations.py @@ -752,13 +752,26 @@ class OperationMixin: if not blockers and "resize_strategy" not in base and curve == "line" and anchor_mode != "center" and strategy_mode in {"auto", "move-edge-end-plane-by-push-pull"}: candidate = self._straight_edge_length_end_face_candidate(info, delta_length, anchor_mode=anchor_mode) if candidate is not None: - push_plan = self.push_pull_plan(int(candidate["end_face_id"]), float(candidate["push_pull_distance"])) + constraint_fields = self._edge_end_face_planar_constraint_fields(info, candidate) + if force_end_face: + base.update(constraint_fields) + if constraint_fields.get("edge_length_planar_constraint_status") == "ready": + push_plan = self.push_pull_plan(int(candidate["end_face_id"]), float(candidate["push_pull_distance"])) + else: + push_plan = { + "status": "blocked", + "message": str( + constraint_fields.get("edge_length_planar_constraint_blockers") + or constraint_fields.get("edge_length_planar_constraint_note") + ), + } if push_plan["status"] != "blocked": risk = _max_risk(risk, str(push_plan["risk"])) push_warnings = str(push_plan.get("warnings", "")) if push_warnings: warnings.append(push_warnings) base.update(candidate) + base.update(constraint_fields) base.update( { "resize_strategy": "move-edge-end-plane-by-push-pull", @@ -1379,6 +1392,126 @@ class OperationMixin: "确认窗口会显示最终采用的实际策略。" ) + def _face_plane_normal_tuple(self, face_id: int) -> tuple[float, float, float] | None: + if face_id < 0 or face_id >= len(self.faces): + return None + surf = BRepAdaptor_Surface(self.faces[face_id]) + if surf.GetType() != GeomAbs_Plane: + return None + return _tuple_normalized(_dir_tuple(surf.Plane().Axis().Direction())) + + def _edge_end_face_planar_constraint_fields( + self, + edge_info: dict[str, object], + candidate: dict[str, object], + ) -> dict[str, object]: + start = _tuple_or_none(edge_info.get("start_point")) + end = _tuple_or_none(edge_info.get("end_point")) + axis = _tuple_normalized(_tuple_sub(end, start)) if start is not None and end is not None else None + blockers: list[str] = [] + warnings: list[str] = [] + side_specs: list[dict[str, object]] = [] + pair_specs: list[dict[str, object]] = [] + relation_tolerance = 0.12 + alignment_min = 0.88 + + if axis is None: + blockers.append("Cannot verify end-face push/pull constraints: the selected Edge direction is unavailable.") + + end_face_id = _int_or_none(candidate.get("end_face_id")) + end_face_normal = self._face_plane_normal_tuple(end_face_id) if end_face_id is not None else None + end_face_axis_alignment = ( + abs(_tuple_dot(end_face_normal, axis)) + if end_face_normal is not None and axis is not None + else 0.0 + ) + if end_face_normal is None: + blockers.append("Cannot verify end-face push/pull constraints: the moving end Face is not planar.") + elif end_face_axis_alignment < alignment_min: + blockers.append( + "Cannot verify end-face push/pull constraints: the moving end Face is not perpendicular to the Edge direction." + ) + + adjacent_face_ids = tuple(_int_values(edge_info.get("adjacent_face_ids"))) + non_planar_face_ids: list[int] = [] + for face_id in adjacent_face_ids: + normal = self._face_plane_normal_tuple(face_id) + if normal is None: + non_planar_face_ids.append(face_id) + continue + edge_axis_dot = abs(_tuple_dot(normal, axis)) if axis is not None else 1.0 + end_face_dot = abs(_tuple_dot(normal, end_face_normal)) if end_face_normal is not None else 1.0 + side_specs.append( + { + "face_id": face_id, + "normal": normal, + "edge_axis_dot": edge_axis_dot, + "end_face_dot": end_face_dot, + } + ) + if edge_axis_dot > relation_tolerance: + blockers.append( + f"Cannot verify end-face push/pull constraints: adjacent Face {face_id} is not parallel to the Edge direction." + ) + if end_face_normal is not None and end_face_dot > relation_tolerance: + blockers.append( + f"Cannot verify end-face push/pull constraints: adjacent Face {face_id} is not perpendicular to the moving end Face." + ) + + if non_planar_face_ids: + blockers.append( + "Cannot verify end-face push/pull constraints: direct adjacent Faces include non-planar Faces " + f"{tuple(non_planar_face_ids)}." + ) + if len(side_specs) < 2: + blockers.append("Cannot verify end-face push/pull constraints: fewer than two planar adjacent side Faces were found.") + elif len(side_specs) > 2: + warnings.append("More than two planar adjacent side Faces were found; the result relation check will be conservative.") + + for left_index, left in enumerate(side_specs): + left_normal = _tuple_or_none(left.get("normal")) + if left_normal is None: + continue + for right in side_specs[left_index + 1 :]: + right_normal = _tuple_or_none(right.get("normal")) + if right_normal is None: + continue + dot = abs(_tuple_dot(left_normal, right_normal)) + if dot >= 1.0 - relation_tolerance: + relation = "parallel" + elif dot <= relation_tolerance: + relation = "perpendicular" + else: + relation = "angled" + blockers.append( + "Cannot verify end-face push/pull constraints: adjacent side Faces are neither parallel nor perpendicular." + ) + pair_specs.append( + { + "face_ids": (int(left["face_id"]), int(right["face_id"])), + "relation": relation, + "normal_dot": dot, + } + ) + + return { + "edge_length_planar_constraint_status": "blocked" if blockers else "ready", + "edge_length_planar_constraint_risk": "blocked" if blockers else ("medium" if warnings else "low"), + "edge_length_planar_constraint_blockers": "; ".join(blockers), + "edge_length_planar_constraint_warnings": "; ".join(warnings), + "edge_length_planar_constraint_side_face_ids": tuple(int(item["face_id"]) for item in side_specs), + "edge_length_planar_constraint_side_face_count": len(side_specs), + "edge_length_planar_constraint_side_face_specs": tuple(side_specs), + "edge_length_planar_constraint_pair_specs": tuple(pair_specs), + "edge_length_planar_constraint_relation_tolerance": relation_tolerance, + "edge_length_planar_constraint_end_face_id": end_face_id if end_face_id is not None else -1, + "edge_length_planar_constraint_end_face_axis_alignment": end_face_axis_alignment, + "edge_length_planar_constraint_note": ( + "End-face push/pull is allowed only when the selected straight Edge has planar direct side Faces " + "and the moving end Face can be verified as perpendicular to them." + ), + } + def _circular_edge_length_cylinder_candidate( self, edge_info: dict[str, object], @@ -2365,6 +2498,8 @@ class OperationMixin: feature_guess = str(info.get("feature_guess", "")) current_radius = float(feature.get("existing_fillet_radius_estimate", info["radius"])) support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ())) + chain_face_ids = tuple(_int_values(feature.get("feature_existing_fillet_chain_face_ids"))) + chain_adjacent_face_ids = tuple(_int_values(feature.get("feature_existing_fillet_chain_adjacent_face_ids"))) topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id) warnings: list[str] = [] blockers: list[str] = [] @@ -2414,6 +2549,11 @@ class OperationMixin: if feature_guess != "round/fillet candidate": blockers.append("当前圆柱面没有被识别为已有圆角/倒圆候选。") + if len(chain_face_ids) > 1 or chain_adjacent_face_ids: + blockers.append( + "当前圆角与其它圆角面直接相连,属于复杂圆角链或角部 blend;当前版本暂未实现稳定圆角链重建," + "已在计划阶段阻止,避免进入高风险 defeature/refillet 计算。" + ) if target_radius <= 0: blockers.append("目标圆角半径必须大于 0。") if current_radius <= 0: @@ -2485,6 +2625,11 @@ class OperationMixin: "axis_point": info.get("axis_point"), "axis": info.get("axis"), "feature_existing_fillet_support_face_ids": support_face_ids, + "feature_existing_fillet_chain_face_ids": chain_face_ids, + "feature_existing_fillet_chain_adjacent_face_ids": chain_adjacent_face_ids, + "existing_fillet_chain_face_count": feature.get("existing_fillet_chain_face_count", len(chain_face_ids)), + "existing_fillet_chain_status": feature.get("existing_fillet_chain_status", ""), + "existing_fillet_chain_note": feature.get("existing_fillet_chain_note", ""), "feature_boundary_edge_ids": feature.get("feature_boundary_edge_ids"), "resize_strategy": "defeature-existing-fillet-face-then-refillet-axis-edge", "edit_strategy_label": "移除旧圆角并重新倒圆", @@ -2952,7 +3097,7 @@ class OperationMixin: except (TypeError, ValueError): distance = 0.0 frame = None - blockers = ["目标偏移变换位置必须是数字。"] + blockers = ["目标偏移位置必须是数字。"] else: frame = self.face_plane_offset_frame(face_id) blockers = [] @@ -2960,7 +3105,7 @@ class OperationMixin: if face_id < 0 or face_id >= len(self.faces): blockers.append(f"Unknown face id {face_id}") if frame is None: - blockers.append("当前 Face 缺少稳定平面方向或基准点,不能执行偏移变换(局部重建)。") + blockers.append("当前 Face 缺少稳定平面方向或基准点,不能执行偏移(局部重建)。") current_center = None target_center = None @@ -3000,8 +3145,8 @@ class OperationMixin: "target_face_center": target_center, "face_center_move_vector": move_vector, "resize_strategy": "local-face-plane-offset-deform", - "edit_strategy_label": "偏移变换(局部重建)", - "edit_semantics": "把目标偏移变换位置换算成沿当前面垂直方向的移动量,移动当前 Face 并重建相邻平面。", + "edit_strategy_label": "偏移(局部重建)", + "edit_semantics": "把目标偏移位置换算成沿当前面垂直方向的移动量,移动当前 Face 并重建相邻平面。", } plan = self.face_center_local_move_plan(face_id, target_center) @@ -3013,9 +3158,9 @@ class OperationMixin: "plane_direction": plane_direction, "plane_offset_distance": distance, "resize_strategy": "local-face-plane-offset-deform", - "edit_strategy_label": "偏移变换(局部重建)", + "edit_strategy_label": "偏移(局部重建)", "edit_semantics": ( - "把目标偏移变换位置换算成沿当前面垂直方向的移动量,移动当前 Face 的顶点并重建相邻平面;" + "把目标偏移位置换算成沿当前面垂直方向的移动量,移动当前 Face 的顶点并重建相邻平面;" "不拉伸/切除加料/切削,也不平移所属对象。" ), } @@ -3028,7 +3173,7 @@ class OperationMixin: except (TypeError, ValueError): distance = 0.0 frame = None - blockers = ["目标偏移变换位置必须是数字。"] + blockers = ["目标偏移位置必须是数字。"] else: frame = self.face_plane_offset_frame(face_id) blockers = [] @@ -3039,7 +3184,7 @@ class OperationMixin: else: info = self.face_info(face_id) if frame is None: - blockers.append("当前 Face 缺少稳定平面方向或基准点,不能按偏移变换平移所属对象。") + blockers.append("当前 Face 缺少稳定平面方向或基准点,不能按偏移平移所属对象。") plane_origin = None plane_direction = None @@ -3082,8 +3227,8 @@ class OperationMixin: "translation_vector": vector, "resize_strategy": "translate-owning-shape-from-plane-offset", "translate_strategy": f"translate-{target_kind}", - "edit_strategy_label": "按偏移变换平移所属对象", - "edit_semantics": "把目标偏移变换位置换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid。", + "edit_strategy_label": "按偏移平移所属对象", + "edit_semantics": "把目标偏移位置换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid。", } plan = self.translate_solid_plan(solid_id, vector) if target_kind == "solid" else self.translate_part_plan(part_id, vector) @@ -3092,7 +3237,7 @@ class OperationMixin: if diagonal > 1e-9: distance_ratio = abs(distance) / diagonal if distance_ratio > 5.0: - blocker = "目标偏移变换位置需要移动的距离超过所属对象尺寸的 5 倍,容易把特征移动到远离模型的位置。" + blocker = "目标偏移位置需要移动的距离超过所属对象尺寸的 5 倍,容易把特征移动到远离模型的位置。" blockers = [part for part in str(plan.get("blockers") or "").split(";") if part] blockers.append(blocker) plan["status"] = "blocked" @@ -3112,8 +3257,8 @@ class OperationMixin: "face_offset_distance_ratio": distance_ratio, "resize_strategy": "translate-owning-shape-from-plane-offset", "translate_strategy": f"translate-{target_kind}", - "edit_strategy_label": "按偏移变换平移所属对象", - "edit_semantics": "把目标偏移变换位置换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid;不拉伸/切除,不切削,也不补料。", + "edit_strategy_label": "按偏移平移所属对象", + "edit_semantics": "把目标偏移位置换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid;不拉伸/切除,不切削,也不补料。", } ) return plan @@ -3545,6 +3690,25 @@ class OperationMixin: risk = _max_risk(risk, "medium") warnings.append("Face 中心移动距离超过所属对象尺寸的 20%,请确认相邻面变化是否符合预期。") + if not blockers: + prismatic_plan = self._rectangular_prismatic_center_move_plan( + face_id=face_id, + target_center=target, + info=info, + first_level_fields=first_level_fields, + current_center=current_center, + move_vector=move_vector, + move_distance=move_distance, + move_ratio=move_ratio, + bbox_diagonal=diagonal, + part=part, + solid=solid, + risk=risk, + warnings=warnings, + ) + if prismatic_plan is not None: + return prismatic_plan + source_points: tuple[tuple[float, float, float], ...] = () face_count = 0 part_solid_count = 0 @@ -3783,7 +3947,7 @@ class OperationMixin: raise ValueError(f"Unknown face id {face_id}") axis_key = "height" if str(axis).strip().lower() in {"height", "h", "v", "y"} else "width" - axis_label = "V向尺寸" if axis_key == "height" else "U向尺寸" + axis_label = "面内宽度" if axis_key == "height" else "面内长度" info = self.face_info(face_id) center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) part_id = int(info.get("part_id", -1)) @@ -3853,6 +4017,32 @@ class OperationMixin: risk = _max_risk(risk, "high") warnings.append("当前 Face 会沿一个方向被拉得很长,可能穿过相邻几何。") + if not blockers: + prismatic_plan = self._rectangular_prismatic_size_resize_plan( + face_id=face_id, + target_size=target_size, + axis_key=axis_key, + axis_label=axis_label, + info=info, + first_level_fields=first_level_fields, + current_width=current_width, + current_height=current_height, + width_dir=width_dir, + height_dir=height_dir, + current_size=current_size, + axis_dir=axis_dir, + scale=scale, + delta_size=delta_size, + delta_ratio=delta_ratio, + bbox_diagonal=diagonal, + part=part, + solid=solid, + risk=risk, + warnings=warnings, + ) + if prismatic_plan is not None: + return prismatic_plan + point_targets: tuple[tuple[tuple[float, float, float], tuple[float, float, float]], ...] = () face_count = 0 part_solid_count = 0 @@ -3952,12 +4142,403 @@ class OperationMixin: ), } + def _rectangular_prismatic_size_resize_plan( + self, + *, + face_id: int, + target_size: float, + axis_key: str, + axis_label: str, + info: dict[str, object], + first_level_fields: dict[str, object], + current_width: float | None, + current_height: float | None, + width_dir: tuple[float, float, float] | None, + height_dir: tuple[float, float, float] | None, + current_size: float | None, + axis_dir: tuple[float, float, float] | None, + scale: float, + delta_size: float, + delta_ratio: float, + bbox_diagonal: float, + part: object, + solid: TopoDS_Shape | None, + risk: str, + warnings: list[str], + ) -> dict[str, object] | None: + try: + feature = self.feature_info(face_id) + except Exception: + return None + semantics = str(feature.get("prismatic_feature_semantics") or "") + if semantics not in {"additive-boss", "subtractive-pocket"}: + return None + if feature.get("prismatic_profile_status") != "candidate": + return None + + blockers: list[str] = [] + local_warnings = list(warnings) + current_length = _float_or_none(feature.get("prismatic_length")) + current_prismatic_width = _float_or_none(feature.get("prismatic_width")) + depth = _float_or_none(feature.get("prismatic_extrusion_estimate")) + length_dir = _tuple_normalized(_tuple_or_none(feature.get("prismatic_length_direction")) or width_dir) + prismatic_width_dir = _tuple_normalized(_tuple_or_none(feature.get("prismatic_width_direction")) or height_dir) + cap_center = _tuple_or_none(feature.get("area_center")) or _tuple_or_none(info.get("area_center")) + reference_face_id = _int_or_none(feature.get("prismatic_reference_face_id")) + connected_side_ids = _int_values(feature.get("prismatic_connected_side_face_ids")) + + if feature.get("prismatic_extrusion_status") != "candidate": + blockers.append("当前矩形特征没有稳定识别到拉伸高度/深度,不能局部改长宽。") + if current_length is None or current_length <= 1e-9 or current_prismatic_width is None or current_prismatic_width <= 1e-9: + blockers.append("当前矩形特征缺少稳定长度或宽度。") + if depth is None or depth <= 1e-9: + blockers.append("当前矩形特征缺少稳定高度/深度。") + if length_dir is None or prismatic_width_dir is None or abs(_tuple_dot(length_dir, prismatic_width_dir)) > 0.01: + blockers.append("当前矩形特征缺少稳定且互相垂直的长宽方向。") + if cap_center is None: + blockers.append("当前矩形特征缺少稳定中心。") + if reference_face_id is None or not (0 <= reference_face_id < len(self.faces)): + blockers.append("当前矩形特征缺少稳定参考面。") + if len(connected_side_ids) != 4: + blockers.append("当前矩形特征需要四个侧壁都能识别后才开放长宽局部重建。") + if part is None: + blockers.append("找不到当前矩形特征所属零件。") + part_solid_count = 0 + else: + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) + if solid is None: + blockers.append("找不到当前矩形特征所属 Solid。") + if part_solid_count != 1: + blockers.append("矩形凸台/口袋长宽局部重建当前只对单 Solid 零件开放。") + + reference_center = None + extrusion_vector = None + extrusion_direction = None + if reference_face_id is not None and 0 <= reference_face_id < len(self.faces) and cap_center is not None: + try: + reference_info = self.feature_info(reference_face_id) + except Exception: + reference_info = {} + reference_center = _tuple_or_none(reference_info.get("area_center")) or _tuple_or_none( + reference_info.get("bbox_center") + ) + if reference_center is None: + blockers.append("当前矩形特征参考面缺少稳定中心。") + else: + extrusion_vector = _tuple_sub(cap_center, reference_center) + extrusion_direction = _tuple_normalized(extrusion_vector) + measured_depth = _vector_length(extrusion_vector) + if measured_depth <= 1e-9: + blockers.append("当前矩形特征参考面与端面距离无效。") + elif depth is not None and abs(measured_depth - depth) > max(depth * 0.08, 1e-4): + blockers.append("当前矩形特征的端面和参考面距离与识别深度不一致,暂不改长宽。") + if axis_dir is None or current_size is None or current_size <= 1e-9: + blockers.append(f"当前矩形特征缺少稳定{axis_label}方向或尺寸。") + + target_length = target_size if axis_key == "width" else current_length + target_width = target_size if axis_key == "height" else current_prismatic_width + tolerance = max(float(bbox_diagonal) * 1e-7, float(depth or 0.0) * 1e-6, 1e-6) + if current_length is not None and current_prismatic_width is not None: + if axis_key == "width" and target_size <= current_prismatic_width + tolerance: + blockers.append("目标长度必须大于当前宽度;当前版本不在一次编辑中交换矩形长宽方向。") + if axis_key == "height" and target_size >= current_length - tolerance: + blockers.append("目标宽度必须小于当前长度;当前版本不在一次编辑中交换矩形长宽方向。") + + if not blockers: + blockers.extend( + self._face_first_level_plan_blockers( + first_level_fields, + operation_label=f"矩形特征{axis_label}(局部重建)", + ) + ) + for candidate_id in [face_id, reference_face_id or -1, *connected_side_ids]: + if candidate_id < 0 or candidate_id >= len(self.faces): + continue + try: + if BRepAdaptor_Surface(self.faces[candidate_id]).GetType() != GeomAbs_Plane: + blockers.append("矩形凸台/口袋长宽局部重建当前只支持全平面侧壁。") + break + except Exception: + blockers.append("矩形凸台/口袋长宽局部重建无法读取部分侧壁。") + break + + feature_label = "矩形凸台" if semantics == "additive-boss" else "矩形口袋" + if not blockers: + local_warnings.append( + f"当前会先重建{feature_label}的矩形包络,再按目标{axis_label}补料或切削;" + "不会缩放整个零件。" + ) + if delta_ratio > 0.6: + risk = _max_risk(risk, "high") + local_warnings.append(f"目标{axis_label}变化超过当前值的 60%,请检查是否碰到附近特征。") + elif delta_ratio > 0.25: + risk = _max_risk(risk, "medium") + local_warnings.append(f"目标{axis_label}变化超过当前值的 25%,建议执行后检查相邻面。") + + if blockers: + status = "blocked" + risk = "blocked" + message = " ".join(blockers) + elif risk != "low": + status = "caution" + message = " ".join(local_warnings) + else: + status = "ready" + message = f"可以局部重建{feature_label}的{axis_label},只影响当前矩形特征和一级相邻面。" + + return { + "status": status, + "risk": risk, + "message": message, + "warnings": ";".join(local_warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": info.get("part_id"), + "solid_id": info.get("solid_id"), + **first_level_fields, + "surface": "plane", + "face_size_axis": axis_key, + "face_size_label": axis_label, + "current_face_width": current_length, + "target_face_width": target_length, + "current_face_height": current_prismatic_width, + "target_face_height": target_width, + "current_face_size": current_size, + "target_face_size": target_size, + "face_size_delta": delta_size, + "face_size_delta_ratio": delta_ratio, + "face_size_scale": scale, + "face_size_center": cap_center, + "face_size_axis_direction": axis_dir, + "face_width_direction": length_dir, + "face_height_direction": prismatic_width_dir, + "bbox_diagonal": bbox_diagonal, + "local_face_deform_target_kind": "part", + "local_face_deform_face_count": 2 + len(connected_side_ids), + "local_face_deform_source_point_targets": (), + "local_face_deform_moved_point_count": 0, + "local_face_deform_distance_hint": abs(delta_size), + "part_solid_count": part_solid_count, + "feature_type": feature.get("feature_type"), + "prismatic_feature_semantics": semantics, + "prismatic_current_length": current_length, + "prismatic_target_length": target_length, + "prismatic_current_width": current_prismatic_width, + "prismatic_target_width": target_width, + "prismatic_depth": depth, + "prismatic_reference_face_id": reference_face_id, + "prismatic_connected_side_face_ids": tuple(connected_side_ids), + "prismatic_cap_center": cap_center, + "prismatic_reference_center": reference_center, + "prismatic_extrusion_vector": extrusion_vector, + "prismatic_extrusion_direction": extrusion_direction, + "prismatic_length_direction": length_dir, + "prismatic_width_direction": prismatic_width_dir, + "prismatic_tool_margin": max(float(bbox_diagonal) * 1e-4, float(depth or 0.0) * 1e-4, 0.02), + "resize_strategy": "rectangular-prismatic-profile-rebuild", + "edit_strategy_label": f"{feature_label}长宽局部重建", + "edit_semantics": ( + f"先{'移除旧矩形凸台包络' if semantics == 'additive-boss' else '补回旧矩形口袋'}," + f"再按目标长度/宽度{'补出新矩形凸台' if semantics == 'additive-boss' else '重切新矩形口袋'};" + "这是局部特征重建,不是缩放整个零件。" + ), + } + + def _rectangular_prismatic_center_move_plan( + self, + *, + face_id: int, + target_center: tuple[float, float, float] | None, + info: dict[str, object], + first_level_fields: dict[str, object], + current_center: tuple[float, float, float] | None, + move_vector: tuple[float, float, float], + move_distance: float, + move_ratio: float, + bbox_diagonal: float, + part: object, + solid: TopoDS_Shape | None, + risk: str, + warnings: list[str], + ) -> dict[str, object] | None: + try: + feature = self.feature_info(face_id) + except Exception: + return None + semantics = str(feature.get("prismatic_feature_semantics") or "") + if semantics not in {"additive-boss", "subtractive-pocket"}: + return None + if feature.get("prismatic_profile_status") != "candidate": + return None + + blockers: list[str] = [] + local_warnings = list(warnings) + current_length = _float_or_none(feature.get("prismatic_length")) + current_width = _float_or_none(feature.get("prismatic_width")) + depth = _float_or_none(feature.get("prismatic_extrusion_estimate")) + length_dir = _tuple_normalized(_tuple_or_none(feature.get("prismatic_length_direction"))) + width_dir = _tuple_normalized(_tuple_or_none(feature.get("prismatic_width_direction"))) + cap_center = _tuple_or_none(feature.get("area_center")) or current_center + reference_face_id = _int_or_none(feature.get("prismatic_reference_face_id")) + connected_side_ids = _int_values(feature.get("prismatic_connected_side_face_ids")) + + if target_center is None: + blockers.append("目标中心必须是有效 X/Y/Z 坐标。") + if feature.get("prismatic_extrusion_status") != "candidate": + blockers.append("当前矩形特征没有稳定识别到高度/深度,不能移动中心。") + if current_length is None or current_length <= 1e-9 or current_width is None or current_width <= 1e-9: + blockers.append("当前矩形特征缺少稳定长度或宽度。") + if depth is None or depth <= 1e-9: + blockers.append("当前矩形特征缺少稳定高度/深度。") + if length_dir is None or width_dir is None or abs(_tuple_dot(length_dir, width_dir)) > 0.01: + blockers.append("当前矩形特征缺少稳定且互相垂直的长宽方向。") + if cap_center is None: + blockers.append("当前矩形特征缺少稳定中心。") + if reference_face_id is None or not (0 <= reference_face_id < len(self.faces)): + blockers.append("当前矩形特征缺少稳定参考面。") + if len(connected_side_ids) != 4: + blockers.append("当前矩形特征需要四个侧壁都能识别后才开放中心移动。") + if part is None: + blockers.append("找不到当前矩形特征所属零件。") + part_solid_count = 0 + else: + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) + if solid is None: + blockers.append("找不到当前矩形特征所属 Solid。") + if part_solid_count != 1: + blockers.append("矩形凸台/口袋中心移动当前只对单 Solid 零件开放。") + + reference_center = None + extrusion_vector = None + extrusion_direction = None + target_cap_center = None + target_reference_center = None + in_plane_move = move_vector + axial_move = 0.0 + if reference_face_id is not None and 0 <= reference_face_id < len(self.faces) and cap_center is not None: + try: + reference_info = self.feature_info(reference_face_id) + except Exception: + reference_info = {} + reference_center = _tuple_or_none(reference_info.get("area_center")) or _tuple_or_none( + reference_info.get("bbox_center") + ) + if reference_center is None: + blockers.append("当前矩形特征参考面缺少稳定中心。") + else: + extrusion_vector = _tuple_sub(cap_center, reference_center) + extrusion_direction = _tuple_normalized(extrusion_vector) + measured_depth = _vector_length(extrusion_vector) + if measured_depth <= 1e-9: + blockers.append("当前矩形特征参考面与端面距离无效。") + elif depth is not None and abs(measured_depth - depth) > max(depth * 0.08, 1e-4): + blockers.append("当前矩形特征端面与参考面距离和识别深度不一致,暂不移动中心。") + + if extrusion_direction is not None and target_center is not None and cap_center is not None: + raw_move = _tuple_sub(target_center, cap_center) + axial_move = _tuple_dot(raw_move, extrusion_direction) + axial_tolerance = max(float(bbox_diagonal) * 1e-6, float(depth or 0.0) * 1e-5, 1e-5) + if abs(axial_move) > axial_tolerance: + blockers.append("矩形凸台/口袋中心移动只支持在基准平面内平移;沿高度/深度方向请修改高度/深度。") + in_plane_move = _tuple_sub(raw_move, _tuple_scale(extrusion_direction, axial_move)) + if _vector_length(in_plane_move) <= max(float(bbox_diagonal) * 1e-7, 1e-7): + blockers.append("目标中心在矩形特征基准平面内几乎没有移动,不需要修改。") + target_cap_center = _tuple_add(cap_center, in_plane_move) + if reference_center is not None: + target_reference_center = _tuple_add(reference_center, in_plane_move) + + if not blockers: + blockers.extend( + self._face_first_level_plan_blockers( + first_level_fields, + operation_label="矩形特征中心移动(局部重建)", + ) + ) + for candidate_id in [face_id, reference_face_id or -1, *connected_side_ids]: + if candidate_id < 0 or candidate_id >= len(self.faces): + continue + try: + if BRepAdaptor_Surface(self.faces[candidate_id]).GetType() != GeomAbs_Plane: + blockers.append("矩形凸台/口袋中心移动当前只支持全平面侧壁。") + break + except Exception: + blockers.append("矩形凸台/口袋中心移动无法读取部分侧壁。") + break + + feature_label = "矩形凸台" if semantics == "additive-boss" else "矩形口袋" + if not blockers: + local_warnings.append( + f"当前会先移除或补回旧{feature_label}矩形包络,再在目标中心重建;不会平移整个零件。" + ) + if move_ratio > 0.35: + risk = _max_risk(risk, "medium") + local_warnings.append("目标中心移动较远,请确认没有撞到附近特征。") + + if blockers: + status = "blocked" + risk = "blocked" + message = " ".join(blockers) + elif risk != "low": + status = "caution" + message = " ".join(local_warnings) + else: + status = "ready" + message = f"可以局部移动{feature_label}中心,只影响当前矩形特征和一级相邻面。" + + return { + "status": status, + "risk": risk, + "message": message, + "warnings": ";".join(local_warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": info.get("part_id"), + "solid_id": info.get("solid_id"), + **first_level_fields, + "surface": "plane", + "current_face_center": cap_center, + "target_face_center": target_cap_center or target_center, + "face_center_move_vector": in_plane_move, + "face_center_move_distance": _vector_length(in_plane_move), + "face_center_move_ratio": move_ratio, + "face_center_axial_move": axial_move, + "bbox_diagonal": bbox_diagonal, + "local_face_deform_target_kind": "part", + "local_face_deform_face_count": 2 + len(connected_side_ids), + "local_face_deform_source_points": (), + "local_face_deform_moved_point_count": 0, + "part_solid_count": part_solid_count, + "feature_type": feature.get("feature_type"), + "prismatic_feature_semantics": semantics, + "prismatic_current_length": current_length, + "prismatic_target_length": current_length, + "prismatic_current_width": current_width, + "prismatic_target_width": current_width, + "prismatic_depth": depth, + "prismatic_reference_face_id": reference_face_id, + "prismatic_connected_side_face_ids": tuple(connected_side_ids), + "prismatic_cap_center": cap_center, + "prismatic_reference_center": reference_center, + "prismatic_target_cap_center": target_cap_center, + "prismatic_target_reference_center": target_reference_center, + "prismatic_extrusion_vector": extrusion_vector, + "prismatic_extrusion_direction": extrusion_direction, + "prismatic_length_direction": length_dir, + "prismatic_width_direction": width_dir, + "prismatic_tool_margin": max(float(bbox_diagonal) * 1e-4, float(depth or 0.0) * 1e-4, 0.02), + "resize_strategy": "rectangular-prismatic-center-rebuild", + "edit_strategy_label": f"{feature_label}中心移动", + "edit_semantics": ( + f"先移除或补回旧{feature_label}矩形包络,再在目标中心重建同长宽、同高度/深度的矩形特征。" + ), + } + def face_size_owning_scale_plan(self, face_id: int, target_size: float, axis: str = "width") -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") axis_key = "height" if str(axis).strip().lower() in {"height", "h", "v", "y"} else "width" - axis_label = "V向尺寸" if axis_key == "height" else "U向尺寸" + axis_label = "面内宽度" if axis_key == "height" else "面内长度" info = self.face_info(face_id) center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) part_id = int(info.get("part_id", -1)) @@ -7030,7 +7611,19 @@ class OperationMixin: plan = self.face_center_local_move_plan(face_id, target_center) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) - preview_shape = self._local_face_deform_shape(plan) + if plan.get("resize_strategy") == "rectangular-prismatic-center-rebuild": + target_plan = dict(plan) + target_reference_center = _tuple_or_none(plan.get("prismatic_target_reference_center")) + if target_reference_center is not None: + target_plan["prismatic_reference_center"] = target_reference_center + preview_shape = self._rectangular_prismatic_tool_shape( + target_plan, + float(plan["prismatic_target_length"]), + float(plan["prismatic_target_width"]), + overshoot=False, + ) + else: + preview_shape = self._local_face_deform_shape(plan) BRepMesh_IncrementalMesh(preview_shape, deflection) return _shape_faces_polydata(preview_shape) @@ -7043,6 +7636,25 @@ class OperationMixin: if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + if plan.get("resize_strategy") == "rectangular-prismatic-center-rebuild": + self._remember_face_target_logical_id(plan, face_id) + _action_result, result_check = self._run_checked_face_edit( + plan, + lambda: self._apply_rectangular_prismatic_size_rebuild(plan), + ) + return ( + "Rectangular prismatic feature center move completed by local envelope rebuild: " + f"face {face_id}, " + f"current_center={plan.get('current_face_center')}, " + f"target_center={plan.get('target_face_center')}, " + f"move={plan.get('face_center_move_vector')}, " + f"length={float(plan['prismatic_current_length']):g}, " + f"width={float(plan['prismatic_current_width']):g}, " + f"depth={float(plan['prismatic_depth']):g}, " + f"feature={plan.get('feature_type')}, " + f"risk={plan['risk']}. {result_check}" + ) + self._remember_face_target_logical_id(plan, face_id) _action_result, result_check = self._run_checked_face_edit( plan, @@ -7106,7 +7718,15 @@ class OperationMixin: plan = self.face_size_local_resize_plan(face_id, target_size, axis) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) - preview_shape = self._local_face_deform_shape(plan) + if plan.get("resize_strategy") == "rectangular-prismatic-profile-rebuild": + preview_shape = self._rectangular_prismatic_tool_shape( + plan, + float(plan["prismatic_target_length"]), + float(plan["prismatic_target_width"]), + overshoot=False, + ) + else: + preview_shape = self._local_face_deform_shape(plan) BRepMesh_IncrementalMesh(preview_shape, deflection) return _shape_faces_polydata(preview_shape) @@ -7115,6 +7735,23 @@ class OperationMixin: if plan["status"] == "blocked": raise ValueError(str(plan["message"])) + if plan.get("resize_strategy") == "rectangular-prismatic-profile-rebuild": + self._remember_face_target_logical_id(plan, face_id) + _action_result, result_check = self._run_checked_face_edit( + plan, + lambda: self._apply_rectangular_prismatic_size_rebuild(plan), + ) + return ( + "Rectangular prismatic feature size resize completed by local envelope rebuild: " + f"face {face_id}, " + f"axis={plan.get('face_size_axis')}, " + f"length={float(plan['prismatic_current_length']):g}->{float(plan['prismatic_target_length']):g}, " + f"width={float(plan['prismatic_current_width']):g}->{float(plan['prismatic_target_width']):g}, " + f"depth={float(plan['prismatic_depth']):g}, " + f"feature={plan.get('feature_type')}, " + f"risk={plan['risk']}. {result_check}" + ) + self._remember_face_target_logical_id(plan, face_id) _action_result, result_check = self._run_checked_face_edit( plan, @@ -7474,11 +8111,15 @@ class OperationMixin: getter = lambda face_id: _float_or_none(self.quick_face_info(face_id).get("area")) elif target_size is not None and target_size > 0: axis_key = "height" if str(plan.get("face_size_axis") or "").lower() == "height" else "width" - info_key = "local_face_height" if axis_key == "height" else "local_face_width" + if plan.get("resize_strategy") == "rectangular-prismatic-profile-rebuild": + info_key = "prismatic_width" if axis_key == "height" else "prismatic_length" + getter = lambda face_id, key=info_key: _float_or_none(self.feature_info(face_id).get(key)) + else: + info_key = "local_face_height" if axis_key == "height" else "local_face_width" + getter = lambda face_id, key=info_key: _float_or_none(self.quick_face_info(face_id).get(key)) metric = info_key target = target_size tolerance = max(abs(target_size) * 0.02, 1e-4) - getter = lambda face_id, key=info_key: _float_or_none(self.quick_face_info(face_id).get(key)) elif target_center is not None: metric = "center" target = target_center @@ -7729,6 +8370,11 @@ class OperationMixin: if check is None: return "Result check unavailable." topology_check = self._edge_first_level_result_summary(plan, int(check["edge_id"]), enforce=False) + relation_check = self._edge_end_face_planar_constraint_result_summary( + plan, + int(check["edge_id"]), + enforce=False, + ) return ( "Result check: " f"match={check['match_method']}, " @@ -7737,7 +8383,7 @@ class OperationMixin: f"target_error={float(check['target_error']):g}, " f"relative_error={float(check['relative_error']):g}, " f"endpoint_error={float(check['endpoint_error']):g}, " - f"scope={check['scope']}. {topology_check}" + f"scope={check['scope']}. {topology_check} {relation_check}".rstrip() ) def _edge_length_result_summary_or_raise(self, plan: dict[str, object]) -> str: @@ -7765,6 +8411,11 @@ class OperationMixin: ) topology_check = self._edge_first_level_result_summary(plan, int(check["edge_id"]), enforce=True) + relation_check = self._edge_end_face_planar_constraint_result_summary( + plan, + int(check["edge_id"]), + enforce=True, + ) return ( "Result check: " f"match={check['match_method']}, " @@ -7773,7 +8424,7 @@ class OperationMixin: f"target_error={float(check['target_error']):g}, " f"relative_error={float(check['relative_error']):g}, " f"endpoint_error={float(check['endpoint_error']):g}, " - f"scope={check['scope']}. {topology_check}" + f"scope={check['scope']}. {topology_check} {relation_check}".rstrip() ) def _edge_first_level_result_summary( @@ -7860,6 +8511,178 @@ class OperationMixin: f"included_edges={actual_included_edges}." ) + def _edge_end_face_planar_constraint_result_summary( + self, + plan: dict[str, object], + result_edge_id: int, + *, + enforce: bool, + ) -> str: + if str(plan.get("resize_strategy") or "") != "move-edge-end-plane-by-push-pull": + return "" + if str(plan.get("edge_length_planar_constraint_status") or "") != "ready": + message = "Planar relation check: unavailable in the original plan." + if enforce: + raise RuntimeError(message) + return message + + tolerance = _float_or_none(plan.get("edge_length_planar_constraint_relation_tolerance")) or 0.12 + expected_side_count = _int_or_none(plan.get("edge_length_planar_constraint_side_face_count")) or 0 + failures: list[str] = [] + + try: + edge_info = self.edge_info(result_edge_id) + except Exception as exc: + if enforce: + raise RuntimeError(f"Planar relation check failed: result Edge {result_edge_id} cannot be inspected.") from exc + return f"Planar relation check: unavailable ({exc})." + + start = _tuple_or_none(edge_info.get("start_point")) + end = _tuple_or_none(edge_info.get("end_point")) + axis = _tuple_normalized(_tuple_sub(end, start)) if start is not None and end is not None else None + if axis is None: + failures.append("result Edge direction is unavailable") + + side_specs: list[dict[str, object]] = [] + non_planar_face_ids: list[int] = [] + max_side_axis_dot = 0.0 + for face_id in _int_values(edge_info.get("adjacent_face_ids")): + normal = self._face_plane_normal_tuple(face_id) + if normal is None: + non_planar_face_ids.append(face_id) + continue + side_axis_dot = abs(_tuple_dot(normal, axis)) if axis is not None else 1.0 + max_side_axis_dot = max(max_side_axis_dot, side_axis_dot) + side_specs.append({"face_id": face_id, "normal": normal, "side_axis_dot": side_axis_dot}) + if side_axis_dot > tolerance: + failures.append(f"adjacent Face {face_id} is not parallel to the result Edge direction") + + if non_planar_face_ids: + failures.append(f"result adjacent Faces include non-planar Faces {tuple(non_planar_face_ids)}") + if expected_side_count > 0 and len(side_specs) < expected_side_count: + failures.append(f"only {len(side_specs)}/{expected_side_count} planar adjacent side Faces were found") + + end_face = self._edge_end_face_planar_constraint_result_end_face(plan, axis) + max_end_side_dot = 0.0 + end_face_id = -1 + end_face_alignment = 0.0 + if end_face is None: + failures.append("moving end Face could not be found after the edit") + else: + end_face_id = int(end_face["face_id"]) + end_face_alignment = float(end_face["axis_alignment"]) + end_normal = _tuple_or_none(end_face.get("normal")) + if end_face_alignment < 1.0 - tolerance: + failures.append(f"moving end Face is not perpendicular to the result Edge direction ({end_face_alignment:g})") + if end_normal is not None: + for spec in side_specs: + side_normal = _tuple_or_none(spec.get("normal")) + if side_normal is None: + continue + end_side_dot = abs(_tuple_dot(end_normal, side_normal)) + max_end_side_dot = max(max_end_side_dot, end_side_dot) + if end_side_dot > tolerance: + failures.append( + f"adjacent Face {spec.get('face_id')} is not perpendicular to moving end Face {end_face_id}" + ) + + expected_relations = { + str(spec.get("relation")) + for spec in tuple(plan.get("edge_length_planar_constraint_pair_specs") or ()) + if isinstance(spec, dict) + } + side_pair_dots: list[float] = [] + for left_index, left in enumerate(side_specs): + left_normal = _tuple_or_none(left.get("normal")) + if left_normal is None: + continue + for right in side_specs[left_index + 1 :]: + right_normal = _tuple_or_none(right.get("normal")) + if right_normal is None: + continue + side_pair_dots.append(abs(_tuple_dot(left_normal, right_normal))) + + max_side_pair_dot = max(side_pair_dots) if side_pair_dots else 0.0 + min_side_pair_dot = min(side_pair_dots) if side_pair_dots else 1.0 + if "perpendicular" in expected_relations and max_side_pair_dot > tolerance: + failures.append(f"adjacent side Faces are no longer perpendicular enough ({max_side_pair_dot:g})") + if "parallel" in expected_relations and 1.0 - min_side_pair_dot > tolerance: + failures.append(f"adjacent side Faces are no longer parallel enough ({min_side_pair_dot:g})") + + if failures and enforce: + raise RuntimeError( + "Edge end-face push/pull did not preserve the verified planar relations; " + "the model has been restored to the previous state. " + + "; ".join(failures) + ) + + status = "ok" if not failures else f"warning({'; '.join(failures)})" + return ( + "Planar relation check: " + f"{status}, side_faces={len(side_specs)}, end_face={end_face_id}, " + f"end_face_alignment={end_face_alignment:g}, " + f"max_side_axis_dot={max_side_axis_dot:g}, " + f"max_end_side_dot={max_end_side_dot:g}, " + f"max_side_pair_dot={max_side_pair_dot:g}." + ) + + def _edge_end_face_planar_constraint_result_end_face( + self, + plan: dict[str, object], + axis: tuple[float, float, float] | None, + ) -> dict[str, object] | None: + if axis is None: + return None + expected = self._edge_length_expected_endpoints(plan) + if expected is None: + return None + endpoint_role = str(plan.get("end_face_endpoint_role") or "") + if endpoint_role == "start": + endpoint = expected[0] + elif endpoint_role == "end": + endpoint = expected[1] + else: + return None + part_id = _int_or_none(plan.get("part_id")) + solid_id = _int_or_none(plan.get("solid_id")) + target_length = _float_or_none(plan.get("target_length")) or 0.0 + tolerance = max(_shape_diagonal(self.shape) * 1e-4, target_length * 1e-3, 1e-4) + candidates: list[tuple[float, dict[str, object]]] = [] + for face_id, face in enumerate(self.faces): + if part_id is not None and part_id >= 0 and int(self.face_part_ids[face_id]) != part_id: + continue + if solid_id is not None and solid_id >= 0 and int(self.face_solid_ids[face_id]) != solid_id: + continue + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Plane: + continue + normal = _tuple_normalized(_dir_tuple(surf.Plane().Axis().Direction())) + if normal is None: + continue + alignment = abs(_tuple_dot(normal, axis)) + if alignment < 0.88: + continue + origin = _point_tuple(surf.Plane().Location()) + distance = abs(_tuple_dot(_tuple_sub(endpoint, origin), normal)) + if distance > tolerance: + continue + score = distance / max(tolerance, 1e-9) + (1.0 - alignment) + candidates.append( + ( + score, + { + "face_id": face_id, + "normal": normal, + "axis_alignment": alignment, + "endpoint_distance": distance, + }, + ) + ) + if not candidates: + return None + candidates.sort(key=lambda item: item[0]) + return candidates[0][1] + def _edge_blend_result_summary_or_raise( self, plan: dict[str, object], @@ -8316,6 +9139,162 @@ class OperationMixin: mapping_tolerance, ) + def _rectangular_prismatic_profile_face( + self, + center: tuple[float, float, float], + length_dir: tuple[float, float, float], + width_dir: tuple[float, float, float], + length: float, + width: float, + extrusion_dir: tuple[float, float, float] | None, + ) -> TopoDS_Shape: + length_dir = _tuple_normalized(length_dir) + width_dir = _tuple_normalized(width_dir) + if length_dir is None or width_dir is None: + raise ValueError("Rectangular prismatic profile is missing stable length/width directions.") + if length <= 1e-9 or width <= 1e-9: + raise ValueError("Rectangular prismatic profile length and width must be greater than zero.") + if abs(_tuple_dot(length_dir, width_dir)) > 1e-4: + raise ValueError("Rectangular prismatic profile length/width directions must be perpendicular.") + + half_length = 0.5 * float(length) + half_width = 0.5 * float(width) + length_half = _tuple_scale(length_dir, half_length) + width_half = _tuple_scale(width_dir, half_width) + points = [ + _tuple_sub(_tuple_sub(center, length_half), width_half), + _tuple_add(_tuple_sub(center, width_half), length_half), + _tuple_add(_tuple_add(center, length_half), width_half), + _tuple_add(_tuple_sub(center, length_half), width_half), + ] + normal = _tuple_normalized(_tuple_cross(length_dir, width_dir)) + if normal is not None and extrusion_dir is not None and _tuple_dot(normal, extrusion_dir) < 0.0: + points.reverse() + return self._make_local_polygon_face(points) + + def _rectangular_prismatic_tool_shape( + self, + plan: dict[str, object], + length: float, + width: float, + *, + overshoot: bool, + ) -> TopoDS_Shape: + reference_center = _tuple_or_none(plan.get("prismatic_reference_center")) + extrusion_vector = _tuple_or_none(plan.get("prismatic_extrusion_vector")) + extrusion_dir = _tuple_normalized(_tuple_or_none(plan.get("prismatic_extrusion_direction")) or extrusion_vector) + length_dir = _tuple_normalized(_tuple_or_none(plan.get("prismatic_length_direction"))) + width_dir = _tuple_normalized(_tuple_or_none(plan.get("prismatic_width_direction"))) + if reference_center is None or extrusion_vector is None or extrusion_dir is None: + raise ValueError("Rectangular prismatic tool is missing reference center or extrusion vector.") + if length_dir is None or width_dir is None: + raise ValueError("Rectangular prismatic tool is missing profile directions.") + + start_center = reference_center + height = _vector_length(extrusion_vector) + if height <= 1e-9: + raise ValueError("Rectangular prismatic tool has an invalid extrusion height.") + if overshoot: + margin = max(_float_or_none(plan.get("prismatic_tool_margin")) or 0.0, height * 1e-5, 1e-5) + start_center = _tuple_sub(reference_center, _tuple_scale(extrusion_dir, margin)) + height += margin + profile_face = self._rectangular_prismatic_profile_face( + start_center, + length_dir, + width_dir, + float(length), + float(width), + extrusion_dir, + ) + height_vec = gp_Vec(extrusion_dir[0] * height, extrusion_dir[1] * height, extrusion_dir[2] * height) + return _finalize_builder_result(BRepPrimAPI_MakePrism(profile_face, height_vec), "rectangular prismatic tool") + + def _rectangular_prismatic_result_candidate_face_ids(self, plan: dict[str, object], part_id: int) -> tuple[int, ...]: + semantics = str(plan.get("prismatic_feature_semantics") or "") + target_length = _float_or_none(plan.get("prismatic_target_length")) + target_width = _float_or_none(plan.get("prismatic_target_width")) + target_depth = _float_or_none(plan.get("prismatic_depth")) + if target_length is None or target_width is None or target_depth is None: + return () + tolerance = max(target_length * 0.03, target_width * 0.03, target_depth * 0.03, 1e-4) + result: list[int] = [] + for face_id in range(len(self.faces)): + if int(self.face_part_ids[face_id]) != int(part_id): + continue + try: + info = self.feature_info(face_id) + except Exception: + continue + if str(info.get("prismatic_feature_semantics") or "") != semantics: + continue + length = _float_or_none(info.get("prismatic_length")) + width = _float_or_none(info.get("prismatic_width")) + depth = _float_or_none(info.get("prismatic_extrusion_estimate")) + if length is None or width is None or depth is None: + continue + if ( + abs(length - target_length) <= tolerance + and abs(width - target_width) <= tolerance + and abs(depth - target_depth) <= tolerance + ): + result.append(face_id) + return tuple(result) + + def _apply_rectangular_prismatic_size_rebuild(self, plan: dict[str, object]) -> None: + part_id = _int_or_none(plan.get("part_id")) + if part_id is None: + raise ValueError("Rectangular prismatic rebuild is missing part id.") + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + + semantics = str(plan.get("prismatic_feature_semantics") or "") + old_length = float(plan["prismatic_current_length"]) + old_width = float(plan["prismatic_current_width"]) + target_length = float(plan["prismatic_target_length"]) + target_width = float(plan["prismatic_target_width"]) + + old_tool = self._rectangular_prismatic_tool_shape(plan, old_length, old_width, overshoot=False) + target_plan = dict(plan) + target_reference_center = _tuple_or_none(plan.get("prismatic_target_reference_center")) + target_cap_center = _tuple_or_none(plan.get("prismatic_target_cap_center")) + if target_reference_center is not None: + target_plan["prismatic_reference_center"] = target_reference_center + if target_cap_center is not None: + target_plan["prismatic_cap_center"] = target_cap_center + target_tool = self._rectangular_prismatic_tool_shape(target_plan, target_length, target_width, overshoot=False) + if semantics == "additive-boss": + removed = _finalize_boolean_result( + BRepAlgoAPI_Cut(part.shape, old_tool), + "rectangular boss remove old envelope", + use_glue=False, + ) + result = _finalize_boolean_result( + BRepAlgoAPI_Fuse(removed, target_tool), + "rectangular boss fuse target envelope", + use_glue=False, + ) + elif semantics == "subtractive-pocket": + filled = _finalize_boolean_result( + BRepAlgoAPI_Fuse(part.shape, old_tool), + "rectangular pocket fill old envelope", + use_glue=False, + ) + target_cutter = self._rectangular_prismatic_tool_shape(target_plan, target_length, target_width, overshoot=True) + result = _finalize_boolean_result( + BRepAlgoAPI_Cut(filled, target_cutter), + "rectangular pocket cut target envelope", + use_glue=False, + ) + else: + raise ValueError(f"Unsupported rectangular prismatic semantics: {semantics}") + + result = _unify_same_domain_shape(result) + result = _ensure_valid_or_repaired_shape(result, "rectangular prismatic local envelope rebuild") + part.shape = result + self.refresh_topology() + plan["result_candidate_face_ids"] = self._rectangular_prismatic_result_candidate_face_ids(plan, part_id) + def _local_face_deform_target_points_for_face( self, face_id: int, diff --git a/step_editor/recognition_priority.py b/step_editor/recognition_priority.py new file mode 100644 index 0000000..85d1d9b --- /dev/null +++ b/step_editor/recognition_priority.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import math +from typing import Mapping + + +USER_OPERATION_PRIORITY: dict[str, int] = { + "push_pull_plane": 10, + "resize_cylinder": 20, + "resize_depth": 24, + "suppress_cylinder": 28, + "resize_slot_width": 30, + "resize_slot_depth": 31, + "resize_slot_arc_length": 34, + "resize_slot_angular_span": 35, + "resize_boss": 40, + "resize_boss_height": 41, + "move_boss_axis": 44, + "inspect_existing_fillet": 50, + "fillet_edge": 52, + "chamfer_edge": 53, + "resize_shell_thickness": 60, + "resize_edge_length": 70, + "resize_ellipse_edge_major_radius": 72, + "resize_ellipse_edge_minor_radius": 73, +} + +USER_PRIORITY_BUCKETS: tuple[tuple[int, str, str], ...] = ( + (10, "Face 面编辑", "最常用:平面拉伸/切除、面尺寸、中心和偏移。"), + (20, "孔", "常用:孔径、孔深、孔轴心和封堵。"), + (30, "槽/半孔", "常用:槽宽、槽深、弧长、弧角和槽轴心。"), + (40, "凸台/外圆", "常用:凸台直径、高度和轴心。"), + (50, "圆角/倒角", "常用但风险更高:已有圆角半径、新增圆角或倒角。"), + (60, "壳体厚度", "专项:相对面可识别时修改局部厚度。"), + (70, "Edge 边编辑", "受限:边长、端点、圆边半径等一级关系编辑。"), + (80, "解析曲面", "较少直接修改:圆锥、球面、环面等解析曲面参数。"), + (90, "只读/诊断", "暂未稳定归类为可修改特征。"), +) + + +def _text(value: object) -> str: + return str(value or "").strip() + + +def _float_or_none(value: object) -> float | None: + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _is_effectively_full_cylinder(info: Mapping[str, object]) -> bool: + if bool(info.get("is_full_cylinder")): + return True + for key in ("same_domain_angular_span", "angular_span"): + angular_span = _float_or_none(info.get(key)) + if angular_span is not None and angular_span >= math.tau * 0.92: + return True + return False + + +def feature_recognition_priority(info: Mapping[str, object]) -> int: + """Rank feature candidates by likely user editing frequency. + + The rank is intentionally product-facing, not a geometry confidence score: + lower numbers should appear earlier in editable-feature lists. + """ + operation_key = _text(info.get("operation_key")) + if operation_key in USER_OPERATION_PRIORITY: + return USER_OPERATION_PRIORITY[operation_key] + + surface = _text(info.get("surface")) + feature_guess = _text(info.get("feature_guess")) + feature_type = _text(info.get("feature_type")) + feature_actions = _text(info.get("feature_edit_actions")) + ready_actions = _text(info.get("recognition_ready_actions")) + + combined_text = ";".join(item for item in (feature_type, feature_actions, ready_actions) if item) + + if surface == "plane": + return 10 + + if surface == "cylinder": + if feature_guess == "hole/groove candidate": + angular_span = _float_or_none(info.get("angular_span")) + if ( + not _is_effectively_full_cylinder(info) + and angular_span is not None + and angular_span < math.tau * 0.92 + ) or _text(info.get("slot_kind")) == "partial-cylindrical-groove" or "槽/半孔候选" in feature_type: + return 30 + return 20 + if feature_guess == "boss/outer-round candidate": + return 40 + if feature_guess == "round/fillet candidate": + return 50 + if "孔" in combined_text: + return 20 + if "槽" in combined_text or "半孔" in combined_text: + return 30 + if "凸台" in combined_text or "外圆" in combined_text: + return 40 + if "圆角" in combined_text or "倒圆" in combined_text: + return 50 + return 90 + + if surface == "edge" or _text(info.get("curve")): + if "圆角" in combined_text: + return 52 + if "倒角" in combined_text: + return 53 + return 70 + + if surface in {"cone", "sphere", "torus"}: + return 80 + + if "壳体" in combined_text or _text(info.get("shell_region_status")) == "candidate": + return 60 + + return 90 + + +def feature_recognition_priority_bucket(priority: int) -> tuple[int, str, str]: + selected = USER_PRIORITY_BUCKETS[-1] + for bucket in USER_PRIORITY_BUCKETS: + if priority >= bucket[0]: + selected = bucket + else: + break + return selected + + +def feature_recognition_priority_label(info: Mapping[str, object]) -> str: + priority = feature_recognition_priority(info) + _start, label, _reason = feature_recognition_priority_bucket(priority) + return f"{priority:02d} · {label}" + + +def feature_recognition_priority_reason(info: Mapping[str, object]) -> str: + priority = feature_recognition_priority(info) + _start, _label, reason = feature_recognition_priority_bucket(priority) + return reason + + +def feature_recognition_sort_key(info: Mapping[str, object]) -> tuple[int, int, int, int]: + status_order = {"ready": 0, "candidate": 0, "caution": 1, "blocked": 2} + risk_order = {"low": 0, "medium": 1, "high": 2, "blocked": 3} + target_id = info.get("target_id", info.get("face_id", info.get("edge_id", -1))) + try: + numeric_target = int(target_id) + except (TypeError, ValueError): + numeric_target = -1 + return ( + feature_recognition_priority(info), + status_order.get(_text(info.get("status")), 9), + risk_order.get(_text(info.get("risk")), 9), + numeric_target, + ) diff --git a/step_editor/ui_helpers.py b/step_editor/ui_helpers.py index 3ab79c8..95d1304 100644 --- a/step_editor/ui_helpers.py +++ b/step_editor/ui_helpers.py @@ -240,6 +240,9 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [ "recognition_risk", "recognition_score", "recognition_decision", + "recognition_user_priority", + "recognition_user_priority_label", + "recognition_user_priority_reason", "recognition_evidence", "recognition_evidence_keys", "recognition_ready_actions", @@ -440,6 +443,9 @@ INFO_LABELS = { "recognition_risk": "识别风险", "recognition_score": "识别评分", "recognition_decision": "识别结论", + "recognition_user_priority": "常用优先级", + "recognition_user_priority_label": "常用优先级分类", + "recognition_user_priority_reason": "优先级说明", "recognition_evidence": "识别依据", "recognition_evidence_keys": "识别依据项", "recognition_ready_actions": "当前可改", @@ -593,24 +599,24 @@ INFO_LABELS = { "center_of_mass": "重心", "surface_center": "表面积中心", "area_center": "面积中心", - "local_face_width": "U向尺寸", - "local_face_height": "V向尺寸", - "local_face_width_direction": "U向方向", - "local_face_height_direction": "V向方向", - "local_face_size_center": "U/V 尺寸中心", - "face_size_axis": "U/V 尺寸方向", - "face_size_label": "U/V 尺寸名称", - "current_face_width": "当前 U向尺寸", - "target_face_width": "目标 U向尺寸", - "current_face_height": "当前 V向尺寸", - "target_face_height": "目标 V向尺寸", - "current_face_size": "当前 U/V 尺寸", - "target_face_size": "目标 U/V 尺寸", - "face_size_delta": "U/V 尺寸变化量", - "face_size_delta_ratio": "U/V 尺寸变化比例", - "face_size_scale": "U/V 尺寸缩放比例", - "face_size_center": "U/V 尺寸缩放中心", - "face_size_axis_direction": "U/V 尺寸缩放方向", + "local_face_width": "面内长度", + "local_face_height": "面内宽度", + "local_face_width_direction": "面内长度方向", + "local_face_height_direction": "面内宽度方向", + "local_face_size_center": "面内尺寸中心", + "face_size_axis": "面内尺寸方向", + "face_size_label": "面内尺寸名称", + "current_face_width": "当前面内长度", + "target_face_width": "目标面内长度", + "current_face_height": "当前面内宽度", + "target_face_height": "目标面内宽度", + "current_face_size": "当前面内尺寸", + "target_face_size": "目标面内尺寸", + "face_size_delta": "面内尺寸变化量", + "face_size_delta_ratio": "面内尺寸变化比例", + "face_size_scale": "面内尺寸缩放比例", + "face_size_center": "面内尺寸缩放中心", + "face_size_axis_direction": "面内尺寸缩放方向", "owning_face_size_rebuild_mode": "所属对象尺寸重建模式", "length_center": "长度中心", "bbox_min": "包围盒最小点", diff --git a/step_editor/window_actions.py b/step_editor/window_actions.py index 0674e49..4822fce 100644 --- a/step_editor/window_actions.py +++ b/step_editor/window_actions.py @@ -77,6 +77,13 @@ def _compact_plan_value(value: object) -> str: return text if len(text) <= 120 else text[:117] + "..." +def _compact_user_message(value: object, limit: int = 360) -> str: + text = " ".join(str(value or "").replace("\r", "\n").split()) + if len(text) <= limit: + return text + return f"{text[: max(0, limit - 1)].rstrip()}..." + + class WindowActionMixin: def export_all(self) -> None: if self.model is None: @@ -356,8 +363,7 @@ class WindowActionMixin: plan = self._push_pull_plan_for_action(face_id, distance) if plan["status"] == "blocked": - QMessageBox.information(self, "不能拉伸/切除平面", str(plan["message"])) - self.statusBar().showMessage("拉伸/切除平面已阻止") + self._show_blocked_plan_message("拉伸/切除平面", plan, "拉伸/切除平面已阻止") return if plan["risk"] != "low": warnings = str(plan.get("warnings", "")) @@ -742,8 +748,7 @@ class WindowActionMixin: if plan is None: plan = self.model.shell_thickness_plan(face_id, target_thickness) if plan["status"] == "blocked": - QMessageBox.information(self, "不能调整壳体厚度", str(plan["message"])) - self.statusBar().showMessage("壳体厚度调整已阻止") + self._show_blocked_plan_message("调整壳体厚度", plan, "壳体厚度调整已阻止") return if plan["risk"] != "low": @@ -1073,7 +1078,7 @@ class WindowActionMixin: delta = target_size - current_size if current_size is not None else None ratio = abs(delta) / current_size if current_size is not None and current_size > 1e-9 else None scale = target_size / current_size if current_size is not None and current_size > 1e-9 else None - axis_label = "V向尺寸" if axis_key == "height" else "U向尺寸" + axis_label = "面内宽度" if axis_key == "height" else "面内长度" return self._quick_blocked_local_face_plan( face_id, blocker, @@ -1153,7 +1158,7 @@ class WindowActionMixin: face_id, blocker, resize_strategy="local-face-plane-offset-deform", - edit_strategy_label="偏移变换(局部重建)", + edit_strategy_label="偏移(局部重建)", edit_semantics="按当前面垂直方向移动 Face 边界顶点并重建一级相邻面;复杂内孔/多边界 Face 当前不放行。", info=info, extra={ @@ -1377,8 +1382,7 @@ class WindowActionMixin: cancelled_status: str, ) -> bool: if plan.get("status") == "blocked": - QMessageBox.information(self, f"不能{title}", str(plan.get("message", ""))) - self.statusBar().showMessage(blocked_status) + self._show_blocked_plan_message(title, plan, blocked_status) return False if ( not self._quick_edit_title_supports_isolation(title) @@ -1428,11 +1432,11 @@ class WindowActionMixin: "壳体厚度(缩放特征)缩放所属对象", "面积(局部重建)", "面面积(缩放特征)", - "U向尺寸(局部重建)", - "V向尺寸(局部重建)", - "U向尺寸(缩放特征)", - "V向尺寸(缩放特征)", - "偏移变换(局部重建)", + "面内长度(局部重建)", + "面内宽度(局部重建)", + "面内长度(缩放特征)", + "面内宽度(缩放特征)", + "偏移(局部重建)", "中心(局部重建)", "圆柱高度调整", "高度(缩放特征)缩放所属对象", @@ -1601,6 +1605,280 @@ class WindowActionMixin: return "" return "\n\n诊断信息:\n" + "\n".join(f"- {line}" for line in lines) + def _edit_blocker_type(self, reason: str) -> tuple[str, str]: + text = str(reason or "") + unsupported_markers = ( + "暂不支持", + "当前版本", + "还没有实现", + "未实现", + "不支持", + "受限能力", + "复杂链式", + "二级", + "三级", + "原 CAD", + "deferred", + "unsupported", + "not implemented", + ) + risk_markers = ( + "风险过高", + "风险太高", + "高风险", + "危险", + "长时间卡死", + "卡死", + "超时", + "timeout", + "too risky", + "high risk", + ) + recognition_markers = ( + "识别不足", + "识别不到", + "没有识别", + "未识别", + "识别不稳", + "不稳定", + "不明确", + "无法确认", + "不能确认", + "缺少稳定", + "置信度", + "not recognized", + "not recognise", + "ambiguous", + "unclear", + "unstable", + ) + illegal_markers = ( + "无效", + "非法", + "必须", + "不能", + "不存在", + "相同", + "超过", + "小于", + "大于", + "切空", + "退化", + "invalid", + "requires", + "must", + "cannot", + "missing", + "empty", + "greater than", + "less than", + "too small", + "too large", + "not recognized", + "not a", + "does not have", + ) + lower = text.lower() + if any(marker.lower() in lower for marker in unsupported_markers): + return "暂未实现", "当前版本暂未实现这类稳定修改。" + if any(marker.lower() in lower for marker in risk_markers): + return "风险过高", "当前修改容易破坏 B-Rep 或让几何计算长时间失败,已在执行前阻止。" + if any(marker.lower() in lower for marker in recognition_markers): + return "识别不足", "程序还没有把当前对象识别成足够稳定的 CAD 特征或一级关系。" + if any(marker.lower() in lower for marker in illegal_markers): + return "当前操作不合法", "当前输入或选中对象不满足这个操作的基本条件。" + return "不能稳定修改", "几何内核没有给出可验证的有效 B-Rep 结果。" + + def _edit_blocker_reason_from_parameters(self, parameters: dict[str, object], prefix: str = "") -> str: + candidate_keys: list[str] = [] + if prefix: + candidate_keys.extend((f"{prefix}_blockers", f"{prefix}_message", f"{prefix}_warnings")) + candidate_keys.extend( + ( + "blockers", + "message", + "resize_blockers", + "resize_message", + "depth_blockers", + "depth_message", + "move_axis_blockers", + "move_axis_message", + "boss_resize_blockers", + "boss_height_blockers", + "existing_fillet_blockers", + "fillet_blockers", + "chamfer_blockers", + "push_pull_message", + ) + ) + for key in candidate_keys: + text = str(parameters.get(key) or "").strip() + if text: + return text + return "" + + def _plan_preflight_blocker(self, plan: dict[str, object]) -> tuple[str, str, str] | None: + status = str(plan.get("status") or "").strip().lower() + risk = str(plan.get("risk") or "").strip().lower() + if status != "blocked" and risk != "blocked": + return None + reason = self._edit_blocker_reason_from_parameters(plan) or "计划阶段已判断该操作不可执行。" + blocker_type, detail = self._edit_blocker_type(reason) + return blocker_type, reason, detail + + def _show_blocked_plan_message( + self, + title: str, + plan: dict[str, object], + blocked_status: str, + ) -> None: + blocker = self._plan_preflight_blocker(plan) + if blocker is None: + blocker = ("不能稳定修改", str(plan.get("message") or "计划阶段已阻止。"), "模型未修改。") + blocker_type, reason, detail = blocker + QMessageBox.information( + self, + "不能修改", + ( + "不能修改。\n\n" + f"类型:{blocker_type}\n" + f"操作:{title}\n\n" + f"原因:{_compact_user_message(reason)}\n\n" + f"{detail}\n\n" + "模型未修改。" + ), + ) + self.statusBar().showMessage(f"{blocked_status}:{blocker_type}") + + def _show_immediate_cannot_edit_message( + self, + title: str, + reason: str, + blocked_status: str, + ) -> None: + blocker_type, detail = self._edit_blocker_type(reason) + QMessageBox.information( + self, + "不能修改", + ( + "不能修改。\n\n" + f"类型:{blocker_type}\n" + f"操作:{title}\n\n" + f"原因:{_compact_user_message(reason)}\n\n" + f"{detail}\n\n" + "模型未修改。" + ), + ) + self.statusBar().showMessage(f"{blocked_status}:{blocker_type}") + + def _edit_preflight_blocker(self, context: dict[str, object]) -> tuple[str, str, str] | None: + parameters = context.get("parameters") + if not isinstance(parameters, dict): + return None + if bool(parameters.get("ui_deferred_model_plan")) or bool(parameters.get("ui_deferred_shell_thickness_plan")): + if bool(parameters.get("ui_deferred_shell_thickness_plan")): + reason = "当前复杂壳体厚度修改还没有快速、稳定的一级关系判断路径。" + else: + reason = "当前复杂 Face 修改还没有快速、稳定的一级关系判断路径。" + detail = ( + "属于当前版本暂未实现的稳定编辑能力。请改用更明确的孔/槽/凸台/Edge 专门入口," + "或先选择更简单的局部面。" + ) + return ("暂未实现", reason, detail) + for key, value in parameters.items(): + if str(key).startswith("ui_quick_blocked") and bool(value): + reason = self._edit_blocker_reason_from_parameters(parameters) or "快速预检已判断该操作不可稳定执行。" + blocker_type, detail = self._edit_blocker_type(reason) + return (blocker_type, reason, detail) + for key, value in parameters.items(): + key_text = str(key) + if not key_text.endswith("_status"): + continue + if key_text in {"quick_plan_status", "topology_relation_status", "slot_status", "quality_status"}: + continue + if str(value or "").strip().lower() != "blocked": + continue + prefix = key_text[: -len("_status")] + reason = self._edit_blocker_reason_from_parameters(parameters, prefix) or "计划阶段已判断该操作不可执行。" + blocker_type, detail = self._edit_blocker_type(reason) + return (blocker_type, reason, detail) + for key, value in parameters.items(): + key_text = str(key) + if not key_text.endswith("_blockers") and key_text != "blockers": + continue + reason = str(value or "").strip() + if not reason: + continue + blocker_type, detail = self._edit_blocker_type(reason) + return (blocker_type, reason, detail) + return None + + def _show_edit_preflight_blocker(self, blocker: tuple[str, str, str], context: dict[str, object]) -> None: + blocker_type, reason, detail = blocker + operation_name = str(context.get("operation_name") or "当前操作") + target = str(context.get("target") or "当前对象") + QMessageBox.information( + self, + "不能修改", + ( + "不能修改。\n\n" + f"类型:{blocker_type}\n" + f"对象:{target}\n" + f"操作:{operation_name}\n\n" + f"原因:{_compact_user_message(reason)}\n\n" + f"{detail}\n\n" + "模型未修改。" + ), + ) + self.statusBar().showMessage(f"不能修改:{blocker_type}") + + def _user_facing_edit_failure_message( + self, + message: str, + context: dict[str, object] | None, + ) -> tuple[str, str]: + raw_message = str(message or "").strip() + reason = raw_message + for marker in ("诊断信息:", "璇婃柇淇℃伅:"): + index = reason.find(marker) + if index >= 0: + reason = reason[:index].strip() + replacements = ( + ("隔离子进程执行失败;主程序没有崩溃,原模型保持不变。", ""), + ("隔离子进程执行超时,已终止危险计算;主程序和原模型保持不变。", ""), + ("隔离子进程", "后台几何计算"), + ("子进程", "后台计算"), + ("isolated worker", "后台几何计算"), + ("isolated", "后台"), + ) + for old, new in replacements: + reason = reason.replace(old, new) + reason = _compact_user_message(reason) + if not reason: + reason = "后台几何计算没有返回可用结果。" + + blocker_type, detail = self._edit_blocker_type(reason) + lower_raw = raw_message.lower() + if "超时" in raw_message or "timeout" in lower_raw: + blocker_type = "暂未实现" + detail = "当前版本还不能稳定处理这类复杂拓扑,已停止继续等待。" + if "returned an invalid b-rep" in lower_raw or "无效 b-rep" in lower_raw: + blocker_type = "几何结果无效" + detail = "几何内核生成的结果没有通过 B-Rep 校验,已回滚。" + + operation_name = "" + target = "" + if isinstance(context, dict): + operation_name = str(context.get("operation_name") or "") + target = str(context.get("target") or "") + lines = ["不能修改。", "", f"类型:{blocker_type}"] + if target: + lines.append(f"对象:{target}") + if operation_name: + lines.append(f"操作:{operation_name}") + lines.extend(["", f"原因:{reason or '几何计算失败。'}", "", detail, "", "模型未修改。"]) + return "不能修改", "\n".join(lines) + def resize_hole(self) -> None: if self.model is None: return @@ -1992,7 +2270,11 @@ class WindowActionMixin: if angular_span is None: angular_span = _float_or_none(info.get("angular_span")) if angular_span is None or angular_span <= 1e-6 or angular_span >= math.tau * 0.92: - QMessageBox.information(self, f"不能整体修改{label}", "当前候选没有稳定的部分圆柱角度,不能换算为目标圆柱直径。") + self._show_immediate_cannot_edit_message( + f"整体修改{label}", + "当前候选没有稳定的部分圆柱角度,不能换算为目标圆柱直径。", + f"整体修改{label}已阻止", + ) return span = min(max(float(angular_span), 1e-6), math.tau - 1e-6) @@ -2000,12 +2282,20 @@ class WindowActionMixin: sagitta_factor = 1.0 - math.cos(min(span, math.pi) * 0.5) if mode == "width": if abs(sin_half_span) <= 1e-6: - QMessageBox.information(self, "不能整体修改槽宽", "当前槽圆弧角度过小,不能可靠换算槽宽。") + self._show_immediate_cannot_edit_message( + "整体修改槽宽", + "当前槽圆弧角度过小,不能可靠换算槽宽。", + "整体修改槽宽已阻止", + ) return target_diameter = target_value / sin_half_span elif mode == "depth": if sagitta_factor <= 1e-6: - QMessageBox.information(self, "不能整体修改槽深", "当前槽圆弧角度过小,不能可靠换算槽深。") + self._show_immediate_cannot_edit_message( + "整体修改槽深", + "当前槽圆弧角度过小,不能可靠换算槽深。", + "整体修改槽深已阻止", + ) return target_diameter = 2.0 * target_value / sagitta_factor else: @@ -2122,11 +2412,19 @@ class WindowActionMixin: if angular_span is None: angular_span = _float_or_none(info.get("angular_span")) if angular_span is None or angular_span <= 1e-6 or angular_span >= math.tau * 0.92: - QMessageBox.information(self, "不能调整槽宽", "当前候选没有稳定的部分圆柱角度,不能换算槽宽。") + self._show_immediate_cannot_edit_message( + "调整槽宽", + "当前候选没有稳定的部分圆柱角度,不能换算槽宽。", + "槽/半孔宽度调整已阻止", + ) return sin_half_span = math.sin(min(max(angular_span, 1e-6), math.tau - 1e-6) / 2.0) if abs(sin_half_span) <= 1e-6: - QMessageBox.information(self, "不能调整槽宽", "当前槽圆弧角度过小,不能可靠换算为圆柱直径。") + self._show_immediate_cannot_edit_message( + "调整槽宽", + "当前槽圆弧角度过小,不能可靠换算为圆柱直径。", + "槽/半孔宽度调整已阻止", + ) return target_diameter = target_width / sin_half_span @@ -2244,12 +2542,20 @@ class WindowActionMixin: if angular_span is None: angular_span = _float_or_none(info.get("angular_span")) if angular_span is None or angular_span <= 1e-6 or angular_span >= math.tau * 0.92: - QMessageBox.information(self, "不能调整槽深", "当前候选没有稳定的部分圆柱角度,不能换算槽深。") + self._show_immediate_cannot_edit_message( + "调整槽深", + "当前候选没有稳定的部分圆柱角度,不能换算槽深。", + "槽/半孔深度调整已阻止", + ) return sagitta_factor = 1.0 - math.cos(min(max(angular_span, 1e-6), math.pi) / 2.0) if sagitta_factor <= 1e-6: - QMessageBox.information(self, "不能调整槽深", "当前槽圆弧角度过小,不能可靠换算为圆柱直径。") + self._show_immediate_cannot_edit_message( + "调整槽深", + "当前槽圆弧角度过小,不能可靠换算为圆柱直径。", + "槽/半孔深度调整已阻止", + ) return target_diameter = 2.0 * target_depth / sagitta_factor @@ -2371,7 +2677,11 @@ class WindowActionMixin: if angular_span is None: angular_span = _float_or_none(info.get("angular_span")) if angular_span is None or angular_span <= 1e-6 or angular_span >= math.tau * 0.92: - QMessageBox.information(self, "不能调整圆弧长度", "当前候选没有稳定的部分圆柱角度,不能换算圆弧长度。") + self._show_immediate_cannot_edit_message( + "调整圆弧长度", + "当前候选没有稳定的部分圆柱角度,不能换算圆弧长度。", + "槽/半孔圆弧长度调整已阻止", + ) return target_diameter = 2.0 * target_arc_length / angular_span @@ -2509,7 +2819,11 @@ class WindowActionMixin: if current_angular_span is None: current_angular_span = _float_or_none(info.get("angular_span")) if current_angular_span is None or current_angular_span <= 1e-6 or current_angular_span >= math.tau * 0.92: - QMessageBox.information(self, "不能调整圆弧角度", "当前候选没有稳定的部分圆柱角度。") + self._show_immediate_cannot_edit_message( + "调整圆弧角度", + "当前候选没有稳定的部分圆柱角度。", + "槽/半孔圆弧角度调整已阻止", + ) return plan = self.model.cylindrical_slot_angular_span_plan(face_id, target_angular_span) @@ -2626,8 +2940,7 @@ class WindowActionMixin: pair_face_id=pair_face_id, ) if plan["status"] == "blocked": - QMessageBox.information(self, "不能调整槽孔总长度", str(plan["message"])) - self.statusBar().showMessage("槽孔总长度调整已阻止") + self._show_blocked_plan_message("调整槽孔总长度", plan, "槽孔总长度调整已阻止") return lines = [ f"Face: {face_id}", @@ -2725,8 +3038,7 @@ class WindowActionMixin: pair_face_id=pair_face_id, ) if plan["status"] == "blocked": - QMessageBox.information(self, "不能调整槽孔中心距", str(plan["message"])) - self.statusBar().showMessage("槽孔中心距调整已阻止") + self._show_blocked_plan_message("调整槽孔中心距", plan, "槽孔中心距调整已阻止") return lines = [ f"Face: {face_id}", @@ -2817,8 +3129,7 @@ class WindowActionMixin: return plan = self.model.cylindrical_boss_resize_plan(self.selected_face_id, diameter) if plan["status"] == "blocked": - QMessageBox.information(self, "不能调整凸台直径", str(plan["message"])) - self.statusBar().showMessage("圆柱凸台直径调整已阻止") + self._show_blocked_plan_message("调整凸台直径", plan, "圆柱凸台直径调整已阻止") return if plan["risk"] != "low": target_to_height_ratio = plan.get("target_to_height_ratio", "") @@ -3013,8 +3324,7 @@ class WindowActionMixin: face_id = self.selected_face_id plan = self.model.cylindrical_boss_height_plan(face_id, target_height) if plan["status"] == "blocked": - QMessageBox.information(self, "不能调整凸台高度", str(plan["message"])) - self.statusBar().showMessage("圆柱凸台高度调整已阻止") + self._show_blocked_plan_message("调整凸台高度", plan, "圆柱凸台高度调整已阻止") return if plan["risk"] != "low": @@ -3280,8 +3590,7 @@ class WindowActionMixin: return plan = self.model.cylindrical_suppress_plan(self.selected_face_id) if plan["status"] == "blocked": - QMessageBox.information(self, "不能封堵圆柱孔", str(plan["message"])) - self.statusBar().showMessage("封堵圆柱孔已阻止") + self._show_blocked_plan_message("封堵圆柱孔", plan, "封堵圆柱孔已阻止") return if plan["risk"] != "low": @@ -3717,8 +4026,7 @@ class WindowActionMixin: plan = self.model.existing_fillet_resize_plan(face_id, target_radius) isolation = self._isolation_for_plan(plan, "resize_existing_fillet", [face_id, target_radius]) if plan["status"] == "blocked": - QMessageBox.information(self, "不能修改已有圆角", str(plan["message"])) - self.statusBar().showMessage("已有圆角半径修改已阻止") + self._show_blocked_plan_message("修改已有圆角", plan, "已有圆角半径修改已阻止") return warnings = str(plan.get("warnings", "")) @@ -3811,8 +4119,7 @@ class WindowActionMixin: plan = self.model.edge_fillet_plan(edge_id, radius) isolation = self._isolation_for_plan(plan, "fillet_edge", [edge_id, radius]) if plan["status"] == "blocked": - QMessageBox.information(self, "不能添加圆角", str(plan["message"])) - self.statusBar().showMessage("添加圆角已阻止") + self._show_blocked_plan_message("添加圆角", plan, "添加圆角已阻止") return if plan["risk"] != "low": @@ -3893,8 +4200,7 @@ class WindowActionMixin: plan = self.model.edge_chamfer_plan(edge_id, distance) isolation = self._isolation_for_plan(plan, "chamfer_edge", [edge_id, distance]) if plan["status"] == "blocked": - QMessageBox.information(self, "不能添加倒角", str(plan["message"])) - self.statusBar().showMessage("添加倒角已阻止") + self._show_blocked_plan_message("添加倒角", plan, "添加倒角已阻止") return if plan["risk"] != "low": @@ -4159,8 +4465,7 @@ class WindowActionMixin: [edge_id, target_length, anchor_mode, strategy_mode], ) if plan["status"] == "blocked": - QMessageBox.information(self, "不能修改Edge长度", str(plan["message"])) - self.statusBar().showMessage("Edge长度修改已阻止") + self._show_blocked_plan_message("修改Edge长度", plan, "Edge长度修改已阻止") return if plan["risk"] != "low": @@ -4325,8 +4630,7 @@ class WindowActionMixin: [edge_id, target_length, anchor_mode, strategy_mode], ) if plan["status"] == "blocked": - QMessageBox.information(self, "不能修改Edge长度", str(plan["message"])) - self.statusBar().showMessage("Edge长度修改已阻止") + self._show_blocked_plan_message("修改Edge长度", plan, "Edge长度修改已阻止") return warnings = str(plan.get("warnings", "")) @@ -4498,8 +4802,7 @@ class WindowActionMixin: [edge_id, target_radius, axis_kind], ) if plan["status"] == "blocked": - QMessageBox.information(self, f"不能修改椭圆Edge{axis_label}", str(plan["message"])) - self.statusBar().showMessage(f"椭圆Edge{axis_label}修改已阻止") + self._show_blocked_plan_message(f"修改椭圆Edge{axis_label}", plan, f"椭圆Edge{axis_label}修改已阻止") return warnings = str(plan.get("warnings", "")) @@ -4861,8 +5164,7 @@ class WindowActionMixin: plan = self.model.conical_reference_radius_plan(face_id, target_radius) isolation = self._isolation_for_plan(plan, "resize_cone_reference_radius", [face_id, target_radius]) if plan["status"] == "blocked": - QMessageBox.information(self, "不能修改圆锥面参数", str(plan["message"])) - self.statusBar().showMessage("圆锥面参数修改已阻止") + self._show_blocked_plan_message("修改圆锥面参数", plan, "圆锥面参数修改已阻止") return if plan["risk"] != "low": warnings = str(plan.get("warnings", "")) @@ -4960,8 +5262,7 @@ class WindowActionMixin: plan = self.model.conical_semi_angle_plan(face_id, target_angle_degrees) isolation = self._isolation_for_plan(plan, "resize_cone_semi_angle", [face_id, target_angle_degrees]) if plan["status"] == "blocked": - QMessageBox.information(self, "不能修改圆锥半角", str(plan["message"])) - self.statusBar().showMessage("圆锥半角修改已阻止") + self._show_blocked_plan_message("修改圆锥半角", plan, "圆锥半角修改已阻止") return if plan["risk"] != "low": warnings = str(plan.get("warnings", "")) @@ -5057,8 +5358,7 @@ class WindowActionMixin: plan = self.model.spherical_radius_plan(face_id, target_radius) isolation = self._isolation_for_plan(plan, "resize_sphere_radius", [face_id, target_radius]) if plan["status"] == "blocked": - QMessageBox.information(self, "不能修改球面(缩放特征)", str(plan["message"])) - self.statusBar().showMessage("球面(缩放特征)修改已阻止") + self._show_blocked_plan_message("修改球面(缩放特征)", plan, "球面(缩放特征)修改已阻止") return if plan["risk"] != "low": warnings = str(plan.get("warnings", "")) @@ -5160,8 +5460,7 @@ class WindowActionMixin: plan = self.model.toroidal_radius_plan(face_id, target_radius, mode_key) isolation = self._isolation_for_plan(plan, "resize_torus_radius", [face_id, target_radius, mode_key]) if plan["status"] == "blocked": - QMessageBox.information(self, "不能修改环面(缩放特征)", str(plan["message"])) - self.statusBar().showMessage("环面(缩放特征)修改已阻止") + self._show_blocked_plan_message("修改环面(缩放特征)", plan, "环面(缩放特征)修改已阻止") return if plan["risk"] != "low": warnings = str(plan.get("warnings", "")) @@ -5260,8 +5559,7 @@ class WindowActionMixin: face_id = self.selected_face_id plan = self.model.face_area_scale_plan(face_id, target_area) if plan["status"] == "blocked": - QMessageBox.information(self, "不能修改面面积", str(plan["message"])) - self.statusBar().showMessage("面面积修改已阻止") + self._show_blocked_plan_message("修改面面积", plan, "面面积修改已阻止") return warnings = str(plan.get("warnings", "")) warnings_line = f"警告: {warnings}\n\n" if warnings else "" @@ -5428,7 +5726,7 @@ class WindowActionMixin: if self.model is None: return axis_key = "height" if str(axis).lower() == "height" else "width" - axis_label = "V向尺寸" if axis_key == "height" else "U向尺寸" + axis_label = "面内宽度" if axis_key == "height" else "面内长度" if self._edit_busy(f"请等待当前编辑完成后再修改{axis_label}(局部重建)。"): return if self.selected_face_id is None: @@ -5450,10 +5748,10 @@ class WindowActionMixin: lines = [ f"Face: {face_id}", f"修改对象: {axis_label}(局部重建)", - f"当前U向尺寸: {_format_value(plan.get('current_face_width'))}", - f"目标U向尺寸: {_format_value(plan.get('target_face_width'))}", - f"当前V向尺寸: {_format_value(plan.get('current_face_height'))}", - f"目标V向尺寸: {_format_value(plan.get('target_face_height'))}", + f"当前面内长度: {_format_value(plan.get('current_face_width'))}", + f"目标面内长度: {_format_value(plan.get('target_face_width'))}", + f"当前面内宽度: {_format_value(plan.get('current_face_height'))}", + f"目标面内宽度: {_format_value(plan.get('target_face_height'))}", f"变化量: {_format_value(plan.get('face_size_delta'))}", f"变化比例: {_format_percent(plan.get('face_size_delta_ratio'))}", f"缩放比例: {_format_value(plan.get('face_size_scale'))}", @@ -5522,7 +5820,7 @@ class WindowActionMixin: if self.model is None: return axis_key = "height" if str(axis).lower() == "height" else "width" - axis_label = "V向尺寸" if axis_key == "height" else "U向尺寸" + axis_label = "面内宽度" if axis_key == "height" else "面内长度" if self._edit_busy(f"请等待当前编辑完成后再修改{axis_label}(缩放特征)。"): return if self.selected_face_id is None: @@ -5542,10 +5840,10 @@ class WindowActionMixin: lines = [ f"Face: {face_id}", f"修改对象: {axis_label}(缩放特征)", - f"当前U向尺寸: {_format_value(plan.get('current_face_width'))}", - f"目标U向尺寸: {_format_value(plan.get('target_face_width'))}", - f"当前V向尺寸: {_format_value(plan.get('current_face_height'))}", - f"目标V向尺寸: {_format_value(plan.get('target_face_height'))}", + f"当前面内长度: {_format_value(plan.get('current_face_width'))}", + f"目标面内长度: {_format_value(plan.get('target_face_width'))}", + f"当前面内宽度: {_format_value(plan.get('current_face_height'))}", + f"目标面内宽度: {_format_value(plan.get('target_face_height'))}", f"变化量: {_format_value(plan.get('face_size_delta'))}", f"变化比例: {_format_percent(plan.get('face_size_delta_ratio'))}", f"缩放比例: {_format_value(plan.get('face_size_scale'))}", @@ -5625,7 +5923,7 @@ class WindowActionMixin: def move_selected_face_plane_position_by_translation(self) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再按偏移变换平移所属对象。"): + if self._edit_busy("请等待当前编辑完成后再按偏移平移所属对象。"): return if self.selected_face_id is None: QMessageBox.information(self, "未选择Face", "请先选择一个平面 Face。") @@ -5633,14 +5931,13 @@ class WindowActionMixin: try: distance = float(self.offset_input.text()) except (AttributeError, ValueError): - QMessageBox.critical(self, "偏移变换无效", "请输入数字形式的目标偏移变换位置。") + QMessageBox.critical(self, "偏移无效", "请输入数字形式的目标偏移位置。") return face_id = self.selected_face_id plan = self.model.face_plane_offset_owning_translation_plan(face_id, distance) if plan["status"] == "blocked": - QMessageBox.information(self, "不能按偏移变换平移", str(plan["message"])) - self.statusBar().showMessage("偏移变换整体平移已阻止") + self._show_blocked_plan_message("按偏移平移", plan, "偏移整体平移已阻止") return current_position = float(plan.get("current_plane_position", 0.0) or 0.0) target_position = float(plan.get("target_plane_position", current_position) or current_position) @@ -5652,7 +5949,7 @@ class WindowActionMixin: target_position, plane_direction, ): - self.statusBar().showMessage("已取消偏移变换整体平移") + self.statusBar().showMessage("已取消偏移整体平移") return self.clear_edit_preview(render=False) @@ -5663,7 +5960,7 @@ class WindowActionMixin: moved_id = plan.get("solid_id") if moved_kind == "solid" else plan.get("part_id") self._run_edit_action( action, - operation_name="偏移变换(移动特征)", + operation_name="偏移(移动特征)", target=f"Face {face_id}", parameters={ "face_id": face_id, @@ -5684,8 +5981,8 @@ class WindowActionMixin: "translate_warnings": plan.get("warnings"), "translate_blockers": plan.get("blockers"), "resize_strategy": "translate-owning-shape-from-plane-offset", - "edit_strategy_label": "按偏移变换平移所属对象", - "edit_semantics": "把目标偏移变换位置换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid;不拉伸/切除,不切削,也不补料。", + "edit_strategy_label": "按偏移平移所属对象", + "edit_semantics": "把目标偏移位置换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid;不拉伸/切除,不切削,也不补料。", }, target_kind="face", target_id=face_id, @@ -5694,7 +5991,7 @@ class WindowActionMixin: def move_selected_face_plane_position_local(self) -> None: if self.model is None: return - if self._edit_busy("请等待当前编辑完成后再执行偏移变换(局部重建)。"): + if self._edit_busy("请等待当前编辑完成后再执行偏移(局部重建)。"): return if self.selected_face_id is None: QMessageBox.information(self, "未选择Face", "请先选择一个平面 Face。") @@ -5702,7 +5999,7 @@ class WindowActionMixin: try: distance = float(self.offset_input.text()) except (AttributeError, ValueError): - QMessageBox.critical(self, "偏移变换无效", "请输入数字形式的目标偏移变换位置。") + QMessageBox.critical(self, "偏移无效", "请输入数字形式的目标偏移位置。") return face_id = self.selected_face_id @@ -5711,8 +6008,8 @@ class WindowActionMixin: plan = self.model.face_plane_offset_local_plan(face_id, distance) lines = [ f"Face: {face_id}", - f"当前偏移变换位置: {_format_value(plan.get('current_plane_position'))}", - f"目标偏移变换位置: {_format_value(plan.get('target_plane_position'))}", + f"当前偏移位置: {_format_value(plan.get('current_plane_position'))}", + f"目标偏移位置: {_format_value(plan.get('target_plane_position'))}", f"平面方向: {_format_value(plan.get('plane_direction'))}", f"移动向量: {_format_value(plan.get('face_center_move_vector'))}", f"移动距离: {_format_value(plan.get('face_center_move_distance'))}", @@ -5722,11 +6019,11 @@ class WindowActionMixin: f"影响目标: {_format_value(plan.get('local_face_deform_target_kind'))}", ] if not self._confirm_quick_edit_plan( - "偏移变换(局部重建)", + "偏移(局部重建)", plan, lines, - "偏移变换(局部重建)已阻止", - "已取消偏移变换(局部重建)", + "偏移(局部重建)已阻止", + "已取消偏移(局部重建)", ): return self.clear_edit_preview(render=False) @@ -5736,7 +6033,7 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="偏移变换(局部重建)", + operation_name="偏移(局部重建)", target=f"Face {face_id}", parameters={ "face_id": face_id, @@ -5758,9 +6055,9 @@ class WindowActionMixin: "local_face_deform_face_count": plan.get("local_face_deform_face_count"), "local_face_deform_moved_point_count": plan.get("local_face_deform_moved_point_count"), "resize_strategy": "local-face-plane-offset-deform", - "edit_strategy_label": "偏移变换(局部重建)", + "edit_strategy_label": "偏移(局部重建)", "edit_semantics": ( - "把目标偏移变换位置换算成沿当前面垂直方向的移动量,移动当前 Face 的顶点并重建相邻平面;" + "把目标偏移位置换算成沿当前面垂直方向的移动量,移动当前 Face 的顶点并重建相邻平面;" "不拉伸/切除加料/切削,也不平移所属对象。" ), "resize_status": plan.get("status"), @@ -5813,12 +6110,15 @@ class WindowActionMixin: return self.model.move_face_center_owning(face_id, target_center) else: - QMessageBox.information(self, "不能移动Face中心", "当前 Face 没有关联到稳定的所属对象。") + self._show_immediate_cannot_edit_message( + "移动Face中心", + "当前 Face 没有关联到稳定的所属对象。", + "Face中心移动已阻止", + ) return if plan["status"] == "blocked": - QMessageBox.information(self, "不能移动Face中心", str(plan["message"])) - self.statusBar().showMessage("Face中心移动已阻止") + self._show_blocked_plan_message("移动Face中心", plan, "Face中心移动已阻止") return if not self._confirm_face_center_move_plan(plan, face_id, current_center, target_center): self.statusBar().showMessage("已取消Face中心移动") @@ -5868,7 +6168,11 @@ class WindowActionMixin: face_id = self.selected_face_id current_center = self._selected_face_center_for_translation(face_id) if current_center is None: - QMessageBox.information(self, "不能执行中心(局部重建)", "当前 Face 缺少稳定中心坐标。") + self._show_immediate_cannot_edit_message( + "中心(局部重建)", + "当前 Face 缺少稳定中心坐标。", + "中心(局部重建)已阻止", + ) return target_center = ( current_center[0] + vector[0], @@ -5953,7 +6257,11 @@ class WindowActionMixin: info = self._selected_face_info_snapshot() current_center = self._selected_axis_center_for_translation(info) if current_center is None: - QMessageBox.information(self, "不能按轴心平移", "当前对象缺少稳定轴心坐标。") + self._show_immediate_cannot_edit_message( + "按轴心平移", + "当前对象缺少稳定轴心坐标。", + "按轴心平移所属对象已阻止", + ) return target_center = ( current_center[0] + vector[0], @@ -5977,12 +6285,15 @@ class WindowActionMixin: return self.model.translate_part(moved_id, vector) else: - QMessageBox.information(self, "不能按轴心平移", "当前对象没有关联到稳定的所属特征或 Solid。") + self._show_immediate_cannot_edit_message( + "按轴心平移", + "当前对象没有关联到稳定的所属特征或 Solid。", + "按轴心平移所属对象已阻止", + ) return if plan["status"] == "blocked": - QMessageBox.information(self, "不能按轴心平移", str(plan["message"])) - self.statusBar().showMessage("按轴心平移所属对象已阻止") + self._show_blocked_plan_message("按轴心平移", plan, "按轴心平移所属对象已阻止") return if not self._confirm_axis_center_translation_plan(plan, face_id, current_center, target_center): self.statusBar().showMessage("已取消按轴心平移所属对象") @@ -6184,23 +6495,23 @@ class WindowActionMixin: warnings_line = f"警告: {warnings}\n\n" if warnings else "" result = QMessageBox.question( self, - "确认偏移变换整体平移", + "确认偏移整体平移", ( f"Face: {face_id}\n" f"移动对象: {_format_value(plan.get('target_kind', ''))}\n" f"零件: {_format_value(plan.get('part_id', ''))}\n" f"Solid: {_format_value(plan.get('solid_id', ''))}\n" - f"当前偏移变换位置: {_format_value(current_position)}\n" - f"目标偏移变换位置: {_format_value(target_position)}\n" + f"当前偏移位置: {_format_value(current_position)}\n" + f"目标偏移位置: {_format_value(target_position)}\n" f"平面方向: {_format_value(plane_direction)}\n" f"平移向量: {_format_value(plan['translation_vector'])}\n" f"平移距离: {_format_value(plan['translation_distance'])}\n" - f"执行策略: 按偏移变换平移所属对象\n" + f"执行策略: 按偏移平移所属对象\n" f"建模意图: 沿当前面垂直方向平移所属特征或 Solid;不拉伸/切除,不切削,也不补料。\n" f"风险: {plan['risk']}\n\n" f"{warnings_line}" f"{plan['message']}\n\n" - "如果你想改变厚度或把这个面推出/切入,请在“偏移变换”这一行把建模意图选为“拉伸/切除”。确定继续吗?" + "如果你想改变厚度或把这个面推出/切入,请在“偏移”这一行把建模意图选为“拉伸/切除”。确定继续吗?" ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, @@ -6223,8 +6534,7 @@ class WindowActionMixin: part_id = self.selected_part_id plan = self.model.translate_part_plan(part_id, vector) if plan["status"] == "blocked": - QMessageBox.information(self, "不能平移零件", str(plan["message"])) - self.statusBar().showMessage("平移零件已阻止") + self._show_blocked_plan_message("平移零件", plan, "平移零件已阻止") return if not self._confirm_translation_plan("确认平移零件", plan): self.statusBar().showMessage("已取消平移零件") @@ -6268,8 +6578,7 @@ class WindowActionMixin: solid_id = self.selected_solid_id plan = self.model.translate_solid_plan(solid_id, vector) if plan["status"] == "blocked": - QMessageBox.information(self, "不能平移Solid", str(plan["message"])) - self.statusBar().showMessage("平移Solid已阻止") + self._show_blocked_plan_message("平移Solid", plan, "平移Solid已阻止") return if not self._confirm_translation_plan("确认平移Solid", plan): self.statusBar().showMessage("已取消平移Solid") @@ -6351,8 +6660,7 @@ class WindowActionMixin: part_id = self.selected_part_id plan = self.model.rotate_part_plan(part_id, axis, angle) if plan["status"] == "blocked": - QMessageBox.information(self, "不能旋转零件", str(plan["message"])) - self.statusBar().showMessage("旋转零件已阻止") + self._show_blocked_plan_message("旋转零件", plan, "旋转零件已阻止") return if not self._confirm_rotation_plan("确认旋转零件", plan): self.statusBar().showMessage("已取消旋转零件") @@ -6397,8 +6705,7 @@ class WindowActionMixin: solid_id = self.selected_solid_id plan = self.model.rotate_solid_plan(solid_id, axis, angle) if plan["status"] == "blocked": - QMessageBox.information(self, "不能旋转Solid", str(plan["message"])) - self.statusBar().showMessage("旋转Solid已阻止") + self._show_blocked_plan_message("旋转Solid", plan, "旋转Solid已阻止") return if not self._confirm_rotation_plan("确认旋转Solid", plan): self.statusBar().showMessage("已取消旋转Solid") @@ -6445,8 +6752,7 @@ class WindowActionMixin: part_id = self.selected_part_id plan = self.model.scale_part_plan(part_id, target_diagonal) if plan["status"] == "blocked": - QMessageBox.information(self, "不能缩放零件", str(plan["message"])) - self.statusBar().showMessage("缩放零件已阻止") + self._show_blocked_plan_message("缩放零件", plan, "缩放零件已阻止") return if not self._confirm_scale_plan("确认缩放零件", plan): self.statusBar().showMessage("已取消缩放零件") @@ -6493,8 +6799,7 @@ class WindowActionMixin: solid_id = self.selected_solid_id plan = self.model.scale_solid_plan(solid_id, target_diagonal) if plan["status"] == "blocked": - QMessageBox.information(self, "不能缩放Solid", str(plan["message"])) - self.statusBar().showMessage("缩放Solid已阻止") + self._show_blocked_plan_message("缩放Solid", plan, "缩放Solid已阻止") return if not self._confirm_scale_plan("确认缩放Solid", plan): self.statusBar().showMessage("已取消缩放Solid") @@ -6594,8 +6899,7 @@ class WindowActionMixin: run_target_kind = "solid" if plan["status"] == "blocked": - QMessageBox.information(self, "不能按轴缩放", str(plan["message"])) - self.statusBar().showMessage("按轴缩放已阻止") + self._show_blocked_plan_message("按轴缩放", plan, "按轴缩放已阻止") return if not self._confirm_axis_scale_plan(title, plan): self.statusBar().showMessage("已取消按轴缩放") @@ -7075,6 +7379,10 @@ class WindowActionMixin: "edit_result_deflection": result_deflection, "isolation": dict(isolation or {}), } + blocker = self._edit_preflight_blocker(context) + if blocker is not None: + self._show_edit_preflight_blocker(blocker, context) + return self._begin_edit_task(operation_name) self.pending_edit_context = context @@ -7563,7 +7871,7 @@ class WindowActionMixin: target_width = _float_or_none(parameters.get("target_face_width")) target_height = _float_or_none(parameters.get("target_face_height")) if target_width is not None or target_height is not None: - checks.append(("U向尺寸/V向尺寸", self._face_target_size_matches(model, face_ids, target_width, target_height))) + checks.append(("面内长度/面内宽度", self._face_target_size_matches(model, face_ids, target_width, target_height))) target_center = _triple_or_none(parameters.get("target_face_center")) if target_center is not None: @@ -7577,7 +7885,7 @@ class WindowActionMixin: if target_position is not None and plane_direction is not None: checks.append( ( - "偏移变换", + "偏移", self._face_target_plane_position_matches( model, face_ids, @@ -8005,9 +8313,9 @@ class WindowActionMixin: "面", "面积", "中心", - "U向尺寸", - "V向尺寸", - "偏移变换", + "面内长度", + "面内宽度", + "偏移", "壳体", "拉伸/切除", "圆锥", @@ -8058,13 +8366,13 @@ class WindowActionMixin: progress_text = f"{operation_name} 正在后台计算,预览表示局部边形变、端面拉伸/切除或后备策略后的目标范围。" elif "面积(局部重建)" in operation_name: progress_text = f"{operation_name} 正在后台计算;当前会在面内移动所选 Face 顶点并重建相邻平面。" - elif "U向尺寸(缩放特征)" in operation_name or "V向尺寸(缩放特征)" in operation_name: + elif "面内长度(缩放特征)" in operation_name or "面内宽度(缩放特征)" in operation_name: progress_text = f"{operation_name} 正在后台计算;当前会沿选中 Face 的平面内方向缩放所属特征或 Solid,其它几何会跟随变化。" - elif "U向尺寸(局部重建)" in operation_name or "V向尺寸(局部重建)" in operation_name: + elif "面内长度(局部重建)" in operation_name or "面内宽度(局部重建)" in operation_name: progress_text = f"{operation_name} 正在后台计算;当前会只沿选中 Face 的一个平面内方向缩放顶点并重建相邻平面。" - elif "偏移变换(局部重建)" in operation_name: + elif "偏移(局部重建)" in operation_name: progress_text = f"{operation_name} 正在后台计算;当前会沿当前面垂直方向移动所选 Face 顶点并重建相邻平面。" - elif "偏移变换(移动特征)" in operation_name: + elif "偏移(移动特征)" in operation_name: progress_text = f"{operation_name} 正在后台计算;当前会沿当前面垂直方向平移所属特征或 Solid,不拉伸/切除。" elif "Face中心" in operation_name: progress_text = f"{operation_name} 正在后台计算;当前会平移所属特征或 Solid,不做单面局部扭曲。" @@ -8199,15 +8507,19 @@ class WindowActionMixin: if hasattr(self, "_is_ui_thread") and not self._is_ui_thread(): self._invoke_on_ui_thread(lambda message=message: self._fail_edit_action(message)) return + context = self.pending_edit_context close_after_cancel = bool(getattr(self, "close_after_edit_cancel", False)) if close_after_cancel: self.close_after_edit_cancel = False self._end_edit_task(clear_preview=True) if not close_after_cancel: - QMessageBox.critical(self, "操作失败", message) + title, user_message = self._user_facing_edit_failure_message(message, context) + QMessageBox.information(self, title, user_message) self._clear_editable_candidates() self._clear_cylinder_candidates() - self.statusBar().showMessage("后台编辑已取消,模型已保持在编辑前状态" if close_after_cancel else "操作失败,模型已保持在编辑前状态") + self.statusBar().showMessage( + "后台编辑已取消,模型已保持在编辑前状态" if close_after_cancel else "不能修改,模型未修改" + ) if close_after_cancel: if self.edit_thread is not None and self.edit_thread.isRunning(): self.edit_thread.quit() diff --git a/step_editor/window_core.py b/step_editor/window_core.py index b5dd533..fc35955 100644 --- a/step_editor/window_core.py +++ b/step_editor/window_core.py @@ -135,6 +135,18 @@ class WindowCoreMixin: def _should_suppress_transient_tooltip(self, watched) -> bool: if not isinstance(watched, QWidget): return False + property_table = getattr(self, "property_table", None) + if isinstance(property_table, QWidget): + widget = watched + while widget is not None: + if widget is property_table: + return False + widget = widget.parentWidget() + try: + if watched is property_table.viewport(): + return False + except Exception: + pass suppressed_roots = ( getattr(self, "vtk_widget", None), getattr(self, "object_edit_box", None), @@ -1791,6 +1803,8 @@ class WindowCoreMixin: self.pointer_button_down = True self.left_button_press_position = (int(x), int(y)) self.left_button_dragged = False + self.left_button_press_camera_state = self._camera_state_signature() + self.left_button_press_target = self._selection_target_at_position(x, y) self.pending_hover_position = None self.last_hover_pick_position = None self.hover_timer.stop() @@ -1812,16 +1826,55 @@ class WindowCoreMixin: def _handle_left_button_release(self, x: int, y: int) -> None: self._update_left_button_drag_state(x, y) was_dragged = bool(getattr(self, "left_button_dragged", False)) + camera_changed = self._left_button_camera_changed() self.pointer_button_down = False + press_target = getattr(self, "left_button_press_target", None) self.left_button_press_position = None self.left_button_dragged = False + self.left_button_press_camera_state = None + self.left_button_press_target = None self.pending_hover_position = None self.last_hover_pick_position = None if getattr(self, "camera_interaction_active", False): self._end_camera_interaction() - if was_dragged: + if was_dragged or camera_changed or self._selection_target_signature(press_target) is None: return - self._handle_left_click(x, y) + self._handle_left_click(x, y, required_press_target=press_target) + + def _camera_state_signature(self) -> tuple[float, ...] | None: + renderer = getattr(self, "renderer", None) + if renderer is None: + return None + try: + camera = renderer.GetActiveCamera() + except Exception: + return None + if camera is None: + return None + values: list[float] = [] + for getter in ( + "GetPosition", + "GetFocalPoint", + "GetViewUp", + ): + try: + values.extend(float(item) for item in getattr(camera, getter)()) + except Exception: + return None + for getter in ("GetParallelScale", "GetDistance", "GetViewAngle"): + try: + values.append(float(getattr(camera, getter)())) + except Exception: + pass + return tuple(values) + + def _left_button_camera_changed(self) -> bool: + before = getattr(self, "left_button_press_camera_state", None) + after = self._camera_state_signature() + if before is None or after is None or len(before) != len(after): + return False + tolerance = float(getattr(self, "left_click_camera_tolerance", 1e-7) or 1e-7) + return any(abs(float(a) - float(b)) > tolerance for a, b in zip(before, after)) def _update_left_button_drag_state(self, x: int, y: int) -> None: press_position = getattr(self, "left_button_press_position", None) @@ -1833,7 +1886,33 @@ class WindowCoreMixin: if max(dx, dy) > threshold: self.left_button_dragged = True - def _handle_left_click(self, x: int, y: int) -> None: + def _selection_target_signature(self, target: dict[str, object] | None) -> tuple[str, int] | None: + if not isinstance(target, dict): + return None + try: + return str(target["kind"]), int(target["target_id"]) + except (KeyError, TypeError, ValueError): + return None + + def _selection_target_at_position(self, x: int, y: int) -> dict[str, object] | None: + if ( + getattr(self, "load_in_progress", False) + or getattr(self, "operation_in_progress", False) + or getattr(self, "scan_in_progress", False) + or getattr(self, "model", None) is None + ): + return None + try: + return self._pick_selection_target(self._current_selection_mode(), int(x), int(y)) + except Exception: + return None + + def _handle_left_click( + self, + x: int, + y: int, + required_press_target: dict[str, object] | None = None, + ) -> None: self.pending_hover_position = None self.last_hover_pick_position = None self.hover_timer.stop() @@ -1850,6 +1929,16 @@ class WindowCoreMixin: return mode = self._current_selection_mode() target = self._pick_selection_target(mode, x, y) + if required_press_target is not None: + press_signature = self._selection_target_signature(required_press_target) + release_signature = self._selection_target_signature(target) + if press_signature is None or release_signature != press_signature: + self._cancel_hover_tracking(render=False) + if hasattr(self, "render_window"): + self.render_window.Render() + if self.selected_kind is not None: + self.statusBar().showMessage("左键按下和松开未命中同一对象,已保持当前选择") + return if target is None: self._cancel_hover_tracking(render=False) if hasattr(self, "render_window"): @@ -1871,6 +1960,7 @@ class WindowCoreMixin: def _handle_pointer_button_press(self) -> None: self.pointer_button_down = True + self.left_button_press_target = None self.pending_hover_position = None self.last_hover_pick_position = None self.hover_timer.stop() @@ -1884,6 +1974,7 @@ class WindowCoreMixin: def _handle_pointer_button_release(self) -> None: self.pointer_button_down = False + self.left_button_press_target = None self.pending_hover_position = None self.last_hover_pick_position = None self._end_camera_interaction() @@ -2279,6 +2370,14 @@ class WindowCoreMixin: self._set_selection_mode("Edge") self.select_edge(int(target_id)) self.statusBar().showMessage(f"已选择可尝试调整长度的Edge {target_id}") + elif action == "resize_ellipse_edge_major_radius": + self._set_selection_mode("Edge") + self.select_edge(int(target_id)) + self.statusBar().showMessage(f"已选择可调整椭圆主半径的Edge {target_id}") + elif action == "resize_ellipse_edge_minor_radius": + self._set_selection_mode("Edge") + self.select_edge(int(target_id)) + self.statusBar().showMessage(f"已选择可调整椭圆小半径的Edge {target_id}") elif target_kind == "edge": self._set_selection_mode("Edge") self.select_edge(int(target_id)) @@ -2439,6 +2538,8 @@ class WindowCoreMixin: self._sync_id_picker("Feature", logical_id) self.set_info(self._with_pick_info(info, pick_position)) feature_type = str(info.get("feature_type", "特征候选")) + if hasattr(self, "_feature_display_label"): + feature_type = self._feature_display_label(feature_type) self._update_action_states() raw_note = f"(当前拓扑 Face {face_id})" if logical_id != face_id else "" self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 Face {logical_id}{raw_note}", pick_position)) diff --git a/step_editor/window_state.py b/step_editor/window_state.py index 495a25c..f0764cf 100644 --- a/step_editor/window_state.py +++ b/step_editor/window_state.py @@ -36,9 +36,8 @@ PROPERTY_LABEL_COLUMN = 0 PROPERTY_CURRENT_COLUMN = 1 PROPERTY_SCOPE_COLUMN = 2 PROPERTY_TARGET_COLUMN = 3 -PROPERTY_ACTION_COLUMN = 4 -PROPERTY_TABLE_MIN_COLUMN_WIDTHS = (54, 118, 48, 66, 74) -PROPERTY_TABLE_PREFERRED_COLUMN_WIDTHS = (68, 168, 58, 92, 82) +PROPERTY_TABLE_MIN_COLUMN_WIDTHS = (72, 118, 72, 82) +PROPERTY_TABLE_PREFERRED_COLUMN_WIDTHS = (118, 168, 108, 104) PROPERTY_COMMAND_ORDER = ("offset", "move", "scale", "rotate", "feature", "diagnostics") PROPERTY_COMMAND_LABELS = { "offset": "偏移", @@ -59,7 +58,7 @@ PROPERTY_COMMAND_SUBTITLES = { PROPERTY_COMMAND_HELP = { "offset": "沿 Face 法线调整位置,适合平面推拉、偏移或切除深度类修改。", "move": "移动当前 Face、Edge、特征或 Solid 的中心/轴心,不直接修改尺寸。", - "scale": "修改面积、宽度、高度、半径、直径、长度等尺寸参数。", + "scale": "修改长度、宽度、高度、半径、直径等尺寸参数。", "rotate": "设置旋转轴或旋转角度,适合 Part/Solid 的整体姿态调整。", "feature": "执行孔、槽、圆角、倒角、封堵等离散特征命令。", "diagnostics": "查看识别依据、一级拓扑关系和当前不支持修改的原因。", @@ -80,9 +79,27 @@ FEATURE_EDIT_SEMANTICS_KEYS = { "face_edit_semantics", } +PROPERTY_EXPLANATION_TOOLTIPS = { + "cad_modeling_form": ( + "建模形式:\n" + "程序对你选中对象的 CAD 语义判断。比如你点了一个平面 Face,它可能被理解成“可拉伸/切除的平面”" + "“偏移面”“壳体区域”等。它是在说“这东西像什么建模对象”。" + ), + "cad_recommended_operation": ( + "推荐操作:\n" + "程序根据当前识别结果,给出的相对安全的改法建议。比如平面 Face 通常建议优先用 偏移 + 拉伸/切除," + "不要一上来做复杂局部重建。它是在说“建议你怎么改”。" + ), + "feature_context_note": ( + "关联探测:\n" + "程序有没有去找当前特征周围的相关特征。比如你点了一个面,它会尝试看共享边附近有没有孔、槽、凸台、" + "相邻 Face 等。它是在说“我额外看了周围没有,找到什么没有”。" + ), +} -def _property_table_column_widths(available_width: int) -> tuple[int, int, int, int, int]: - """Prefer current value and action visibility over a wide parameter-name column.""" + +def _property_table_column_widths(available_width: int) -> tuple[int, int, int, int]: + """Prefer current value visibility while keeping modeling intent and target usable.""" column_count = len(PROPERTY_TABLE_MIN_COLUMN_WIDTHS) available = max(int(available_width or 0), column_count * 44) minimum = PROPERTY_TABLE_MIN_COLUMN_WIDTHS @@ -93,7 +110,7 @@ def _property_table_column_widths(available_width: int) -> tuple[int, int, int, if available >= preferred_total: widths = [int(value) for value in preferred] extra = available - preferred_total - weights = (0.05, 0.62, 0.04, 0.19, 0.10) + weights = (0.26, 0.38, 0.18, 0.18) for index, weight in enumerate(weights): addition = int(extra * weight) widths[index] += addition @@ -109,10 +126,10 @@ def _property_table_column_widths(available_width: int) -> tuple[int, int, int, widths[1] += available - sum(widths) return tuple(widths) # type: ignore[return-value] - floors = (48, 96, 44, 56, 70) + floors = (48, 96, 44, 56) widths = [int(value) for value in minimum] deficit = min_total - available - for index in (0, 2, 3, 1, 4): + for index in (0, 2, 3, 1): if deficit <= 0: break reducible = max(0, widths[index] - floors[index]) @@ -143,10 +160,11 @@ def _feature_dimension_keys(action_info: dict[str, object]) -> tuple[str, ...]: keys = ["local_face_width", "local_face_height"] if action_info.get("prismatic_extrusion_status") == "candidate": keys.append("shell_thickness_estimate") + if str(action_info.get("prismatic_feature_semantics") or "") in {"additive-boss", "subtractive-pocket"}: + keys.append("face_center_position") return tuple(keys) if action_info.get("shell_region_status") == "candidate": return ( - "area", "local_face_width", "local_face_height", "face_center_position", @@ -154,7 +172,6 @@ def _feature_dimension_keys(action_info: dict[str, object]) -> tuple[str, ...]: "shell_thickness_estimate", ) return ( - "area", "local_face_width", "local_face_height", "face_center_position", @@ -178,7 +195,7 @@ def _feature_dimension_keys(action_info: dict[str, object]) -> tuple[str, ...]: if feature_guess == "boss/outer-round candidate": return ("boss_diameter", "boss_height") if feature_guess == "round/fillet candidate": - return ("existing_fillet_radius_estimate",) + return ("existing_fillet_radius_estimate", "existing_fillet_arc_length_estimate") return ("generic_cylinder_diameter", "cylinder_height") if surface == "cone": @@ -986,9 +1003,9 @@ class WindowStateMixin: if hasattr(self, "property_card_scroll"): self._set_control_state( self.property_card_scroll, - has_selection and has_current_info, - "查看当前选中对象的参数卡片;可修改的卡片可以输入目标值。", - "请先选择一个对象。", + False, + "旧展示层已停用;当前使用表格展示特征参数。", + "当前使用表格展示特征参数。", ) self._update_edit_action_states(has_model) @@ -1023,6 +1040,9 @@ class WindowStateMixin: ) is_blind = action_info.get("cylinder_end_type") == "blind" has_bottom = bool(_int_values(action_info.get("feature_bottom_face_ids"))) + has_depth_estimate = _float_or_none(action_info.get("hole_depth_estimate")) is not None + depth_status = str(action_info.get("depth_status") or "") + can_try_auto_blind_depth = bool(is_blind and has_depth_estimate and depth_status != "blocked") has_manual_bottom = bool( hasattr(self, "hole_bottom_face_input") and self.hole_bottom_face_input.text().strip() @@ -1035,7 +1055,9 @@ class WindowStateMixin: 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_resize_depth = has_model and is_hole_or_groove and ( + can_try_auto_blind_depth or (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 @@ -1392,7 +1414,7 @@ class WindowStateMixin: info = dict(getattr(self, "current_info_values", {}) or {}) explicit_type = str(info.get("feature_type", "") or "").strip() if explicit_type: - return explicit_type + return self._feature_display_label(explicit_type) surface = str(info.get("surface", "") or "") feature_guess = str(info.get("feature_guess", "") or "") if surface == "cylinder" and feature_guess == "hole/groove candidate": @@ -1401,16 +1423,24 @@ class WindowStateMixin: info.get("slot_kind") == "partial-cylindrical-groove" or (angular_span is not None and angular_span < math.tau * 0.92) ): - return "槽/半孔候选" - return "圆柱孔/槽候选" + return self._feature_display_label("槽/半孔候选") + return self._feature_display_label("圆柱孔/槽候选") if surface == "cylinder" and feature_guess == "boss/outer-round candidate": - return "凸台/外圆候选" + return self._feature_display_label("凸台/外圆候选") if surface == "cylinder" and feature_guess == "round/fillet candidate": - return "圆角/倒圆候选" + return self._feature_display_label("圆角/倒圆候选") if surface == "plane" and info.get("push_pull_status"): - return "可拉伸/切除平面候选" + return self._feature_display_label("可拉伸/切除平面候选") return "" + def _feature_display_label(self, label: object) -> str: + text = str(label or "").strip() + if text == "矩形口袋候选": + return "矩形槽/口袋" + if len(text) > 2 and text.endswith("候选"): + return text[:-2].rstrip(" /·,,") + return text + def _clear_property_editor(self) -> None: if not hasattr(self, "property_table"): return @@ -1426,6 +1456,7 @@ class WindowStateMixin: self.property_table.blockSignals(was_blocked) self._clear_property_cards() self._clear_property_command_bar() + self._update_current_capability_panel() self._resize_property_table_height() self._update_property_apply_state(False) @@ -1497,6 +1528,26 @@ class WindowStateMixin: tooltip_parts = [str(spec.get("current_text", "") or "") for spec in specs if bool(spec.get("pin_top"))] return summary, "\n\n".join(part for part in tooltip_parts if part) + def _current_capability_text(self) -> tuple[str, str, str]: + headline = "当前支持:Face、孔、槽、凸台、圆角/倒角、壳体、Edge" + detail = "" + tooltip = ( + "Face:面内长度/宽度、中心、偏移、壳体厚度。\n" + "孔/槽:孔径、轴心、封堵、盲孔/盲槽深度、槽宽/槽深/弧长/总长。\n" + "凸台:圆柱凸台直径/高度/轴心;矩形凸台/矩形槽口袋长宽、中心、高度/深度;多台阶矩形凸台顶层规则台阶。\n" + "圆角/倒角:简单已有圆角半径/弧长,直线 Edge 新增圆角/倒角。\n" + "Edge/解析曲面:直线 Edge 长度/端点/中心、圆/椭圆 Edge、简单圆锥/球/环面。\n" + "受限:复杂链式特征、二级/三级拓扑传播和原 CAD 历史恢复。" + ) + return headline, detail, tooltip + + def _update_current_capability_panel(self) -> None: + if not hasattr(self, "current_capability_headline"): + return + headline, _detail, tooltip = self._current_capability_text() + self.current_capability_headline.setText(headline) + self.current_capability_headline.setToolTip(tooltip or headline) + def _rebuild_property_command_bar(self) -> None: layout = getattr(self, "property_command_layout", None) if layout is None: @@ -1545,7 +1596,7 @@ class WindowStateMixin: selected_items = [(row, spec) for row, spec in enumerate(specs) if row == selected_row] if selected_items: visible = selected_items + [(row, spec) for row, spec in visible if row != selected_row] - collapsed_rows = max(int(getattr(self, "property_table_collapsed_rows", 6) or 6), 6) + collapsed_rows = max(int(getattr(self, "property_table_collapsed_rows", 5) or 5), 5) return visible[:collapsed_rows] def _rebuild_property_cards(self) -> None: @@ -1824,17 +1875,6 @@ class WindowStateMixin: hint.setVisible(bool(hint_text)) row_widgets["hint_label"] = hint - if editable and effective_spec.get("action"): - button = QPushButton("未改动") - button.setObjectName("propertyRowEditButton") - button.setCursor(Qt.CursorShape.PointingHandCursor) - button.setProperty("changed", False) - button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - button.clicked.connect(lambda _checked=False, target_row=row: self.apply_property_row_edit(target_row)) - card_layout.addWidget(button) - status_label.setVisible(False) - row_widgets["action_button"] = button - self.property_card_rows[row] = row_widgets card.style().unpolish(card) card.style().polish(card) @@ -1922,26 +1962,17 @@ class WindowStateMixin: input_editable = editable and value_type != "command" label_item = self._property_table_item(str(effective_spec.get("label", "")), editable=False) current_item = self._property_table_item(str(effective_spec.get("current_text", "")), editable=False) - span_value_columns = bool(effective_spec.get("span_value_columns")) and not editable - scope_item = self._property_table_item( - "" if span_value_columns else str(effective_spec.get("scope_text", "")), - editable=False, - ) + scope_item = self._property_table_item(str(effective_spec.get("scope_text", "")), editable=False) target_item = self._property_table_item( - "" if input_editable or span_value_columns else str(effective_spec.get("target_text", "")), + "" if input_editable else str(effective_spec.get("target_text", "")), editable=False, ) - action_text = "" if editable or span_value_columns else str(effective_spec.get("status_text", "")) - action_item = self._property_table_item(action_text, editable=False) target_item.setToolTip(self._property_target_tooltip(effective_spec, editable=editable)) - row_items = (label_item, current_item, scope_item, target_item, action_item) + row_items = (label_item, current_item, scope_item, target_item) self._style_property_row_items(row_items, editable=editable, spec=effective_spec) for column, item in enumerate(row_items): item.setToolTip(item.toolTip() or item.text()) self.property_table.setItem(row, column, item) - if span_value_columns: - current_item.setToolTip(str(effective_spec.get("disabled_tip") or current_item.text())) - self.property_table.setSpan(row, PROPERTY_CURRENT_COLUMN, 1, 4) self.property_table.setRowHeight(row, 28 if editable else 24) if spec.get("scope_modes"): self._set_property_scope_editor(row, spec) @@ -1952,12 +1983,11 @@ class WindowStateMixin: self._set_property_target_editor(row, effective_spec) else: self.property_table.removeCellWidget(row, PROPERTY_TARGET_COLUMN) - self._set_property_row_button(row, effective_spec) else: self.property_table.removeCellWidget(row, PROPERTY_TARGET_COLUMN) - self.property_table.removeCellWidget(row, PROPERTY_ACTION_COLUMN) - self._rebuild_property_command_bar() - self._rebuild_property_cards() + self._clear_property_command_bar() + self._clear_property_cards() + self._update_current_capability_panel() self._resize_property_table_height() finally: self.property_table.blockSignals(was_blocked) @@ -1967,8 +1997,6 @@ class WindowStateMixin: def _sort_property_specs_for_display(self, specs: list[dict[str, object]]) -> list[dict[str, object]]: def rank(spec: dict[str, object]) -> int: - if bool(spec.get("pin_top")): - return -1 editable = bool(spec.get("editable")) enabled = bool(spec.get("enabled")) has_action = bool(spec.get("action")) @@ -1987,12 +2015,12 @@ class WindowStateMixin: def _style_property_row_items( self, - items: tuple[QTableWidgetItem, QTableWidgetItem, QTableWidgetItem, QTableWidgetItem, QTableWidgetItem], + items: tuple[QTableWidgetItem, QTableWidgetItem, QTableWidgetItem, QTableWidgetItem], *, editable: bool, spec: dict[str, object] | None = None, ) -> None: - label_item, current_item, scope_item, target_item, status_item = items + label_item, current_item, scope_item, target_item = items if spec is not None and bool(spec.get("pin_top")): for item in items: item.setBackground(QColor("#eef6ff")) @@ -2000,7 +2028,6 @@ class WindowStateMixin: current_item.setForeground(QColor("#0f172a")) scope_item.setForeground(QColor("#0369a1")) target_item.setForeground(QColor("#64748b")) - status_item.setForeground(QColor("#0369a1")) label_font = label_item.font() label_font.setBold(True) label_item.setFont(label_font) @@ -2009,28 +2036,23 @@ class WindowStateMixin: current_item.setFont(current_font) return if editable: - row_backgrounds = ("#fff7ed", "#fffbeb", "#fff7ed", "#fff7ed", "#fff7ed") + row_backgrounds = ("#fff7ed", "#fffbeb", "#fff7ed", "#fff7ed") for item, color in zip(items, row_backgrounds): item.setBackground(QColor(color)) label_item.setForeground(QColor("#7c2d12")) current_item.setForeground(QColor("#431407")) scope_item.setForeground(QColor("#9a3412")) target_item.setForeground(QColor("#111827")) - status_item.setForeground(QColor("#9a3412")) label_font = label_item.font() label_font.setBold(True) label_item.setFont(label_font) scope_font = scope_item.font() scope_font.setBold(True) scope_item.setFont(scope_font) - status_font = status_item.font() - status_font.setBold(True) - status_item.setFont(status_font) return scope_item.setForeground(QColor("#64748b")) target_item.setBackground(QColor("#eef2f6")) target_item.setForeground(QColor("#8f99a8")) - status_item.setForeground(QColor("#64748b")) def _property_scope_default(self, spec: dict[str, object]) -> str: modes = spec.get("scope_modes") @@ -2103,7 +2125,7 @@ class WindowStateMixin: editor.setCursor(Qt.CursorShape.IBeamCursor) editor.setFrame(True) editor.textChanged.connect(lambda _text="", _row=row: self._update_property_apply_state()) - editor.returnPressed.connect(lambda target_row=row: self.apply_property_row_edit(target_row)) + editor.returnPressed.connect(self.apply_current_property_edit) self.property_table.setCellWidget(row, PROPERTY_TARGET_COLUMN, editor) def _set_property_scope_editor(self, row: int, spec: dict[str, object]) -> None: @@ -2133,17 +2155,7 @@ class WindowStateMixin: self.property_table.setCellWidget(row, PROPERTY_SCOPE_COLUMN, combo) def _set_property_row_button(self, row: int, spec: dict[str, object]) -> None: - button = QPushButton("未改动") - button.setObjectName("propertyRowEditButton") - tooltip = f"只应用“{spec.get('label', '当前属性')}”这一行的目标值。" - range_hint = self._property_range_hint(spec) - if range_hint: - tooltip = f"{tooltip}\n\n{range_hint}" - button.setToolTip(tooltip) - button.setCursor(Qt.CursorShape.PointingHandCursor) - button.setProperty("changed", False) - button.clicked.connect(lambda _checked=False, target_row=row: self.apply_property_row_edit(target_row)) - self.property_table.setCellWidget(row, PROPERTY_ACTION_COLUMN, button) + return def _on_property_scope_changed(self, row: int) -> None: specs = getattr(self, "property_editor_specs", []) @@ -2184,9 +2196,7 @@ class WindowStateMixin: target_item = self.property_table.item(row, PROPERTY_TARGET_COLUMN) if target_item is not None: target_item.setToolTip(self._property_target_tooltip(effective_spec, editable=editable)) - action_item = self.property_table.item(row, PROPERTY_ACTION_COLUMN) - if action_item is not None and not editable: - action_item.setText(str(effective_spec.get("status_text", ""))) + self._update_current_capability_panel() self._update_property_apply_state() def _on_property_card_target_changed(self, row: int) -> None: @@ -2235,14 +2245,27 @@ class WindowStateMixin: if not hasattr(self, "property_table"): return row_count = len(getattr(self, "property_editor_specs", []) or []) - collapsed_rows = int(getattr(self, "property_table_collapsed_rows", 6) or 6) + collapsed_rows = int(getattr(self, "property_table_collapsed_rows", 5) or 5) + collapsed_rows = max(collapsed_rows, 1) + min_visible_rows = int(getattr(self, "property_table_min_visible_rows", 5) or 5) + min_visible_rows = max(min_visible_rows, 1) expanded = bool(getattr(self, "property_table_expanded", False)) - visible_rows = row_count if expanded else min(row_count, collapsed_rows) - visible_rows = max(1, visible_rows) - row_height = max(int(self.property_table.verticalHeader().defaultSectionSize()), 22) + if expanded: + visible_rows = max(row_count, 1) + elif row_count > 0: + visible_rows = min(max(row_count, min_visible_rows), collapsed_rows) + else: + visible_rows = 1 + default_row_height = max(int(self.property_table.verticalHeader().defaultSectionSize()), 22) + row_heights = [ + max(int(self.property_table.rowHeight(row)), default_row_height) + for row in range(min(row_count, visible_rows)) + ] + if len(row_heights) < visible_rows: + row_heights.extend([default_row_height] * (visible_rows - len(row_heights))) header_height = int(self.property_table.horizontalHeader().height()) frame = int(self.property_table.frameWidth()) * 2 - height = header_height + frame + visible_rows * row_height + 8 + height = header_height + frame + sum(row_heights) + 8 self.property_table.setMinimumHeight(height) self.property_table.setMaximumHeight(height) has_hidden_rows = row_count > collapsed_rows @@ -2252,34 +2275,17 @@ class WindowStateMixin: else Qt.ScrollBarPolicy.ScrollBarAsNeeded ) if hasattr(self, "property_card_scroll"): - card_visible_rows = self._visible_property_card_rows() - card_heights: list[int] = [] - for row, _spec in card_visible_rows: - card = self._property_card_widgets(row).get("card") - if isinstance(card, QFrame): - card_heights.append(max(int(card.sizeHint().height()), 40)) - if card_heights: - content_height = sum(card_heights) + max(0, len(card_heights) - 1) * 4 + 10 - else: - content_height = 96 - card_height = content_height - card_height = min(card_height, 560 if expanded else 360) - card_height = max(96, card_height) - if hasattr(self, "property_card_container"): - self.property_card_container.setMinimumHeight(content_height) - self.property_card_scroll.setMinimumHeight(card_height) - self.property_card_scroll.setMaximumHeight(card_height) - self.property_card_scroll.setVerticalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAsNeeded if content_height > card_height else Qt.ScrollBarPolicy.ScrollBarAlwaysOff - ) + self.property_card_scroll.setVisible(False) + self.property_card_scroll.setMinimumHeight(0) + self.property_card_scroll.setMaximumHeight(0) if hasattr(self, "property_expand_button"): self.property_expand_button.setVisible(has_hidden_rows) if expanded: - self.property_expand_button.setText(f"收起诊断信息,保留前 {collapsed_rows} 项") - self.property_expand_button.setToolTip("收起只读诊断信息,保留可编辑参数和关键说明。") + self.property_expand_button.setText(f"收起到前 {collapsed_rows} 项") + self.property_expand_button.setToolTip("收起参数列表,只保留最常用的前几项。") else: - self.property_expand_button.setText(f"更多诊断信息 ({row_count} 项)") - self.property_expand_button.setToolTip("展开完整参数、识别依据和一级关系诊断;参数化建模按钮会继续留在下方。") + self.property_expand_button.setText(f"展开全部参数 ({row_count} 项)") + self.property_expand_button.setToolTip("展开完整参数列表;参数化建模按钮会继续留在下方。") self._resize_property_table_columns() def _resize_property_table_columns(self) -> None: @@ -2297,8 +2303,6 @@ class WindowStateMixin: if not hasattr(self, "property_table"): return self.property_table_expanded = not bool(getattr(self, "property_table_expanded", False)) - self._rebuild_property_command_bar() - self._rebuild_property_cards() self._resize_property_table_height() def _property_table_item(self, text: str, *, editable: bool) -> QTableWidgetItem: @@ -2314,40 +2318,17 @@ class WindowStateMixin: info: dict[str, object], action_info: dict[str, object], ) -> list[dict[str, object]]: - editable_specs, used_keys = self._editable_property_specs(action_info) + editable_specs, _used_keys = self._editable_property_specs(action_info) if self.selected_kind == "feature": return self._feature_context_property_specs(editable_specs, action_info) - specs = list(editable_specs) - for key, value in self._ordered_property_info_items(info): - if key in used_keys: - continue - specs.append( - { - "key": key, - "label": INFO_LABELS.get(key, key), - "current_text": _format_info_value(key, value), - "current_raw": value, - "target_text": "", - "editable": False, - "enabled": False, - "status_text": "只读", - "disabled_tip": "这个属性来自当前 STEP/B-Rep 几何或识别结果,当前版本只能查看,不能直接修改。", - } - ) - if not specs: - specs.append( - { - "key": "empty", - "label": "当前对象", - "current_text": "尚未选择", - "target_text": "", - "editable": False, - "enabled": False, - "status_text": "只读", - "disabled_tip": "请先在模型中选择零件、Solid、Face、Edge 或特征。", - } - ) - return specs + return [ + dict(spec) + for spec in editable_specs + if bool(spec.get("editable")) + and bool(spec.get("enabled")) + and bool(spec.get("action")) + and str(spec.get("value_type", "number")) != "command" + ] def _feature_property_specs( self, @@ -2356,51 +2337,113 @@ class WindowStateMixin: ) -> list[dict[str, object]]: allowed_keys = _feature_dimension_keys(action_info) spec_by_key = {str(spec.get("key", "")): spec for spec in specs} + prismatic_semantics = str(action_info.get("prismatic_feature_semantics") or "") + embedded_prismatic = prismatic_semantics in {"additive-boss", "subtractive-pocket"} + prismatic_length = _float_or_none(action_info.get("prismatic_length")) + prismatic_width = _float_or_none(action_info.get("prismatic_width")) + prismatic_depth = _float_or_none(action_info.get("prismatic_extrusion_estimate")) + prismatic_size_supported = bool( + embedded_prismatic + and action_info.get("prismatic_profile_status") == "candidate" + and action_info.get("prismatic_extrusion_status") == "candidate" + and prismatic_length is not None + and prismatic_length > 0 + and prismatic_width is not None + and prismatic_width > 0 + and prismatic_depth is not None + and prismatic_depth > 0 + and len(_int_values(action_info.get("prismatic_connected_side_face_ids"))) == 4 + ) dimensions: list[dict[str, object]] = [] for key in allowed_keys: spec = spec_by_key.get(key) - if spec is None or not bool(spec.get("editable")) or not bool(spec.get("enabled")): + prismatic_size_key = embedded_prismatic and key in {"local_face_width", "local_face_height"} + prismatic_center_key = embedded_prismatic and key == "face_center_position" + if ( + spec is None + or not bool(spec.get("editable")) + or (not bool(spec.get("enabled")) and not prismatic_size_key and not prismatic_center_key) + or str(spec.get("value_type", "number")) == "command" + ): continue dimension = dict(spec) dimension["parameter_role"] = "dimension" if action_info.get("prismatic_profile_status") == "candidate": + if prismatic_size_key: + if not prismatic_size_supported: + continue + modes = spec.get("scope_modes") + local_mode = modes.get("local") if isinstance(modes, dict) else None + local_mode = dict(local_mode) if isinstance(local_mode, dict) else {} + is_width_axis = key == "local_face_width" + current_limit = prismatic_width if is_width_axis else prismatic_length + local_mode.update( + { + "label": "局部重建", + "action": "resize_face_width_local" if is_width_axis else "resize_face_height_local", + "target_attr": "face_width_input" if is_width_axis else "face_height_input", + "enabled": True, + "enabled_tip": ( + "输入矩形凸台/口袋的目标长度或宽度;程序会先移除/补回旧矩形包络," + "再重建目标矩形特征。" + ), + "disabled_tip": "当前矩形特征的长宽、深度或四个侧壁没有稳定识别,暂不开放长宽修改。", + "range_hint": "当前版本不在一次编辑中交换长度和宽度方向。", + } + ) + if current_limit is not None and current_limit > 0: + if is_width_axis: + local_mode["min_value"] = current_limit + local_mode["min_exclusive"] = True + else: + local_mode["max_value"] = current_limit + local_mode["max_exclusive"] = True + dimension["scope_modes"] = {"local": local_mode} + dimension["scope_default"] = "local" + dimension["scope_text"] = "局部重建" + dimension["enabled"] = True + dimension["editable"] = True + if prismatic_center_key: + if not prismatic_size_supported: + continue + modes = spec.get("scope_modes") + local_mode = modes.get("local") if isinstance(modes, dict) else None + local_mode = dict(local_mode) if isinstance(local_mode, dict) else {} + current_center = _triple_or_none(action_info.get("area_center")) or _triple_or_none( + action_info.get("bbox_center") + ) + local_mode.update( + { + "label": "移动特征", + "action": "move_selected_face_center_local", + "target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"), + "enabled": current_center is not None, + "enabled_tip": ( + "输入矩形凸台/口袋的目标中心;当前只支持在基准平面内移动," + "沿高度/深度方向请修改高度/深度。" + ), + "disabled_tip": "当前矩形特征缺少稳定中心,暂不开放中心移动。", + "range_hint": "移动中心会局部重建矩形包络,不会平移整个零件。", + "target_transform": "target_center_to_translation", + "transform_context": {"current_center": current_center}, + } + ) + dimension["scope_modes"] = {"local": local_mode} + dimension["scope_default"] = "local" + dimension["scope_text"] = "移动特征" + dimension["enabled"] = current_center is not None + dimension["editable"] = True label_overrides = { "local_face_width": "长度", "local_face_height": "宽度", "shell_thickness_estimate": "高度/深度", + "face_center_position": "中心", } if key in label_overrides: dimension["label"] = label_overrides[key] dimensions.append(dimension) - pinned_explanations = [ - dict(spec) - for spec in specs - if str(spec.get("key", "")) in FEATURE_EDIT_SEMANTICS_KEYS and bool(spec.get("pin_top")) - ] - explanations = [ - dict(spec) - for spec in specs - if str(spec.get("key", "")) in FEATURE_EDIT_SEMANTICS_KEYS and not bool(spec.get("pin_top")) - ] - if not dimensions: - dimensions.append( - { - "key": "no_editable_feature_dimensions", - "label": "可变尺寸", - "current_text": "未识别到可靠的独立尺寸", - "current_raw": "", - "target_text": "", - "editable": False, - "enabled": False, - "status_text": "说明", - "disabled_tip": ( - "当前几何仍可在诊断信息中查看,但不会把面积、中心、包围盒或底层曲面参数" - "伪装成特征设计尺寸。" - ), - } - ) - return pinned_explanations + dimensions + explanations + return dimensions def _feature_context_property_specs( self, @@ -2408,37 +2451,11 @@ class WindowStateMixin: action_info: dict[str, object], ) -> list[dict[str, object]]: root_rows = self._feature_property_specs(root_specs, action_info) - context_rows: list[dict[str, object]] = [] - context_note = str(action_info.get("feature_context_note") or "").strip() - if context_note: - level_text = str(action_info.get("feature_detection_level") or "").strip() - associated_count = _int_or_none(action_info.get("associated_feature_count")) - count_text = "" if associated_count is None else f";关联特征 {associated_count} 项" - context_rows.append( - { - "key": "feature_context_note", - "label": "关联探测", - "current_text": context_note, - "current_raw": context_note, - "target_text": "", - "editable": False, - "enabled": False, - "status_text": "说明", - "scope_text": f"{level_text}{count_text}".strip(";"), - "disabled_tip": ( - "这里说明当前特征探测级别和已经找到的一级/二级关联特征;" - "有关联尺寸时,会作为带“关联 Face”的可修改行显示在同一张参数表里。" - ), - "pin_top": True, - "span_value_columns": True, - } - ) associated = action_info.get("associated_feature_infos") if not isinstance(associated, (list, tuple)) or not associated: - return context_rows + root_rows + return root_rows root_dimensions = [dict(spec) for spec in root_rows if spec.get("parameter_role") == "dimension"] - root_explanations = [dict(spec) for spec in root_rows if spec.get("parameter_role") != "dimension"] related_rows: list[dict[str, object]] = [] for index, related_info in enumerate(associated, start=1): if not isinstance(related_info, dict): @@ -2447,6 +2464,7 @@ class WindowStateMixin: feature_label = str(related_info.get("feature_type") or related_info.get("feature_guess") or "关联特征") source_face_id = _int_or_none(related_info.get("association_source_face_id")) feature_label = str(related_info.get("association_label") or feature_label) + feature_label = self._feature_display_label(feature_label) for spec in self._feature_property_specs(related_specs, related_info): if spec.get("parameter_role") != "dimension": continue @@ -2458,7 +2476,7 @@ class WindowStateMixin: related["association_index"] = index related_rows.append(related) - return context_rows + root_dimensions + related_rows + root_explanations + return root_dimensions + related_rows def _ordered_property_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]: items = self._ordered_info_items(info) @@ -2685,7 +2703,6 @@ class WindowStateMixin: return "格式为 X, Y, Z;建议先小幅试改并查看预览。" face_linear_hard_limit = "目标值低于当前值的 5% 或高于当前值的 5 倍会被阻止。" - face_area_hard_limit = "如果目标面积会让 U向尺寸/V向尺寸缩到当前 5% 以下或放大到 5 倍以上,会被阻止。" face_offset_hard_limit = "如果目标位置与当前位置差距远超当前模型尺寸,会被阻止。" face_center_hard_limit = "如果目标中心与当前中心距离超过所属对象尺寸的 5 倍,会被阻止。" @@ -2968,7 +2985,7 @@ class WindowStateMixin: ) if is_plane: return ( - "柔性建模 / 拉伸切除 / 偏移变换:平面 Face 可按不同意图改变位置、面积或尺寸。", + "柔性建模 / 拉伸切除 / 偏移:平面 Face 可按不同意图改变位置或尺寸。", "平面 Face 不是直接暴露 B-Rep 参数;界面会把目标值映射为拉伸/切除、局部重建、移动特征或缩放特征。", ) if is_cone: @@ -3047,12 +3064,12 @@ class WindowStateMixin: ) if is_shell_candidate: return ( - "优先改壳体厚度;普通位置变化再用偏移变换。", + "优先改壳体厚度;普通位置变化再用偏移。", "壳体厚度需要相对面识别稳定;如果只想移动整个对象,请选择移动特征或缩放特征语义。", ) if is_plane: return ( - "优先改偏移变换,并选择拉伸/切除;只想让当前面变形时再改面积、U/V尺寸或中心。", + "优先改偏移,并选择拉伸/切除;只想让当前面变形时再改面内长度、面内宽度或中心。", "拉伸/切除更接近常见 CAD 加料/切料;局部重建会让相邻面自然跟随变形,不一定保持垂直。", ) if is_cone: @@ -3112,7 +3129,7 @@ class WindowStateMixin: key="cad_modeling_form", label="建模形式", text=cad_form_text, - tip=cad_form_tip, + tip=PROPERTY_EXPLANATION_TOOLTIPS["cad_modeling_form"], pin_top=True, ) recommended_text, recommended_tip = cad_recommended_operation() @@ -3121,7 +3138,7 @@ class WindowStateMixin: key="cad_recommended_operation", label="推荐操作", text=recommended_text, - tip=recommended_tip, + tip=PROPERTY_EXPLANATION_TOOLTIPS["cad_recommended_operation"], pin_top=True, ) @@ -3274,8 +3291,8 @@ class WindowStateMixin: ) face_semantics_text = "Face:先改目标值,再用“建模意图”选择局部重建、拉伸/切除、移动特征或缩放特征。" face_semantics_tip = ( - "面积是 Face 的几何面积;U向尺寸/V向尺寸是这个 Face 自身参数方向上的两个投影尺寸;" - "偏移变换使用 Creo 柔性建模里的 Offset 语义,表示沿当前 Face 法向得到的目标位置。" + "面内长度/面内宽度是这个 Face 在自身平面内两个主方向上的投影尺寸;" + "偏移使用 Creo 柔性建模里的 Offset 语义,表示沿当前 Face 法向得到的目标位置。" "建模意图决定这次修改是局部重建当前 Face、按拉伸/切除加减材料," "还是移动或缩放所属特征/Solid。" ) @@ -3335,75 +3352,24 @@ class WindowStateMixin: ) if show_generic_face_edit_specs: - current_face_area = _float_or_none(action_info.get("area")) current_face_center = ( _triple_or_none(action_info.get("area_center")) or _triple_or_none(action_info.get("bbox_center")) ) - add_scoped_spec( - key="area", - label="面积", - current_raw=current_face_area if current_face_area is not None else "", - target_text=numeric_text(current_face_area), - scope_default="local", - scope_modes={ - "local": { - "label": "局部重建", - "action": "resize_face_area_local", - "target_attr": "face_area_input", - "enabled": bool( - is_plane - and local_face_deform_ready - and current_face_area is not None - and current_face_area > 0 - and current_face_center is not None - ), - "enabled_tip": ( - "输入目标面积;程序只在当前 Face 平面内缩放该 Face 的顶点," - "相邻面会按新边界重建。" - ), - "disabled_tip": face_local_disabled_tip( - "只有带稳定面积和中心坐标的平面 Face 才能尝试局部重建面积。" - ), - "range_hint": ( - "局部重建时,相邻面可能自然变斜。当前只对简单全平面实体开放," - f"建议先小幅修改。 {relative_range_hint(current_face_area, 0.15, 0.8, face_area_hard_limit)}" - ), - }, - "owning": { - "label": "缩放特征", - "action": "resize_face_area", - "target_attr": "face_area_input", - "enabled": current_face_area is not None and current_face_area > 0, - "enabled_tip": ( - "输入目标面积;程序会用该 Face 作为参考,均匀缩放它所属的特征或 Solid," - "让该面的面积接近目标值。" - ), - "disabled_tip": "当前 Face 缺少稳定面积,不能按目标面积缩放所属对象。", - "range_hint": ( - "缩放特征会影响同一对象上的其它尺寸。" - f" {relative_range_hint(current_face_area, 0.15, 0.8, face_area_hard_limit)}" - ), - }, - }, - value_type="positive", - used=("area", "area_center", "bbox_center"), - **face_scale_limits(current_face_area, squared=True), - ) if is_plane: current_face_width = _float_or_none(action_info.get("local_face_width")) current_face_height = _float_or_none(action_info.get("local_face_height")) face_size_tip = ( - "这里的 U向尺寸/V向尺寸是选中 Face 在自身参数方向上的投影长度," + "这里的面内长度/面内宽度是选中 Face 在自身平面内两个主方向上的投影长度," "不是面积,也不是模型整体高度。局部重建会移动当前 Face 顶点,相邻面会按新顶点重建。" ) face_size_owner_tip = ( - "这里的 U向尺寸/V向尺寸是选中 Face 在自身参数方向上的投影长度," + "这里的面内长度/面内宽度是选中 Face 在自身平面内两个主方向上的投影长度," "不是面积,也不是模型整体高度。程序会沿该方向缩放所属特征或 Solid,其它几何会跟随变化。" ) add_scoped_spec( key="local_face_width", - label="U向尺寸", + label="面内长度", current_raw=current_face_width if current_face_width is not None else "", target_text=numeric_text(current_face_width), scope_default="local", @@ -3418,9 +3384,9 @@ class WindowStateMixin: and current_face_width > 0 and current_face_center is not None ), - "enabled_tip": f"输入目标 U向尺寸;{face_size_tip}", + "enabled_tip": f"输入目标面内长度;{face_size_tip}", "disabled_tip": face_local_disabled_tip( - "只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改 U向尺寸。" + "只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改面内长度。" ), "range_hint": f"{face_size_tip} {relative_range_hint(current_face_width, 0.25, 0.8, face_linear_hard_limit)}", }, @@ -3434,8 +3400,8 @@ class WindowStateMixin: and current_face_center is not None and (self.selected_part_id is not None or self.selected_solid_id is not None) ), - "enabled_tip": f"输入目标 U向尺寸;{face_size_owner_tip}", - "disabled_tip": "当前 Face 缺少稳定 U向尺寸、中心坐标或所属对象,不能按这个方向缩放所属对象。", + "enabled_tip": f"输入目标面内长度;{face_size_owner_tip}", + "disabled_tip": "当前 Face 缺少稳定面内长度、中心坐标或所属对象,不能按这个方向缩放所属对象。", "range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_width, 0.2, 0.5, face_linear_hard_limit)}", }, }, @@ -3445,7 +3411,7 @@ class WindowStateMixin: ) add_scoped_spec( key="local_face_height", - label="V向尺寸", + label="面内宽度", current_raw=current_face_height if current_face_height is not None else "", target_text=numeric_text(current_face_height), scope_default="local", @@ -3460,9 +3426,9 @@ class WindowStateMixin: and current_face_height > 0 and current_face_center is not None ), - "enabled_tip": f"输入目标 V向尺寸;{face_size_tip}", + "enabled_tip": f"输入目标面内宽度;{face_size_tip}", "disabled_tip": face_local_disabled_tip( - "只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改 V向尺寸。" + "只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改面内宽度。" ), "range_hint": f"{face_size_tip} {relative_range_hint(current_face_height, 0.25, 0.8, face_linear_hard_limit)}", }, @@ -3476,8 +3442,8 @@ class WindowStateMixin: and current_face_center is not None and (self.selected_part_id is not None or self.selected_solid_id is not None) ), - "enabled_tip": f"输入目标 V向尺寸;{face_size_owner_tip}", - "disabled_tip": "当前 Face 缺少稳定 V向尺寸、中心坐标或所属对象,不能按这个方向缩放所属对象。", + "enabled_tip": f"输入目标面内宽度;{face_size_owner_tip}", + "disabled_tip": "当前 Face 缺少稳定面内宽度、中心坐标或所属对象,不能按这个方向缩放所属对象。", "range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_height, 0.2, 0.5, face_linear_hard_limit)}", }, }, @@ -3562,7 +3528,7 @@ class WindowStateMixin: ) add_scoped_spec( key="face_target_normal_position", - label="偏移变换", + label="偏移", current_raw=current_plane_position if current_plane_position is not None else "", target_text=numeric_text(current_plane_position), scope_default="push_pull", @@ -3572,10 +3538,10 @@ class WindowStateMixin: "action": "push_pull_face", "target_attr": "offset_input", "enabled": current_plane_position is not None, - "enabled_tip": "输入偏移变换的目标位置;程序会沿当前 Face 法向执行拉伸或切除。", + "enabled_tip": "输入偏移的目标位置;程序会沿当前 Face 法向执行拉伸或切除。", "disabled_tip": "当前平面缺少稳定移动方向或基准点,不能按目标位置拉伸/切除。", "range_hint": ( - "偏移变换是沿当前 Face 法向测量的目标位置,不是面积、不是移动距离,也不是 X/Y/Z 坐标;单位同模型。" + "偏移是沿当前 Face 法向测量的目标位置,不是面积、不是移动距离,也不是 X/Y/Z 坐标;单位同模型。" "程序会把目标位置自动换算成本次拉伸/切除距离。" f"{face_offset_hard_limit}" ), @@ -3593,11 +3559,11 @@ class WindowStateMixin: and current_face_center is not None ), "enabled_tip": ( - "输入偏移变换的目标位置;程序只把当前 Face 沿法向移动到该目标值," + "输入偏移的目标位置;程序只把当前 Face 沿法向移动到该目标值," "并让相邻平面按新顶点重建。" ), "disabled_tip": face_local_disabled_tip( - "当前平面缺少稳定方向、基准点或中心,不能按偏移变换局部重建当前 Face。" + "当前平面缺少稳定方向、基准点或中心,不能按偏移局部重建当前 Face。" ), "range_hint": ( "局部重建不会自动加料/切削,相邻面可能自然变斜," @@ -3617,10 +3583,10 @@ class WindowStateMixin: and (self.selected_part_id is not None or self.selected_solid_id is not None) ), "enabled_tip": ( - "输入偏移变换的目标位置;程序会沿当前 Face 法向平移所属特征或 Solid," + "输入偏移的目标位置;程序会沿当前 Face 法向平移所属特征或 Solid," "当前面形状和所属对象内部尺寸不变。" ), - "disabled_tip": "当前平面缺少稳定方向、基准点或所属对象,不能按偏移变换移动所属特征。", + "disabled_tip": "当前平面缺少稳定方向、基准点或所属对象,不能按偏移移动所属特征。", "range_hint": ( "这是移动所属对象,不是拉伸/切除当前 Face;如果想改变形状或厚度,请选择“拉伸/切除”。" f"{translation_hint()} {face_offset_hard_limit}" @@ -4117,7 +4083,25 @@ class WindowStateMixin: current_depth = manual_depth except Exception: pass - can_depth = bool((is_blind or manual_bottom_id is not None) and (has_bottom or manual_bottom_id is not None) and current_depth is not None) + depth_status = str(action_info.get("depth_status") or "") + auto_blind_depth_ready = bool(is_blind and current_depth is not None and depth_status != "blocked") + explicit_bottom_ready = bool(has_bottom or manual_bottom_id is not None) + can_depth = bool(current_depth is not None and (auto_blind_depth_ready or manual_bottom_id is not None)) + local_depth_tip = "输入盲孔或盲槽的目标深度;加深会切削,变浅会补料到新的底面位置。" + local_depth_range_hint = relative_range_hint(current_depth, 0.35, 1.0) + if can_depth and not explicit_bottom_ready: + local_depth_tip = ( + "快速识别已判断为盲孔/盲槽并估算出深度;点击修改时会重新确认底面 Face," + "确认失败会立即阻止并说明原因。" + ) + local_depth_range_hint = ( + f"{local_depth_range_hint} 执行时会重新确认底面 Face;确认失败会立即阻止。" + ) + depth_disabled_tip = ( + "当前对象没有稳定盲孔/盲槽深度估算,或端部不是明确盲孔;" + "自动识别失败时可手动填写底面 Face ID。" + ) + owning_can_depth = bool(can_depth and explicit_bottom_ready) if is_blind or current_depth is not None: add_scoped_spec( key="hole_depth_estimate", @@ -4131,16 +4115,16 @@ class WindowStateMixin: "action": "resize_hole_depth", "target_attr": "hole_depth_input", "enabled": can_depth, - "enabled_tip": "输入盲孔或盲槽的目标深度;加深会切削,变浅会补料到新的底面位置。", - "disabled_tip": "需要识别到盲孔/盲槽底面后才能直接修改深度。", - "range_hint": relative_range_hint(current_depth, 0.35, 1.0), + "enabled_tip": local_depth_tip, + "disabled_tip": depth_disabled_tip, + "range_hint": local_depth_range_hint, }, "owning": { "label": "缩放特征", "action": "resize_hole_depth_owning_scale", "target_attr": "hole_depth_input", "enabled": bool( - can_depth + owning_can_depth and current_depth is not None and current_depth > 0 and _triple_or_none(action_info.get("axis_point")) is not None @@ -4770,8 +4754,14 @@ class WindowStateMixin: ) if has_edge: current_length = _float_or_none(action_info.get("length")) + is_line_edge = curve == "line" is_circle_edge = curve == "circle" is_ellipse_edge = curve == "ellipse" + edge_length_supported = bool(is_line_edge or is_circle_edge) + unsupported_edge_length_tip = ( + "当前阶段只把直线Edge长度、圆Edge周长/相邻圆柱半径作为稳定可改入口;" + "椭圆Edge请改主半径或小半径,B-spline/Bezier/其它复杂曲线Edge暂未实现通用约束长度编辑。" + ) def edge_strategy_effect_text(strategy_mode: str, anchor_mode: str) -> str: anchor_text = { "auto": "自动基准:默认尽量固定一端,移动另一端。", @@ -4811,9 +4801,9 @@ class WindowStateMixin: "label": "自动", "action": "resize_any_edge_length", "target_attr": "edge_target_length_input", - "enabled": current_length is not None, + "enabled": bool(edge_length_supported and current_length is not None), "enabled_tip": "输入当前Edge的目标长度;程序会自动选择局部形变、端面移动、相邻圆柱编辑或缩放所属对象。", - "disabled_tip": "当前Edge没有稳定长度信息。", + "disabled_tip": "当前Edge没有稳定长度信息。" if edge_length_supported else unsupported_edge_length_tip, "range_hint": ( f"{edge_strategy_effect_text('auto', current_anchor_mode)} " f"{relative_range_hint(current_length, 0.25, 0.5)}" @@ -4863,9 +4853,9 @@ class WindowStateMixin: "label": "缩放所属", "action": "resize_any_edge_length", "target_attr": "edge_target_length_input", - "enabled": current_length is not None, + "enabled": bool(edge_length_supported and current_length is not None), "enabled_tip": "输入当前Edge的目标长度;程序会缩放所属特征或 Solid,其它尺寸也会跟随变化。", - "disabled_tip": "当前Edge没有稳定长度信息,不能缩放所属对象。", + "disabled_tip": "当前Edge没有稳定长度信息,不能缩放所属对象。" if edge_length_supported else unsupported_edge_length_tip, "range_hint": ( f"{edge_strategy_effect_text('scale-owning-shape-from-edge', current_anchor_mode)} " f"{relative_range_hint(current_length, 0.2, 0.5)}" @@ -4876,36 +4866,37 @@ class WindowStateMixin: value_type="positive", **positive_minimum(), ) - add_spec( - key="edge_length_anchor_mode", - label="长度基准", - current_raw=current_anchor_mode, - target_text=current_anchor_label, - action="set_edge_length_anchor_mode", - target_attr="edge_length_anchor_combo", - enabled=True, - enabled_tip="设置修改Edge长度时固定哪里:自动、中心、固定起点或固定终点。", - disabled_tip="请先选择一条Edge。", - value_type="choice", - range_hint="可输入:自动、中心、固定起点、固定终点,也可以输入 auto、center、start、end。", - button_text="设置", - choices={ - "自动": "auto", - "auto": "auto", - "中心": "center", - "固定中心": "center", - "center": "center", - "centre": "center", - "起点": "keep-start", - "固定起点": "keep-start", - "start": "keep-start", - "keep-start": "keep-start", - "终点": "keep-end", - "固定终点": "keep-end", - "end": "keep-end", - "keep-end": "keep-end", - }, - ) + if edge_length_supported: + add_spec( + key="edge_length_anchor_mode", + label="长度基准", + current_raw=current_anchor_mode, + target_text=current_anchor_label, + action="set_edge_length_anchor_mode", + target_attr="edge_length_anchor_combo", + enabled=True, + enabled_tip="设置修改Edge长度时固定哪里:自动、中心、固定起点或固定终点。", + disabled_tip="请先选择一条Edge。", + value_type="choice", + range_hint="可输入:自动、中心、固定起点、固定终点,也可以输入 auto、center、start、end。", + button_text="设置", + choices={ + "自动": "auto", + "auto": "auto", + "中心": "center", + "固定中心": "center", + "center": "center", + "centre": "center", + "起点": "keep-start", + "固定起点": "keep-start", + "start": "keep-start", + "keep-start": "keep-start", + "终点": "keep-end", + "固定终点": "keep-end", + "end": "keep-end", + "keep-end": "keep-end", + }, + ) edge_start_point = _triple_or_none(action_info.get("start_point")) edge_end_point = _triple_or_none(action_info.get("end_point")) edge_center_point = _triple_or_none(action_info.get("length_center")) @@ -5435,8 +5426,6 @@ class WindowStateMixin: card.style().polish(card) setattr(card, "_geom_param_card_state", card_state) widget = card_widgets.get("action_button") - if not isinstance(widget, QPushButton): - widget = self.property_table.cellWidget(row, PROPERTY_ACTION_COLUMN) if isinstance(widget, QPushButton): validation_error = "" if str(effective_spec.get("value_type", "number")) == "command": @@ -5508,11 +5497,11 @@ class WindowStateMixin: enabled = bool(has_model and changed) disabled_tip = "请先选择对象,并在属性表中修改一个可编辑目标值。" if changed and len(changed) > 1: - disabled_tip = "请使用每一行状态列里的修改按钮,单独应用某个特征。" + disabled_tip = "当前一次只执行一个几何修改;请只保留一行目标值不同,再点击参数化建模。" self._set_control_state( self.apply_property_button, enabled and len(changed) == 1, - "应用属性表中被修改的一行目标值。", + "应用当前被修改的参数。", disabled_tip, ) @@ -5723,7 +5712,7 @@ class WindowStateMixin: self.statusBar().showMessage("请先在当前选中对象表中修改一个可编辑目标值。") return if len(changed) > 1: - QMessageBox.information(self, "一次应用一个修改", "请只修改一行目标值,然后再点击应用当前修改。") + QMessageBox.information(self, "一次执行一个修改", "请只修改一行目标值,然后再点击参数化建模。") return row, _spec, _text = changed[0] self.apply_property_row_edit(row)