Compare commits

...

5 Commits

32 changed files with 4722 additions and 297 deletions
+57 -46
View File
@@ -158,7 +158,7 @@ git diff --check
- 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。
- 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。
- 识别结果会区分 `当前可改``当前受限修改``识别限制``受限能力`:比如一个通孔可以改孔径,但盲孔深度属于受限能力;一个带内孔的平面可以拉伸/切除,但“局部重建尺寸/中心/偏移”会被标成受限修改。
- 用户可以修改可控的特征,比如 Face 的面内长度、面内宽度、中心、偏移和壳体厚度,并通过 `建模意图` 选择拉伸/切除、局部重建、移动特征或缩放特征;面积是结果变量,放在诊断信息里查看,不作为特征参数表里的驱动变量。还可以做孔径/半径调整、孔/槽轴心并通过 `建模意图` 选择移动局部特征或移动特征、槽宽/槽深/弧长并通过 `建模意图` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、壳体厚度(局部拉伸/切除或整体缩放)、凸台直径/半径/高度/轴心并通过 `建模意图` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `建模意图` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(简单圆锥解析重建,嵌入式锥孔优先局部重切)、球面半径/直径(缩放特征)、环面主/小半径或直径(缩放特征)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `建模意图` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `建模意图` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。
- 用户可以修改可控的特征,比如 Face 的面内长度、面内宽度、中心、偏移和壳体厚度,并通过 `建模意图` 选择拉伸/切除、局部重建、移动特征或缩放特征;面积是结果变量,放在诊断信息里查看,不作为特征参数表里的驱动变量。还可以做孔径/半径调整、孔/槽轴心并通过 `建模意图` 选择移动局部特征或移动特征、槽宽/槽深/弧长并通过 `建模意图` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、壳体厚度(局部拉伸/切除或整体缩放)、凸台直径/半径/高度/轴心并通过 `建模意图` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度、简单等半径圆角链半径/弧长并通过 `建模意图` 选择重建圆角或整体调整、已有等距倒角距离、圆锥参考半径/直径/半角(简单圆锥解析重建,嵌入式锥孔优先局部重切)、球面半径/直径(缩放特征)、环面主/小半径或直径(缩放特征)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `建模意图` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `建模意图` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。
- 用户可以撤销/重做修改,避免一步编辑做坏。
- 用户可以导出修改后的完整模型。
- 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。
@@ -192,6 +192,8 @@ STEP/B-Rep 参数化编辑主线
├── 1. 平面 Face,第一条主线
│ ├── [x] [偏移 -> 推拉平面 / 拉伸切除]
│ │ └── 当前 Face 移动或加料/切削,边、点和共享边一级相邻面跟随重建,复杂端盖走隔离和回滚。
│ ├── [x] [偏移 + 保持关系 -> 受限拉伸/切除]
│ │ └── 偏移行新增“保持关系”建模意图:执行前要求一级相邻 Face 都能验证为平面平行/垂直关系,执行后反查关系;非平面、斜交或复杂多边界慢计划会提前阻止并有回归守门。
│ ├── [x] [面内长度 -> 改长度]
│ │ └── 近矩形平面 Face 沿长度方向变化,一级相邻面跟随,面积不作为驱动参数。
│ ├── [x] [面内宽度 -> 改宽度]
@@ -230,6 +232,8 @@ STEP/B-Rep 参数化编辑主线
│ │ └── 自动或手动配对两个圆弧端,槽宽保持不变。
│ ├── [x] [中心位置 -> 移动槽]
│ │ └── 半圆槽和长圆槽都按特征整体补旧槽、切新槽。
│ ├── [x] [复杂/交叉槽 -> 识别并快速阻止]
│ │ └── 多个同半径槽端但没有稳定共享侧壁配对时,会标成复杂槽/多槽组,不在扫描列表和参数表里暴露槽宽、槽深、弧长、弧角或轴心入口。
│ └── [ ] [复杂草图槽/跨复杂面槽 -> 稳定修改]
│ └── 非圆柱槽、跨复杂面的槽暂不承诺稳定编辑;多槽组关联放到第 8 阶段。
@@ -248,28 +252,32 @@ STEP/B-Rep 参数化编辑主线
│ │ └── 规则矩形凸台/口袋可在基准平面内移动中心,长宽和高度/深度保持不变;沿高度/深度方向移动会提前阻止。
│ ├── [x] [多台阶矩形凸台顶层规则台阶 -> 独立编辑]
│ │ └── 选中多台阶凸台最上层规则矩形台阶的顶面时,可按独立矩形凸台修改长宽、中心和高度。
│ └── [ ] [复杂多台阶凸台整体 -> 局部重建]
│ └── 多台阶整组联动、异形凸台和复杂融合边界暂不作为稳定能力。
│ └── [x] [复杂多台阶凸台整体 -> 识别并快速阻止]
│ └── 多台阶中间过渡面会标记为受限,扫描列表、参数表和计划阶段都不暴露伪可改入口;整组联动、异形凸台和复杂融合边界仍未作为稳定能力。
├── 5. 圆角 / 倒角,从主形体扩展到边修饰
│ ├── [x] [Edge -> 添加圆角]
│ │ └── 简单直线 Edge 可调用 OCCT 倒圆,失败时回滚。
│ ├── [x] [Edge -> 添加倒角]
│ │ └── 支持对称倒角、不等距倒角和距离+角度倒角的简单边场景。
│ ├── [x] [距离 -> 改已有简单等距倒角]
│ │ └── 选中简单倒角斜面时按短边和支撑面估算倒角距离,先移除旧倒角面,再在恢复出的锐边上重新倒角。
│ ├── [x] [半径 -> 改已有简单圆角]
│ │ └── 当前走 defeature + refillet,目标半径和结果邻域必须反查通过。
│ ├── [x] [弧长 -> 调整已有圆角]
│ │ └── 按当前圆弧角把目标弧长换算成目标半径,再走 defeature + refillet,结果弧长必须反查通过。
│ ├── [x] [复杂圆角链 -> 识别并快速阻止]
│ │ └── 相邻同半径圆角 Face 会标成圆角链,修改半径/弧长时在计划阶段阻止并说明当前暂未实现
── [ ] [复杂圆角链 / 变半径圆角 -> 稳定重建]
└── 链式 blend 和变半径圆角的同步重建仍未完成,暂不承诺修改
│ ├── [x] [简单等半径圆角链 -> 整链半径/弧长修改]
│ │ └── 2 到 4 个共享边连通的同半径圆角 Face 可一起 defeature,再对恢复出的多条锐边重新倒圆;弧长会按当前圆弧角换算成目标半径,结果至少反查到同数量目标半径/弧长圆角面
── [x] [变半径 / 复杂圆角链 -> 识别并快速阻止]
└── 相邻不同半径圆角 Face、角部 blend 或超过 4 个面的同半径长链会阻止,不暴露为单个可改圆角参数
│ └── [ ] [复杂圆角链 / 变半径圆角 -> 通用稳定重建]
│ └── 大链式 blend、变半径圆角和复杂角部补面同步重建仍未完成,暂不承诺修改。
├── 6. Edge 一级编辑,补齐底层直接改边能力
│ ├── [x] [边长 -> 改直线 Edge 长度]
│ │ └── 支持只改当前边、移动端面、相邻圆柱和缩放所属对象等显式建模意图。
│ ├── [x] [起点/中心/终点 -> 移动直线 Edge]
│ │ └── 简单全平面模型可移动端点或整条边,并重建相邻面。
│ │ └── 简单全平面模型可移动端点或整条边,并重建相邻面;计划和确认框会明确这是局部 Edge 变形,不等同于端面整体推拉
│ ├── [x] [圆形 Edge 半径/直径 -> 代理到相邻圆柱]
│ │ └── 圆边优先复用孔/槽/凸台直径或轴心修改,而不是裸改曲线。
│ ├── [x] [椭圆 Edge 主/小半径 -> 单轴缩放]
@@ -279,9 +287,11 @@ STEP/B-Rep 参数化编辑主线
│ ├── [x] [固定起点/终点/中心 -> Edge 长度锚点]
│ │ └── 修改直线 Edge 长度时可选择固定起点、固定终点或固定中心,结果会按锚点反查。
│ ├── [x] [平行/垂直 -> 端面推拉一级关系守门]
│ │ └── 移动端面策略会确认直接相邻平面和移动端面可验证,执行后反查垂直/平行关系。
│ │ └── 移动端面策略会确认直接相邻平面和移动端面可验证,执行前给出平面约束摘要,执行后反查垂直/平行关系。
│ ├── [x] [平行/垂直 -> Face/Edge 一级事实识别]
│ │ └── Face 和 Edge 一级事实图会记录直接邻域内的平行、垂直、斜交关系数量和摘要;Face 事实图还会明确同域/共面碎片和一级同轴圆柱关系,供 UI 说明、计划守门和后续约束求解复用。
│ ├── [~] [平行/垂直 -> 通用可选约束]
│ │ └── 跨多面、跨特征的平行/垂直约束仍需继续泛化,还不是通用约束求解
│ │ └── Face 偏移可走受限拉伸/切除,Face 面内长度/宽度的“保持关系”会转成所属对象单向缩放,Face 中心会转成所属对象平移,Edge 长度可走端面推拉;这些路径都会要求一级平面关系可验证并做结果反查。跨多面、跨特征的通用约束求解仍需继续泛化
│ ├── [x] [复杂曲线 Edge -> 可编辑入口守门]
│ │ └── B-spline / Bezier / 其它复杂曲线不再在扫描列表或参数表里伪装成稳定“改长度”入口。
│ └── [ ] [任意曲线 Edge -> 通用约束编辑]
@@ -296,8 +306,8 @@ STEP/B-Rep 参数化编辑主线
│ │ └── 修改后仍需识别为目标球面。
│ ├── [x] [环面主/小半径 -> 缩放特征]
│ │ └── 修改后仍需识别为目标环面。
│ └── [ ] [完整抽壳/开口面编辑 -> 壳体特征级重建]
│ └── STEP 无历史下的通用抽壳关系恢复放在后期
│ └── [x] [完整抽壳/开口面编辑 -> 先识别开口薄壁上下文并明确限制]
│ └── 开口薄壁盒/槽腔类区域会标出“完整抽壳/开口面编辑”受限;可编辑对象扫描会优先露出真实薄壁的壳体厚度入口,当前仍只开放局部壳体厚度、平面推拉或整体缩放,不做通用抽壳历史恢复
└── 8. 二级 / 三级关系,从“局部能改”走向“关系能保持”
├── [ ] [Face 二级传播 -> 孔底/槽底/台阶联动]
@@ -332,7 +342,7 @@ Creo 资料的参考价值不只是统一术语,还要用来组织功能入口
孔/槽一级关系的主要布尔/重建入口也开始复用同一条隔离执行通道,包括孔径、圆柱尺寸缩放、孔轴心、孔封堵、盲孔/盲槽深度、槽/半孔轴心、槽宽、槽深、圆弧长度、圆弧角度、长圆槽总长度和中心距。这样即使孔/槽局部重切在复杂 STEP 上失败、超时或触发 OCCT 崩溃,主界面和原模型也应保持不变。圆柱直径整体缩放和盲孔/盲槽深度整体缩放会在模型层做结果校验;如果结果虽然是有效 B-Rep,但已经丢失目标圆柱/盲孔语义,会自动回滚并提示原因。
Edge 一级关系的主要高风险入口也接入同一条隔离执行通道,包括修改 Edge 长度、直线 Edge 起点/中心/终点移动、圆形 Edge 相邻轴心移动、椭圆 Edge 主/小半径、已有圆角半径、直线 Edge 新增圆角、对称倒角、不等距倒角和距离+角度倒角。Edge 操作仍然不是通用 CAD 历史特征编辑;复杂曲线、复杂角链、无法确认一级邻域或 OCCT 倒圆/倒角失败时会回滚或提示失败,但这些危险计算不应再拖垮主界面进程。
Edge 一级关系的主要高风险入口也接入同一条隔离执行通道,包括修改 Edge 长度、直线 Edge 起点/中心/终点移动、圆形 Edge 相邻轴心移动、椭圆 Edge 主/小半径、已有圆角半径/弧长、简单等半径圆角链半径/弧长、已有简单倒角距离、直线 Edge 新增圆角、对称倒角、不等距倒角和距离+角度倒角。Edge 操作仍然不是通用 CAD 历史特征编辑;复杂曲线、变半径圆角链、复杂角链、无法确认一级邻域或 OCCT 倒圆/倒角失败时会回滚或提示失败,但这些危险计算不应再拖垮主界面进程。
Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示底层 OCCT 异常。当前会尽量附带诊断信息:操作对象、Face 类型、目标值/目标位置、一级边界 Edge/Vertex、共享边相邻 Face、内孔/内边界数量、已识别的端盖专用路径,以及是否可能因为孔壁、槽底、台阶终点等二级/三级关系暂未传播而失败。
@@ -390,8 +400,8 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示
| Face / 面 | 面内长度、面内宽度、中心、偏移、壳体厚度;圆柱端盖或完整圆柱侧面的高度。面积作为诊断结果只读展示。 | 拉伸/切除当前平面或圆柱端盖;移动当前面顶点并重建相邻面;或平移/缩放所属特征。 | 自由曲面会明确识别为只读,不开放伪参数化修改;带复杂内孔的面、复杂壳体局部区域、非完整圆柱侧面高度。 |
| 孔 / 圆柱孔 | 规则孔的直径/半径、完整孔轴心、完整孔封堵、可识别盲孔/盲槽深度。 | 改孔径时同轴重切孔壁;移动孔时先补旧孔再切新孔;改深度时切削或补料孔底。 | 螺纹孔、锥孔、复杂孔组、底面识别不可靠的盲孔。 |
| 槽 / 半孔 | 槽宽、槽深、弧长、弧角、开口角、简单轴心、长圆槽总长度/中心距;规则矩形槽/口袋的长宽、中心和深度。 | 按圆柱槽、长圆槽胶囊区域或矩形切除包络,先补旧槽,再切出新槽。 | 复杂草图槽、非圆柱槽、多槽联动、跨复杂面的槽;矩形槽中心沿深度方向移动暂不处理。 |
| 凸台 / 外圆 | 圆柱凸台的直径/半径、高度、轴心;规则矩形凸台/口袋的长度、宽度、中心、高度/深度;多台阶矩形凸台的顶层规则台阶。 | 移除旧特征包络后重建;或拉伸/切除端盖、轴向缩放。 | 异形凸台、多台阶凸台整组联动、复杂相邻拓扑;矩形长宽方向交换和沿高度/深度方向移动中心暂不在一次编辑中处理。 |
| 圆角 / 倒角 | 部分已有圆角的半径/弧长;直线边新增圆角或倒角;相邻同半径圆角链可识别并快速阻止。 | 已有简单圆角先移除再重建;新增圆角/倒角调用 OCCT 的圆角/倒角算法;圆角链不进入危险重建。 | 复杂 blend、圆角稳定重建、变半径圆角、支撑面不明确的圆角。 |
| 凸台 / 外圆 | 圆柱凸台的直径/半径、高度、轴心;规则矩形凸台/口袋的长度、宽度、中心、高度/深度;多台阶矩形凸台的顶层规则台阶。 | 移除旧特征包络后重建;或拉伸/切除端盖、轴向缩放;多台阶中间过渡面会识别为受限并快速阻止。 | 异形凸台、多台阶凸台整组联动、复杂相邻拓扑;矩形长宽方向交换和沿高度/深度方向移动中心暂不在一次编辑中处理。 |
| 圆角 / 倒角 | 部分已有圆角的半径/弧长;简单等半径圆角链半径/弧长;直线边新增圆角或倒角;变半径/复杂圆角链可识别并快速阻止。 | 已有简单圆角和简单等半径圆角链先移除再重建;新增圆角/倒角调用 OCCT 的圆角/倒角算法;变半径圆角链和复杂 blend 不进入危险重建。 | 大链式 blend、变半径圆角稳定重建、复杂角部补面、支撑面不明确的圆角。 |
| Solid / 整体特征 | 平移、旋转、包围盒尺寸、体积、表面积整体缩放。 | 对所属 Solid / 特征做整体变换。 | 装配约束、零件间关联约束、原 CAD 建模历史恢复。 |
## 当前可修改范围
@@ -403,9 +413,9 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示
| Face | 平面 Face 可以改 `面内长度``面内宽度``中心``偏移`;这些行会在 `建模意图` 里选择 `局部重建``拉伸/切除``移动特征``缩放特征`。面积是结果变量,只在诊断信息里展示。识别到相对平面时还可以改 `壳体厚度`,并通过 `建模意图` 选择 `拉伸/切除``缩放特征`。圆柱上/下端盖、同轴空心圆柱的环形端面可以通过 `拉伸/切除` 改高度;选中完整圆柱侧面时,也可以把 `高度` 换算成端盖拉伸/切除,或者选择沿轴向 `缩放特征`。简单端盖会解析重建,复杂同轴筒体会优先只处理旧端盖到新端盖之间的局部段,端盖边界上有槽口/缺口时会按真实端盖轮廓向外拉伸或在安全范围内向内切削。 | `面内长度/面内宽度` 是这个 Face 自身平面里的两个方向尺寸,不是面积,也不是模型高度;`偏移` 是沿当前面垂直方向测到的位置,不再单独显示容易混淆的额外的“本次移动量”提示。`拉伸/切除` 会加料或切削;`局部重建` 会移动当前 Face 顶点并重建相邻平面,相邻面可能变斜;`移动特征` 会平移所属特征或 Solid`缩放特征` 会缩放所属特征或 Solid 并影响其它尺寸;自由曲面会显示为只读受限对象,不把面积、中心、包围盒或底层 UV 参数伪装成可修改尺寸;复杂壳体局部区域、非完整圆柱侧面、非同轴多孔环形端面、端盖边界本身连着槽/缺口/台阶且向内切除越过槽底/台阶终点的面和其它带复杂边界的面仍不能稳定做局部尺寸编辑,界面会禁用或快速阻止并在提示里说明具体原因,建议改用孔/槽专门入口、`拉伸/切除` 的简单端盖路径或整体调整。 |
| 圆柱孔 / 圆柱面 | 孔或圆柱的 `直径``半径`,并通过 `建模意图` 选择 `只改孔/槽壁``缩放特征`;完整圆柱孔可以改轴心坐标;完整孔可以尝试封堵。普通孔径语义是同轴圆柱重切,孔轴心语义是先填旧孔再切新孔。 | 复杂孔、螺纹孔、锥孔、不完整圆柱孔和拓扑不稳定的孔可能失败并回滚;`缩放特征` 会影响同一对象上的高度、厚度和其它尺寸,不适合只想改孔壁的场景。 |
| 盲孔 / 盲槽 | 识别到疑似底面 Face 时,可以改 `盲孔/盲槽深度`,并通过 `建模意图` 选择 `改底面深度``缩放特征``改底面深度` 加深会沿开口到底面方向切削,变浅会从新底面到旧底面补料;`缩放特征` 会沿孔/槽轴向缩放所属特征或 Solid。 | STEP 不保存真实孔深历史;找不到可靠底面或底面被复杂拓扑切碎时,深度只能只读或需要手动辅助;`缩放特征` 会影响同一对象上的壁厚、孔距和其它轴向尺寸,不适合只想改孔底位置的场景。 |
| 槽 / 半孔 | 可以改槽宽、槽深、弧长、弧角、开口角和简单局部轴心;槽宽、槽深和弧长会在 `建模意图` 里选择 `只改槽壁``缩放特征`。长圆槽的轴心、总长度和中心距会在点击修改时自动尝试配对另一个半圆端,也可以手动填写配对端 Face ID。规则矩形槽/口袋可以改长宽、中心和深度。 | 轴心仍是受限的局部扇形槽/胶囊槽重建;复杂草图槽、非圆柱槽、多槽关联迁移、链式槽和跨多个不规则面的槽还不是稳定能力。矩形槽/口袋会局部重建矩形切除包络,但长宽方向交换和中心沿深度方向移动暂不处理。`只改槽壁` 会局部重建槽,`缩放特征` 会缩放所属对象并影响其它尺寸;如果结果真的把一个 Solid 拆成多个 Solid,会自动回滚。 |
| 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径``半径``高度``轴心` 会在 `建模意图` 里选择 `只改凸台``拉伸/切除端盖``移动凸台``缩放特征``移动特征`。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建;`缩放特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 |
| 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改 `圆角半径``圆角弧长`,并通过 `建模意图` 选择 `重建圆角``缩放特征``重建圆角` 会先移除旧圆角再重建新圆角;`缩放特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。相邻同半径圆角 Face 会被标成圆角链,计划阶段直接提示暂未实现稳定重建。 | 复杂 blend、支撑面不明确、圆角稳定重建、变半径圆角或恢复锐边失败时会阻止或回滚;`缩放特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 |
| 槽 / 半孔 | 可以改槽宽、槽深、弧长、弧角、开口角和简单局部轴心;槽宽、槽深和弧长会在 `建模意图` 里选择 `只改槽壁``缩放特征`。长圆槽的轴心、总长度和中心距会在点击修改时自动尝试配对另一个半圆端,也可以手动填写配对端 Face ID。规则矩形槽/口袋可以改长宽、中心和深度。复杂/交叉槽会识别为受限,不暴露伪可改槽参数。 | 轴心仍是受限的局部扇形槽/胶囊槽重建;复杂草图槽、非圆柱槽、多槽关联迁移、链式槽和跨多个不规则面的槽还不是稳定能力。矩形槽/口袋会局部重建矩形切除包络,但长宽方向交换和中心沿深度方向移动暂不处理。`只改槽壁` 会局部重建槽,`缩放特征` 会缩放所属对象并影响其它尺寸;如果结果真的把一个 Solid 拆成多个 Solid,会自动回滚。 |
| 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心;普通完整圆柱也有高度兜底修改。凸台的 `直径``半径``高度``轴心` 会在 `建模意图` 里选择 `只改凸台``拉伸/切除端盖``移动凸台``缩放特征``移动特征`多台阶矩形凸台的顶层规则台阶可以独立修改;中间过渡面会识别为复杂多台阶凸台并禁用伪可改参数。 | 复杂凸台、异形凸台、多台阶凸台整组联动和不完整外圆不能保证稳定重建;`缩放特征` 会连带改变同一对象上的高度、厚度和其它尺寸,不能当作单独重建凸台包络或只移动端盖使用。 |
| 已有圆角 / 倒圆面 / 简单倒角面 | 部分规则圆柱倒圆面可以改 `圆角半径``圆角弧长`简单等半径圆角链可以改整链半径/弧长,并通过 `建模意图` 选择 `重建圆角``缩放特征``重建圆角` 会先移除旧圆角面或链上圆角面,再重建新圆角;`缩放特征` 会把目标值换算为圆柱直径比例,再整体缩放所属特征或 Solid。选中简单等距倒角斜面时可以改 `倒角距离`,当前同样走“移除旧斜面 -> 恢复锐边 -> 重新倒角”。相邻同半径圆角 Face 会被标成变半径圆角链,计划阶段直接提示暂未实现稳定重建。 | 大链式 blend、复杂角部补面、支撑面不明确、变半径圆角稳定重建、复杂倒角链、不等距已有倒角或恢复锐边失败时会阻止或回滚;`缩放特征` 会影响同一对象上的其它尺寸,不适合只想改圆角本身的场景。 |
| Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径、直径和 `圆心/轴心`,其中圆心移动会把圆边移动量转成相邻孔/槽/凸台的轴心移动;椭圆 Edge 可以改 `椭圆主半径``椭圆小半径`,分别沿主轴或小轴单轴缩放所属对象。 | “通用任意 Edge”仍未作为稳定能力开放;复杂曲线、B-spline、Bezier 和无法确定局部变形区域的 Edge 不会在扫描列表或参数表里显示为可改长度入口;圆Edge轴心移动需要找到稳定相邻圆柱特征;椭圆单轴缩放会影响同一对象上同方向的其它几何,不是恢复 CAD 草图约束。 |
| 圆锥 / 球 / 环面 | 圆锥可以改 `参考半径``参考直径``半角`:简单圆锥会解析重建,嵌入式锥孔/沉孔会优先局部重切;选中圆锥特征时会显示可识别的小端半径、大端半径、锥孔高度和半角,便于判断真实修改对象。球面可以改 `半径(缩放特征)``直径(缩放特征)`;环面可以改 `主半径(缩放特征)``主直径(缩放特征)``小半径(缩放特征)``小直径(缩放特征)`。 | B-Rep 里的 Face 可以是平面、圆柱面、圆锥面等;半角属于圆锥面属性,1° 左右的浅锥/拔模面看起来会很像平面。圆锥局部重切主要覆盖可识别的双圆边界锥孔;复杂圆锥/拔模面的参考半径、参考直径和半角暂不开放,会提前禁用或快速阻止,而不是进入危险整体缩放或布尔计算。解析曲面的识别评分过低、置信度低或存在识别限制时,属性会保留当前值展示,但修改按钮会变为不可用并显示原因。其它复杂球面和环面仍可能几何缩放所属特征或 Solid,不是恢复原 CAD 历史参数,复杂相邻拓扑可能失败或回滚。 |
| Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 |
@@ -447,7 +457,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示
- 圆柱面会识别为圆柱孔候选、槽/半孔候选、圆角/倒圆候选、凸台/外圆候选或未明确圆柱特征。
- 圆柱特征会先把同一实体内同轴、同半径且轴向连续/重叠的完整圆柱Face当作同一侧壁区域,再尝试找出边界Edge、相邻Face、端面Face、开口端相邻Face和疑似底面 Face,并一起高亮相关局部区域。
- 槽/半孔候选会额外显示槽宽、圆弧长度、槽深估算和槽边界相邻Face。
- 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑Face;如果直接相邻同半径圆角 Face 形成圆角链,会标出链内 Face 并在修改半径/弧长快速阻止。
- 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑Face;如果共享边连通的同半径圆角 Face 形成 2 到 4 个面的简单小链,会标出链内 Face 并尝试整链半径/弧长重建;变半径或复杂长链会快速阻止。
- `特征探测级别` 切到 `探测相邻特征``探测二级特征` 后,关联孔、槽、凸台等可改尺寸可以作为 `关联 Face` 参数行合并到同一张参数表里;`关联探测` 本身属于诊断信息,不占用特征参数表。
- 当前选中对象表使用四列表格:`尺寸参数``当前值``建模意图``目标值`。表格只放可作为建模输入的尺寸,面积、识别摘要、一级关系、关联探测和其它只读诊断放在下方 `诊断信息(高级)` 中;用户改完目标值后统一点击 `参数化建模`,不再保留逐行执行入口。
- 属性面板会统一显示 `识别摘要``识别候选``识别置信度``识别风险``识别评分``识别结论``识别依据``识别限制``受限能力`。这些字段来自当前已知的 B-Rep 几何事实、一级拓扑关系、同域区域、材料采样和阻止原因,用来说明程序为什么把对象当作某类候选,而不是把猜测隐藏在代码里。`识别限制` 表示这个候选整体不能稳定编辑;`受限能力` 表示对象本身可识别、某些修改可用,但另一些子能力暂不开放。低置信解析曲面不会开放可编辑按钮,避免把“底层曲面类型像圆锥/球/环面”误当成稳定 CAD 设计参数。
@@ -572,13 +582,14 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示
- 这是局部 B-Rep 平面拉伸/切除近似,不是 CAD 壳命令或完整壳体参数编辑。
- 当前选中对象表的 `壳体厚度` 会通过 `建模意图` 选择 `拉伸/切除``缩放特征`;后者会沿厚度方向缩放所属特征或 Solid,让厚度接近目标值,同一对象上的其它厚度方向尺寸也会跟随变化。
- 对简单全平面薄板/盒体,`缩放特征` 会优先用平面重建路线,避免把原本的平面 Face 变成 B-spline 曲面,并保持原选中 Face 的逻辑 ID 继续指向修改后的面。
- 对开口薄壁盒、槽腔这类上下文,程序会把 `完整抽壳/开口面编辑` 显示为受限能力;局部壁厚仍可尝试修改,但不会伪装成 CAD 的完整抽壳历史或开口面特征级重建。
- 当前版本支持已有圆角/倒圆候选结构化识别:
- 对局部小半径圆柱面,会标记为 `round/fillet candidate`
- 会估算已有圆角半径、圆弧角度和圆弧长度。
- 会识别已有圆角Face和相邻支撑Face,并在特征模式下一起高亮。
- `可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径`,点击后会选中该圆角 Face,并把圆角半径输入框预填为当前估算半径的参考目标值。
- `可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径/弧长``修改已有圆角链半径/弧长`,点击后会选中该圆角 Face,并把圆角半径/圆角弧长输入框预填为当前估算的参考目标值。
- 选中已有圆角候选时,不会再误把它填入 `孔直径`,避免把圆角当孔径修改。
- 当前版本的已有圆角半径修改会先尝试移除圆角Face,再在恢复出的锐边上重新倒圆;也可以在当前选中对象表中输入已有圆角圆弧长度,程序会按当前圆弧角度换算成目标半径。只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。
- 当前版本的已有圆角半径修改会先尝试移除圆角Face或简单等半径圆角链,再在恢复出的锐边上重新倒圆;也可以在当前选中对象表中输入已有圆角圆弧长度,程序会按当前圆弧角度换算成目标半径。只对较规整的圆柱圆角候选和 2 到 4 个共享边连通面的简单等半径链开放,复杂 blend 或更长圆角链会提前阻止或回滚。
- 当前选中对象表的 `圆角半径` / `圆角弧长` 会通过 `建模意图` 选择 `重建圆角``缩放特征`。后者不会移除并重建圆角,而是按目标半径或弧长比例整体缩放所属特征或 Solid,同一对象上的其它尺寸也会变化。
- 当前版本支持圆柱孔封堵:
- 只对 `hole/groove candidate` 且角度跨度接近完整圆柱的孔开放。
@@ -719,8 +730,8 @@ vertices: 3262
- 复杂特征识别:已经有平面、圆柱、边、孔/槽候选、槽/半孔候选、已有圆角候选、圆柱凸台候选等基础候选列表,但还不是完整 CAD 语义级识别。
- 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面 Face的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。
- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度/深度/圆弧长度/圆弧角度/开口角度调整、槽宽/槽深/弧长的整体缩放所属对象语义、长圆槽总长度/中心距执行时自动配对和手动配对端 Face ID、完整圆柱凸台直径/半径/高度/轴心坐标修改、规则矩形凸台/口袋长度/宽度/中心/高度/深度局部重建、多台阶矩形凸台顶层规则台阶独立编辑、普通完整圆柱高度兜底拉伸/切除或通过 `建模意图` 做轴向整体缩放,以及平面壳体相对面厚度估算、局部厚度拉伸/切除和通过 `建模意图` 做厚度方向整体缩放;复杂槽、复杂凸台整体联动和完整壳体区域仍需要继续做。
- 已有圆角/倒圆面半径:已经有当前版本的受限修改实现,使用 defeature + refillet 路线;直接相连的同半径圆角链会在识别层标成受限能力,不作为可编辑圆角项列出,选中后也会在计划阶段说明暂未实现稳定重建;复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。
- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度/深度/圆弧长度/圆弧角度/开口角度调整、槽宽/槽深/弧长的整体缩放所属对象语义、长圆槽总长度/中心距执行时自动配对和手动配对端 Face ID、完整圆柱凸台直径/半径/高度/轴心坐标修改、规则矩形凸台/口袋长度/宽度/中心/高度/深度局部重建、多台阶矩形凸台顶层规则台阶独立编辑、多台阶中间过渡面识别和快速阻止、普通完整圆柱高度兜底拉伸/切除或通过 `建模意图` 做轴向整体缩放,以及平面壳体相对面厚度估算、局部厚度拉伸/切除和通过 `建模意图` 做厚度方向整体缩放;复杂槽、复杂凸台整体联动和完整壳体区域仍需要继续做。
- 已有圆角/倒圆面半径/弧长:已经有当前版本的受限修改实现,使用 defeature + refillet 路线;2 到 4 个共享边连通面的简单等半径圆角链会作为高风险候选开放整链半径/弧长修改,变半径圆角链和更长复杂链会在识别层标成受限能力并在计划阶段说明暂未实现稳定重建;复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。
- 局部偏差分析:已经有编辑前后体积、面积、包围盒、距离热力图和差异报告,但还不是工程公差级的局部区域偏差报告。
- 错误恢复和模型修复:已经有编辑前快照、失败回滚、基础 B-Rep 校验、同域面合并、布尔结果自动修复,以及一键修复当前模型或选中零件/solid,但还不是完整的模型修复流水线。
- 多零件装配:已经有装配/零件树和单独导出入口,但还没有做大规模、多来源 STEP 装配测试。
@@ -752,7 +763,7 @@ Face 阶段的当前验收口径:
- `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` 必须通过,集中覆盖新增圆角/倒角、已有圆角重建、结果反查、失败回滚和一级邻域守门。
- `python scripts\verify_round_chamfer_edit_suite.py` 必须通过,集中覆盖新增圆角/倒角、已有圆角重建、简单等半径圆角链重建、四面连通链重建、结果反查、失败回滚和一级邻域守门。
- `python scripts\verify_shell_edit_suite.py` 必须通过,集中覆盖壳体厚度的局部拉伸/切除、缩放特征语义、Face 一级关系计划字段和结果校验。
- 对不能稳定做 `局部重建` 的 Face,要禁用该建模意图,并在提示中说明原因;不能让复杂内孔、曲面 Solid 或复杂边界静悄悄走高风险局部变形。
- Face 一级关系闭环后,当前优先把槽/孔一级关系阶段补成独立闭环;槽/孔的一级邻域、局部重建和隔离执行稳定后,再继续推进 Edge 与其它特征阶段。
@@ -836,7 +847,7 @@ Face 阶段的当前验收口径:
- `调整盲孔/盲槽深度` 行表示这个Face是较明确的盲孔/盲槽候选,可以在当前选中对象表里修改盲孔/盲槽深度。
- `给Edge添加圆角` 行表示这个对象是直线Edge,可以配合 `圆角半径``给Edge添加圆角` 使用。
- `给Edge添加倒角` 行表示这个对象是直线Edge,可以配合 `倒角距离``给Edge添加倒角` 使用。
- `修改已有圆角半径` 行表示这个Face是已有圆角/倒圆候选,点击后会切到 `特征` 模式并预填圆角半径参考值;真正执行需要再点击编辑区里的 `修改已有圆角半径` 按钮并确认。
- `修改已有圆角半径/弧长` / `修改已有圆角链半径/弧长` 行表示这个Face是已有圆角/倒圆候选或简单等半径圆角链候选,点击后会切到 `特征` 模式并预填圆角半径/圆角弧长参考值;真正执行需要在特征参数表里改目标值,再点击 `参数化建模` 并确认。
- `状态``ready` 表示比较适合尝试,`caution` 表示可以尝试但风险更高,`blocked` 表示当前参数或对象不适合执行。
- `风险` 越高越应该谨慎,尤其是圆角、凸柱或未明确圆柱面,不要直接当孔修改。
- 点击任意一行会自动选中并高亮对应Face或Edge。
@@ -1046,8 +1057,8 @@ Face 阶段的当前验收口径:
- 在当前选中对象表里修改 `圆角半径``圆角弧长`,并把 `建模意图` 设为 `重建圆角`
- 输入目标值后点击 `参数化建模`
- 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。
- 通过检查后,会显示蓝色半透明预览,表示将尝试移除并重建的已有圆角面。
- 真实编辑会先用 OCCT defeaturing 移除已有圆角面,再从恢复出的候选锐边中寻找可重新倒圆的边并调用 OCCT 倒圆 API。
- 通过检查后,会显示蓝色半透明预览,表示将尝试移除并重建的已有圆角面;简单等半径圆角链会把链上圆角面一起纳入重建计划
- 真实编辑会先用 OCCT defeaturing 移除已有圆角面或链上圆角面,再从恢复出的候选锐边中寻找可重新倒圆的边并调用 OCCT 倒圆 API。
- 这是 B-Rep 几何重建,不是 CAD 历史圆角参数编辑;复杂 blend、支撑面不明确或恢复锐边失败时会失败并回滚。
- `圆角半径` / `圆角弧长``缩放特征`
- 先选择一个已有圆角/倒圆候选 Face。
@@ -1170,7 +1181,7 @@ 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 区域一起拉伸/切除
@@ -1181,8 +1192,8 @@ pythonocc-step-editor/
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 的局部/整体中心、面内尺寸、偏移和拉伸/切除
verify_face_mixed_surface_guard.py # 验证圆柱端盖这类平面 Face 会禁用局部变形,但保留拉伸/切除;保持关系会因非平面一级邻域阻止
verify_face_nonrectangular_local_edit.py # 验证三角平面 Face 的局部/整体中心、面内尺寸、偏移和拉伸/切除;斜交一级邻域会阻止保持关系
verify_face_noop_guards.py # 验证 Face 目标值不变时会被阻止
verify_first_level_fact_graph.py # 验证平面、孔、槽会输出统一一级拓扑事实包,且编辑计划会携带这份事实
verify_feature_recognition_summary.py # 验证平面、孔、槽、凸台候选会输出统一识别摘要、常用优先级、置信度、风险和证据
@@ -1316,21 +1327,21 @@ git diff --check
- `scripts/verify_boss_resize.py`:临时生成底板加圆柱凸台 STEP,验证凸台直径扩大、直径缩小、高度修改和轴心移动的局部重建链路,并断言不会把单 Solid 拆成多个 Solid。
- `scripts/verify_boss_first_level_topology.py`:临时生成底板加圆柱凸台 STEP,验证凸台直径、高度、轴心和圆 Edge 代理轴心计划都会暴露一级共边拓扑关系,并验证凸台直径执行后会反查目标 Face 和一级关系。
- `scripts/verify_boss_edit_suite.py`:集中运行当前凸台阶段验证,串起凸台一级拓扑、凸台直径/高度/轴心局部重建、圆 Edge 代理轴心移动和属性表分组。
- `scripts/verify_round_chamfer_edit_suite.py`:集中运行圆角/倒角阶段验证,串起直线 Edge 新增圆角、对称倒角、不等距倒角、距离+角度倒角、已有圆角半径重建、结果反查和属性表分组。
- `scripts/verify_edge_edit_suite.py`:集中运行当前 Edge 阶段验证,覆盖 Edge 一级拓扑事实包、直线 Edge 长度的自动策略、只改当前 Edge、移动端面/保持垂直、缩放所属对象,以及固定起点、固定终点、固定中心三类锚点;同时串起 Edge 建模意图 UI 映射、直线 Edge 起点/中心/终点坐标修改、Edge 圆角/倒角/已有圆角修改椭圆 Edge 主/小半径修改。
- `scripts/verify_round_chamfer_edit_suite.py`:集中运行圆角/倒角阶段验证,串起直线 Edge 新增圆角、对称倒角、不等距倒角、距离+角度倒角、已有圆角半径/弧长重建、简单等半径圆角链半径/弧长重建、四面连通圆角链重建、复杂同半径长链和变半径链守门、已有简单倒角距离重建、结果反查和属性表分组。
- `scripts/verify_edge_edit_suite.py`:集中运行当前 Edge 阶段验证,覆盖 Edge 一级拓扑事实包、直线 Edge 长度的自动策略、只改当前 Edge、移动端面/保持垂直、缩放所属对象,以及固定起点、固定终点、固定中心三类锚点;同时串起 Edge 建模意图 UI 映射、直线 Edge 起点/中心/终点坐标修改、Edge 圆角/倒角/已有圆角和简单等半径圆角链修改椭圆 Edge 主/小半径修改。
- `scripts/verify_edge_first_level_topology.py`:加载 `cube_10mm.step`,验证一条正方体 Edge 的一级关系包含当前 Edge、2 个端点 Vertex、4 条共享端点相邻 Edge 和 2 个直接相邻 Face,并确认边长、端点/中心坐标、圆角和倒角计划都会携带这份一级事实包。
- `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_isolated_edit.py`:通过 `python -m step_editor.isolated_edit_worker` 的真实 worker 入口验证 Edge 长度、直线 Edge 起点/中心移动、圆形 Edge 相邻轴心移动、椭圆 Edge 主半径、直线 Edge 新增圆角、三类倒角已有圆角半径修改可以在隔离执行通道里完成,输出 STEP 能重新加载并保持目标几何语义。
- `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 一级共享边拓扑、面内长度/面内宽度、矩形 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 会报告边界环数量,并禁用 `局部重建` 的面内长度/面内宽度、中心和偏移计划;同时确认 `拉伸/切除` 的向外补料和向内切削都仍可用并保持单 Solid,且同一 Solid 的局部变形可用性判断会复用缓存。
- `scripts/verify_face_mixed_surface_guard.py`:临时生成简单圆柱,验证圆柱端盖这个平面 Face 会因为所属 Solid 含有曲面而禁用 `局部重建` 的中心和偏移计划,属性提示会说明具体原因;同时确认 `拉伸/切除` 仍可用并让圆柱高度增加。
- `scripts/verify_face_mixed_surface_guard.py`:临时生成简单圆柱,验证圆柱端盖这个平面 Face 会因为所属 Solid 含有曲面而禁用 `局部重建` 的中心和偏移计划,属性提示会说明具体原因;同时确认 `拉伸/切除` 仍可用并让圆柱高度增加`保持关系` 会因一级相邻含非平面/曲面而在 UI 和计划阶段阻止
- `scripts/verify_face_freeform_guard.py`:临时生成 B-spline 自由曲面 STEP,验证它会被识别为只读受限 Face,识别风险为 blocked;属性表只显示限制说明和只读诊断,不暴露面积、中心、偏移、半径、半角等伪参数化编辑入口。
- `scripts/verify_face_invalid_target_guards.py`:验证 Face 面内尺寸和壳体厚度的局部/整体计划在目标值为非数字、0 或负数时都会返回 blocked,并提示目标值必须是有效正数;历史面积路径只作为后端守门回归保留。
@@ -1338,10 +1349,10 @@ git diff --check
- `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 的 `局部重建``缩放特征``移动特征``拉伸/切除` 语义,在中心、面内尺寸和偏移场景下都能保持单 Solid。
- `scripts/verify_face_resize_semantics.py`:加载 `cube_10mm.step`,验证 Face 的面内长度/面内宽度、中心坐标和偏移在 `当前面` 局部编辑、`整体` 所属对象编辑`拉伸/切除` 之间不会互相混淆;面积路径只作为后端缩放语义回归保留。
- `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_first_level_fact_graph.py`:验证正方体平面 Face、拆分共面 Face、空心圆柱端盖、通孔圆柱面和槽/半孔候选都会输出统一一级拓扑事实包,包含主体 Face、同域/共面碎片、一级同轴圆柱关系、边界 Edge/Vertex、共享边一级相邻 Face、角色分组,以及明确不自动传播的二级/三级关系;同时验证 Face 拉伸/切除/面积/中心移动、孔径修改和槽宽修改的编辑计划都会携带这份事实。
- `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;向外一次性拉伸/切除较大时会标为高风险并通过隔离子进程执行,而不是按绝对距离直接拒绝。
@@ -1357,7 +1368,7 @@ git diff --check
- `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_shell_thickness_resize.py`:临时生成薄板 STEP,验证壳体厚度局部拉伸/切除和整体调整两种语义,目标厚度可以大于或小于当前厚度;同时确认修改后原逻辑 Face ID 仍指向被编辑后的平面 Face,简单全平面薄板不会被整体调整变成 B-spline 曲面。脚本还会生成开口薄壁盒,验证完整抽壳/开口面编辑会被列为受限能力,而局部壳体厚度仍保留可用计划。
- `scripts/verify_slot_resize.py`:临时生成半圆槽和长圆槽 STEP,验证槽宽、槽深、弧长、弧角、半圆槽局部轴心、长圆槽整槽轴心、槽孔总长度和两端中心距的局部重建链路。
- `scripts/verify_cylindrical_first_level_topology.py`:临时生成通孔、盲孔、半圆槽和长圆槽 STEP,验证孔/槽类圆柱特征的修改计划会暴露一级共边拓扑关系,包括圆柱侧壁、边界 Edge、边界 Vertex、直接相邻 Face、底面/开口面/槽边界面,并明确二级、三级关系暂不自动传播。
@@ -1388,14 +1399,14 @@ git diff --check
- 模型展示区域左下角会显示 CAD 风格的 view cube + 彩色 `XYZ` 方向标;主视图前景层会在 STEP 文件真实全局原点 `(0,0,0)` 显示一组 `XYZ` 坐标基准,带原点球、粗箭头、负方向短轴和 3D 标签,且不参与拾取或初始相机适配,避免打开模型时被坐标轴拉远缩小。
- 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮;左键拖动旋转不会触发选择;悬停和普通点选只做轻量拾取,不触发完整特征识别。
- 同域面:同域合并仍用于编辑计划、扫描候选、历史定位和必要的模型修复;普通交互不再实时扫描整片同域区域。
- 编辑:偏移、面内长度/面内宽度、中心、孔径/半径、孔/槽轴心、槽宽、槽深、弧长、弧角、开口角、总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心、普通完整圆柱高度、已有圆角半径/圆弧长度、圆锥参考半径/直径/半角、球面半径/直径(缩放特征)、环面主/小半径或直径(缩放特征)、Edge圆角、Edge倒角、圆Edge半径/直径、椭圆Edge主半径/小半径、壳体厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。Face 面积作为诊断结果只读展示。
- 编辑:偏移、面内长度/面内宽度、中心、孔径/半径、孔/槽轴心、槽宽、槽深、弧长、弧角、开口角、总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心、普通完整圆柱高度、已有圆角半径/圆弧长度、简单等半径圆角链半径/圆弧长度、已有等距倒角距离、圆锥参考半径/直径/半角、球面半径/直径(缩放特征)、环面主/小半径或直径(缩放特征)、Edge圆角、Edge倒角、圆Edge半径/直径、椭圆Edge主半径/小半径、壳体厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。Face 面积作为诊断结果只读展示。
- 编辑语义:Face 的偏移、面内长度/面内宽度和中心通过 `建模意图` 选择“拉伸/切除”“局部重建”“移动特征”或“缩放特征”;面积不作为特征参数表里的驱动变量。圆锥 Face 会优先区分简单圆锥解析重建、嵌入式锥孔局部重切和复杂兜底整体缩放;孔/槽、凸台、盲孔/盲槽、壳体、圆角和 Edge 长度也会通过 `建模意图` 或计划策略说明本次修改会局部重建、拉伸/切除、移动还是整体缩放。
- 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。
- 导出:完整模型、零件、Solid、Face、特征区域和 Edge,导出前做基础质量检查。
### 编辑语义
所有几何参数修改都必须先明确语义、建模意图、执行影响和风险。修改 Face、孔、槽、圆角、凸台、Solid 或 Edge 时,都不能只看“目标值能不能算出来”;要区分 Face 拉伸/切除、切削补料、特征重建、局部形变、端面移动、对象平移、整体缩放等不同策略,并在确认窗口、属性提示或操作历史里尽量说明本次采用的策略。Edge 的 `长度` 行已经通过 `建模意图` 提供显式改法选择;Face 当前把 `偏移``面内长度``面内宽度``中心` 显示为单行属性,并通过 `建模意图` 下拉框表达“拉伸/切除”“局部重建”“移动特征”或“缩放特征”;面积只作为诊断结果展示。孔/槽直径和半径也通过 `建模意图` 区分“局部重切/重建孔壁”和“缩放所属对象”;凸台直径、半径、高度和轴心也通过 `建模意图` 区分“局部重建/端盖拉伸/切除/移动凸台”和“整体缩放/移动所属对象”;普通完整圆柱的直径、半径和高度也通过 `建模意图` 区分“兜底重切/端盖拉伸/切除”和“整体缩放所属对象”;槽宽、槽深和弧长也通过 `建模意图` 区分“局部重建槽”和“缩放所属对象”;盲孔/盲槽深度也通过 `建模意图` 区分“局部切削/补料”和“轴向缩放所属对象”;壳体厚度也通过 `建模意图` 区分“局部拉伸/切除当前壳体平面”和“沿厚度方向缩放所属对象”;已有圆角半径/弧长也通过 `建模意图` 区分“移除并重建圆角”和“整体缩放所属对象”;孔、槽的轴心坐标已经开始区分“移动/重建局部特征”和“平移所属对象”;孔、槽、凸台、盲孔/盲槽深度、壳体厚度现在会显示并记录当前采用的建模意图,后续还要继续把更多多语义场景做成用户可选策略。
所有几何参数修改都必须先明确语义、建模意图、执行影响和风险。修改 Face、孔、槽、圆角、凸台、Solid 或 Edge 时,都不能只看“目标值能不能算出来”;要区分 Face 拉伸/切除、切削补料、特征重建、局部形变、端面移动、对象平移、整体缩放等不同策略,并在确认窗口、属性提示或操作历史里尽量说明本次采用的策略。Edge 的 `长度` 行已经通过 `建模意图` 提供显式改法选择;Face 当前把 `偏移``面内长度``面内宽度``中心` 显示为单行属性,并通过 `建模意图` 下拉框表达“拉伸/切除”“局部重建”“移动特征”或“缩放特征”;面积只作为诊断结果展示。孔/槽直径和半径也通过 `建模意图` 区分“局部重切/重建孔壁”和“缩放所属对象”;凸台直径、半径、高度和轴心也通过 `建模意图` 区分“局部重建/端盖拉伸/切除/移动凸台”和“整体缩放/移动所属对象”;普通完整圆柱的直径、半径和高度也通过 `建模意图` 区分“兜底重切/端盖拉伸/切除”和“整体缩放所属对象”;槽宽、槽深和弧长也通过 `建模意图` 区分“局部重建槽”和“缩放所属对象”;盲孔/盲槽深度也通过 `建模意图` 区分“局部切削/补料”和“轴向缩放所属对象”;壳体厚度也通过 `建模意图` 区分“局部拉伸/切除当前壳体平面”和“沿厚度方向缩放所属对象”;已有圆角半径/弧长和简单等半径圆角链半径/弧长也通过 `建模意图` 区分“移除并重建圆角”和“整体缩放所属对象”;孔、槽的轴心坐标已经开始区分“移动/重建局部特征”和“平移所属对象”;孔、槽、凸台、盲孔/盲槽深度、壳体厚度现在会显示并记录当前采用的建模意图,后续还要继续把更多多语义场景做成用户可选策略。
必须记住:用户不是只要“数值改掉”,而是要选择建模意图。正方体改一条 Edge 只是例子,实际要求覆盖所有对象:用户应该能选择只改当前边、移动端面/保持垂直、固定某些边/点、让哪些相邻几何跟随变化,或整体缩放/重建所属对象。Face、孔、槽、凸台和圆角也要按同样思路设计。当多种结果都合理时,不要让程序暗自决定;要把“改法、约束、影响对象、跟随关系”做成当前选中对象里的策略行、下拉框或清晰按钮,并用普通用户能理解的话解释。
@@ -1423,7 +1434,7 @@ Face 局部移动基线验证:在 `assets/models/cube_10mm.step` 上选上表
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`,正向执行加料,负向执行切削`保持关系` 会走受限的 `push-pull-planar-face-keep-relations`,要求一级相邻平面关系在执行前可验证、执行后可反查。四条路径都会验证目标平面中心位置,但结果形态不同,不能混为一个“偏移”操作。
Face 面积后端基线验证:面积是结果变量,不再显示为特征参数表里的驱动行;后端仍保留 `local-face-area-only-deform` 等历史回归,用来确认旧路径不会破坏 B-Rep、逻辑 ID 和结果校验。
@@ -1441,9 +1452,9 @@ 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`、已有圆角弧长 `2.35619`复杂圆角链计划阶段阻止。已有简单圆角修改走 defeature + refillet;圆角弧长会按当前圆弧角换算成目标半径;普通 90° 圆角恢复出的锐边距离圆角轴线约 `sqrt(2) * R`,候选搜索半径需要覆盖这个距离;圆角链暂不尝试重建,避免进入高风险 blend 计算。
Edge 圆角/倒角基线验证:`scripts/verify_edge_round_chamfer.py` 会临时生成普通盒子、已有圆角盒子、已有倒角盒子和圆角链盒子。当前验证通过十二条路线:直线 Edge 新增圆角半径 `1`、对称倒角距离 `1`、不等距倒角 `0.8 / 1.2`、距离+角度倒角 `1 / 45°`、已有圆角半径 `1 -> 1.5`、已有圆角弧长 `2.35619`已有等距倒角距离 `1.5 -> 2.0`、简单等半径圆角链半径 `1 -> 1.5`、简单等半径圆角链弧长 `2.35619`、四面连通同半径圆角链半径 `1 -> 1.5`、五面同半径复杂长链识别/计划阶段阻止、变半径圆角链识别/计划阶段阻止。已有简单圆角和简单等半径圆角链修改走 defeature + refillet已有简单倒角修改走 defeature + rechamfer圆角弧长会按当前圆弧角换算成目标半径;普通 90° 圆角恢复出的锐边距离圆角轴线约 `sqrt(2) * R`,候选搜索半径需要覆盖这个距离;复杂同半径长链、变半径圆角链、大链式 blend 和复杂角部补面暂不尝试重建,避免进入高风险 blend 计算。
Edge 隔离执行基线验证:`scripts/verify_edge_isolated_edit.py` 会通过真实 isolated worker 请求覆盖 Edge 长度、端点/中心移动、圆形 Edge 相邻轴心移动、椭圆 Edge 主半径、Edge 圆角、三类倒角已有圆角半径修改。这个脚本的目标不是替代内存执行套件,而是确认 UI 交给 worker 的操作名和参数能在子进程里独立完成,避免危险 OCCT 倒圆、倒角或局部重建卡死主程序。
Edge 隔离执行基线验证:`scripts/verify_edge_isolated_edit.py` 会通过真实 isolated worker 请求覆盖 Edge 长度、端点/中心移动、圆形 Edge 相邻轴心移动、椭圆 Edge 主半径、Edge 圆角、三类倒角已有圆角半径、简单等半径圆角链半径和已有简单倒角距离修改。这个脚本的目标不是替代内存执行套件,而是确认 UI 交给 worker 的操作名和参数能在子进程里独立完成,避免危险 OCCT 倒圆、倒角或局部重建卡死主程序。
壳体厚度基线验证:`scripts/verify_shell_thickness_resize.py` 会临时生成 `30 x 20 x 2` 的薄板。当前验证通过局部和整体两种路线,并在 Face 专项套件里同时覆盖厚度 `2 -> 3``2 -> 1``拉伸/切除` 会保持相对面不动,`缩放特征` 会保持厚度中心基本不动。
@@ -1473,7 +1484,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`;其中 `verify_prismatic_feature.py` 会同时覆盖矩形凸台/口袋长度/宽度、中心、高度/深度多台阶矩形凸台顶层台阶编辑。
- 新增验证脚本:`scripts/verify_feature_parameter_policy.py``scripts/verify_prismatic_feature.py``scripts/verify_associated_features.py`;其中 `verify_prismatic_feature.py` 会同时覆盖矩形凸台/口袋长度/宽度、中心、高度/深度多台阶矩形凸台顶层台阶编辑和中间过渡面快速阻止
+4
View File
@@ -42,6 +42,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"Edge length move end plane, fixed start point",
("verify_edge_length_resize.py", "--strategy", "move-edge-end-plane-by-push-pull", "--anchor", "keep-start"),
),
(
"Edge length keep first-level planar relations, fixed start point",
("verify_edge_length_resize.py", "--strategy", "keep-first-level-planar-relations", "--anchor", "keep-start"),
),
(
"Edge length move end plane, fixed end point",
("verify_edge_length_resize.py", "--strategy", "move-edge-end-plane-by-push-pull", "--anchor", "keep-end"),
@@ -72,6 +72,18 @@ def _assert_edge_topology(model: StepModel, edge_id: int) -> dict[str, object]:
int(topology.get("first_level_edge_count", 0) or 0) == 5,
f"cube Edge first-level Edge set should contain selected Edge + 4 neighbors: {topology}",
)
_assert(
topology.get("first_level_planar_relation_status") == "ready",
f"cube Edge should expose first-level planar relation facts: {topology}",
)
_assert(
int(topology.get("first_level_planar_relation_parallel_count", 0) or 0) >= 2,
f"cube Edge should have two incident Faces parallel to the Edge direction: {topology}",
)
_assert(
int(topology.get("first_level_planar_relation_perpendicular_count", 0) or 0) >= 1,
f"cube Edge incident Faces should be perpendicular to each other: {topology}",
)
ignored = tuple(topology.get("topology_ignored_relation_depths", ()) or ())
_assert("second-level" in ignored and "third-level" in ignored, f"missing ignored depths: {topology}")
return topology
@@ -93,6 +105,16 @@ def _assert_edge_facts(facts: dict[str, object], label: str) -> None:
_assert(int(facts.get("first_level_fact_adjacent_face_count", 0) or 0) == 2, f"{label}: bad adjacent Faces")
_assert(int(facts.get("first_level_fact_included_edge_count", 0) or 0) == 5, f"{label}: bad included Edges")
_assert(str(facts.get("first_level_fact_summary") or ""), f"{label}: missing summary")
_assert(facts.get("first_level_planar_relation_status") == "ready", f"{label}: missing planar relation facts")
_assert(
int(facts.get("first_level_planar_relation_parallel_count", 0) or 0) >= 2,
f"{label}: expected incident Faces parallel to selected Edge direction",
)
_assert(
int(facts.get("first_level_planar_relation_perpendicular_count", 0) or 0) >= 1,
f"{label}: expected perpendicular incident Face relation",
)
_assert("一级平面关系" in str(facts.get("first_level_fact_summary") or ""), f"{label}: missing relation summary")
def _assert_plan_facts(plan: dict[str, object], label: str) -> None:
@@ -111,6 +133,9 @@ def main() -> int:
"edge_first_level_topology",
"first_level_vertex_count",
"first_level_adjacent_edge_count",
"first_level_planar_relation_summary",
"first_level_planar_relation_parallel_count",
"first_level_planar_relation_perpendicular_count",
):
_assert(key in INFO_LABELS, f"{key} should have a user-facing label")
+90
View File
@@ -58,6 +58,7 @@ def _strategy_label(strategy: str) -> str:
"auto": "自动选择",
"local-edge-only-deform": "只改当前Edge",
"move-edge-end-plane-by-push-pull": "移动端面/保持垂直",
"keep-first-level-planar-relations": "保持一级平面关系",
"resize-adjacent-cylinder-from-circular-edge-length": "相邻圆柱直径",
"scale-owning-shape-from-edge": "缩放所属",
}.get(strategy, strategy)
@@ -133,6 +134,7 @@ def _verify_line_edge_intent_specs() -> None:
_assert_edge_length_scope(length_spec, "auto", "自动", enabled=True)
_assert_edge_length_scope(length_spec, "local-edge-only-deform", "只改当前Edge", enabled=True)
_assert_edge_length_scope(length_spec, "move-edge-end-plane-by-push-pull", "移动端面", enabled=True)
_assert_edge_length_scope(length_spec, "keep-first-level-planar-relations", "保持关系", enabled=True)
_assert_edge_length_scope(
length_spec,
"resize-adjacent-cylinder-from-circular-edge-length",
@@ -140,6 +142,12 @@ def _verify_line_edge_intent_specs() -> None:
enabled=False,
)
_assert_edge_length_scope(length_spec, "scale-owning-shape-from-edge", "缩放所属", enabled=True)
constrained_specs = _specs_for_edge(info, strategy="keep-first-level-planar-relations", anchor="keep-start")
constrained_length_spec = _spec(constrained_specs, "length")
_assert(
str(constrained_length_spec.get("scope_default")) == "keep-first-level-planar-relations",
"line Edge should honor the explicit keep-planar-relations modeling intent",
)
anchor_spec = _spec(specs, "edge_length_anchor_mode")
_assert(str(anchor_spec.get("label")) == "长度基准", "line Edge should expose a length anchor row")
print("line Edge modeling-intent specs ok")
@@ -171,6 +179,7 @@ def _verify_circle_edge_intent_specs() -> None:
)
_assert_edge_length_scope(length_spec, "local-edge-only-deform", "只改当前Edge", enabled=False)
_assert_edge_length_scope(length_spec, "move-edge-end-plane-by-push-pull", "移动端面", enabled=False)
_assert_edge_length_scope(length_spec, "keep-first-level-planar-relations", "保持关系", enabled=False)
_assert_edge_length_scope(
length_spec,
"resize-adjacent-cylinder-from-circular-edge-length",
@@ -195,6 +204,7 @@ def _verify_edge_length_plan_intents() -> None:
expectations = (
("local-edge-only-deform", "keep-start", "local-edge-only-deform", "相邻面会自然变斜"),
("move-edge-end-plane-by-push-pull", "keep-start", "move-edge-end-plane-by-push-pull", "正方体这类模型会更像变成长方体"),
("keep-first-level-planar-relations", "keep-start", "move-edge-end-plane-by-push-pull", "保持一级平面关系"),
("scale-owning-shape-from-edge", "center", "scale-owning-shape-from-edge", "其它尺寸会跟随变化"),
)
for strategy, anchor, expected_strategy, impact_fragment in expectations:
@@ -206,6 +216,28 @@ def _verify_edge_length_plan_intents() -> None:
)
impact = str(plan.get("edge_length_impact_summary") or "")
_assert(impact_fragment in impact, f"{strategy} impact summary should explain result: {impact}")
if expected_strategy == "move-edge-end-plane-by-push-pull":
_assert(
plan.get("edge_length_planar_constraint_status") == "ready",
f"end-face push/pull should expose ready planar constraints: {plan}",
)
_assert(
"端面推拉平面约束" in str(plan.get("edge_length_planar_constraint_summary") or ""),
f"end-face push/pull should explain its planar constraint guard: {plan}",
)
_assert(
int(plan.get("edge_length_planar_constraint_perpendicular_count", 0) or 0) >= 1,
f"end-face push/pull should detect perpendicular side-face relations: {plan}",
)
if strategy == "keep-first-level-planar-relations":
_assert(
plan.get("edge_length_planar_relation_constraint_requested") is True,
f"explicit keep-planar-relations strategy should be preserved in the plan: {plan}",
)
_assert(
str(plan.get("edge_length_strategy_label")) == "保持一级平面关系",
f"explicit keep-planar-relations strategy should keep a user-facing label: {plan}",
)
blocked = model.general_edge_length_plan(
edge_id,
15.0,
@@ -214,13 +246,71 @@ def _verify_edge_length_plan_intents() -> None:
)
_assert(str(blocked.get("status")) == "blocked", "moving an end plane while fixing center should be blocked")
_assert("不能使用固定中心" in str(blocked.get("message") or ""), f"blocked message should explain center conflict: {blocked}")
blocked_keep_relations = model.general_edge_length_plan(
edge_id,
15.0,
anchor_mode="center",
strategy_mode="keep-first-level-planar-relations",
)
_assert(
str(blocked_keep_relations.get("status")) == "blocked",
"keeping first-level planar relations while fixing center should be blocked",
)
_assert(
"保持一级平面关系" in str(blocked_keep_relations.get("message") or ""),
f"blocked keep-planar-relations plan should name the requested constraint: {blocked_keep_relations}",
)
print("Edge modeling-intent plans ok")
def _verify_edge_coordinate_move_plan_semantics() -> None:
model = StepModel.load(DEFAULT_CUBE)
edge_id = _line_edge_ids_near_length(model, 10.0, 1e-5)[0]
info = model.edge_info(edge_id)
start = info.get("start_point")
center = info.get("length_center")
if not isinstance(start, (tuple, list)) or len(start) != 3:
raise AssertionError(f"line Edge lacks a stable start point: {info}")
if not isinstance(center, (tuple, list)) or len(center) != 3:
raise AssertionError(f"line Edge lacks a stable center: {info}")
endpoint_plan = model.edge_endpoint_move_plan(
edge_id,
"start",
(float(start[0]) - 1.0, float(start[1]), float(start[2])),
)
_assert(str(endpoint_plan.get("status")) != "blocked", f"endpoint move should be available: {endpoint_plan}")
_assert(str(endpoint_plan.get("edge_endpoint_label")) == "起点", f"endpoint label should be Chinese: {endpoint_plan}")
_assert(
str(endpoint_plan.get("edit_strategy_label")) == "Edge端点局部移动",
f"endpoint move should expose a user-facing strategy label: {endpoint_plan}",
)
_assert(
"不是端面整体推拉" in str(endpoint_plan.get("edit_semantics") or ""),
f"endpoint move should explain that it is local deformation: {endpoint_plan}",
)
center_plan = model.edge_center_move_plan(
edge_id,
(float(center[0]), float(center[1]), float(center[2]) + 1.0),
)
_assert(str(center_plan.get("status")) != "blocked", f"center move should be available: {center_plan}")
_assert(
str(center_plan.get("edit_strategy_label")) == "Edge整体局部移动",
f"center move should expose a user-facing strategy label: {center_plan}",
)
_assert(
"不是移动整个端面" in str(center_plan.get("edit_semantics") or ""),
f"center move should explain that it is local deformation: {center_plan}",
)
print("Edge coordinate move plan semantics ok")
def main() -> int:
_verify_line_edge_intent_specs()
_verify_circle_edge_intent_specs()
_verify_edge_length_plan_intents()
_verify_edge_coordinate_move_plan_semantics()
print("Edge modeling-intent spec suite passed.")
return 0
+57
View File
@@ -25,10 +25,14 @@ from verify_edge_round_chamfer import (
_cylindrical_faces_near_radius,
_first_adjacent_reference_face,
_first_editable_line_edge,
_first_existing_chamfer_face,
_first_existing_fillet_face,
_verify_chamfer_topology,
_write_box_model,
_write_chamfered_box_model,
_write_chained_filleted_box_model,
_write_filleted_box_model,
_existing_chamfer_faces_near_distance,
)
from verify_ellipse_edge_resize import _ellipse_edge_ids, _write_ellipse_face_model
from verify_hole_resize import (
@@ -267,6 +271,57 @@ def _verify_existing_fillet_isolated(temp_dir: Path) -> None:
print(f"isolated existing fillet ok: face={face_id}, matches={matches[:3]}")
def _verify_existing_fillet_chain_isolated(temp_dir: Path) -> None:
input_path = temp_dir / "existing_fillet_chain.step"
output_path = temp_dir / "existing_fillet_chain_out.step"
request_path = temp_dir / "existing_fillet_chain_request.json"
source_radius = 1.0
target_radius = 1.5
_write_chained_filleted_box_model(input_path, source_radius)
model = StepModel.load(input_path)
face_id = _first_existing_fillet_face(model, source_radius, 2e-4)
feature = model.feature_info(face_id)
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
if len(chain_face_ids) < 2:
raise SystemExit(f"isolated existing fillet chain source did not expose a chain: {feature}")
_write_request(request_path, input_path, output_path, "resize_existing_fillet", [face_id, target_radius])
response = _run_worker(request_path)
result_model = _load_worker_output(output_path, response)
matches = _cylindrical_faces_near_radius(result_model, target_radius, 2e-4)
old_matches = _cylindrical_faces_near_radius(result_model, source_radius, 2e-4)
if len(matches) < len(chain_face_ids) or old_matches:
raise SystemExit(
"isolated existing fillet chain failed: "
f"matches={matches}, old_matches={old_matches}, chain={chain_face_ids}, response={response}"
)
_assert_one_solid(result_model, "isolated existing fillet chain")
print(f"isolated existing fillet chain ok: face={face_id}, chain={chain_face_ids}, matches={matches[:4]}")
def _verify_existing_chamfer_isolated(temp_dir: Path) -> None:
input_path = temp_dir / "existing_chamfer.step"
output_path = temp_dir / "existing_chamfer_out.step"
request_path = temp_dir / "existing_chamfer_request.json"
source_distance = 1.5
target_distance = 2.0
_write_chamfered_box_model(input_path, source_distance)
model = StepModel.load(input_path)
face_id = _first_existing_chamfer_face(model, source_distance, 2e-4)
_write_request(request_path, input_path, output_path, "resize_existing_chamfer", [face_id, target_distance])
response = _run_worker(request_path)
result_model = _load_worker_output(output_path, response)
matches = _existing_chamfer_faces_near_distance(result_model, target_distance, target_distance * 0.03)
old_matches = _existing_chamfer_faces_near_distance(result_model, source_distance, source_distance * 0.02)
if not matches or old_matches:
raise SystemExit(
f"isolated existing chamfer failed: matches={matches}, old_matches={old_matches}, response={response}"
)
_assert_one_solid(result_model, "isolated existing chamfer")
print(f"isolated existing chamfer ok: face={face_id}, matches={matches[:3]}")
def main() -> int:
with tempfile.TemporaryDirectory(prefix="geom_param_edge_isolated_") as temp:
temp_dir = Path(temp)
@@ -278,6 +333,8 @@ def main() -> int:
_verify_edge_fillet_isolated(temp_dir)
_verify_edge_chamfers_isolated(temp_dir)
_verify_existing_fillet_isolated(temp_dir)
_verify_existing_fillet_chain_isolated(temp_dir)
_verify_existing_chamfer_isolated(temp_dir)
print("Edge isolated edit suite passed.")
return 0
+13 -1
View File
@@ -168,7 +168,11 @@ def _assert_cube_edge_intent_geometry(
f"source_count={source_count}, slanted_count={slanted_count}, "
f"expected_slanted={expected_slanted:g}, lengths={_length_distribution_from_values(lengths)}"
)
elif strategy in {"move-edge-end-plane-by-push-pull", "scale-owning-shape-from-edge"}:
elif strategy in {
"move-edge-end-plane-by-push-pull",
"keep-first-level-planar-relations",
"scale-owning-shape-from-edge",
}:
if target_count != 4 or source_count != 8:
raise SystemExit(
f"{strategy} should resize the whole cube span in the selected Edge direction; "
@@ -195,6 +199,7 @@ def main() -> int:
"auto",
"local-edge-only-deform",
"move-edge-end-plane-by-push-pull",
"keep-first-level-planar-relations",
"scale-owning-shape-from-edge",
],
help="Requested Edge length edit semantics.",
@@ -220,6 +225,8 @@ def main() -> int:
expected_strategy = args.expect_strategy or args.strategy
if expected_strategy == "auto":
expected_strategy = strategy
if expected_strategy == "keep-first-level-planar-relations":
expected_strategy = "move-edge-end-plane-by-push-pull"
if strategy != expected_strategy:
raise SystemExit(f"expected {expected_strategy}, got {strategy or '<none>'}")
if strategy == "move-edge-end-plane-by-push-pull" and plan.get("edge_length_planar_constraint_status") != "ready":
@@ -228,6 +235,11 @@ def main() -> int:
f"status={plan.get('edge_length_planar_constraint_status')}, "
f"blockers={plan.get('edge_length_planar_constraint_blockers')}"
)
if args.strategy == "keep-first-level-planar-relations":
if plan.get("edge_length_planar_relation_constraint_requested") is not True:
raise SystemExit(f"keep-planar-relations plan should preserve explicit constraint intent: {plan}")
if "保持一级平面关系" not in str(plan.get("edge_length_strategy_label") or ""):
raise SystemExit(f"keep-planar-relations plan should use a clear user-facing label: {plan}")
result = model.resize_general_edge_length(
edge_id,
+486 -24
View File
@@ -7,7 +7,7 @@ import sys
import tempfile
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeFillet
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
from OCC.Core.BRepGProp import brepgprop
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
from OCC.Core.GeomAbs import GeomAbs_Line
@@ -71,6 +71,14 @@ def _write_filleted_box_model(path: Path, radius: float) -> None:
_write_step(result, path)
def _write_chamfered_box_model(path: Path, distance: float) -> None:
shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape()
maker = BRepFilletAPI_MakeChamfer(shape)
maker.Add(float(distance), _first_line_edge(shape))
result = _finalize_builder_result(maker, "verify source box chamfer")
_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)
@@ -92,6 +100,52 @@ def _write_chained_filleted_box_model(path: Path, radius: float) -> None:
_write_step(result, path)
def _write_transitive_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 len(records) < 6:
raise SystemExit("not enough line edges found in generated box for transitive fillet chain")
maker = BRepFilletAPI_MakeFillet(shape)
for record in records[:6]:
maker.Add(float(radius), record["edge"])
result = _finalize_builder_result(maker, "verify source box transitive fillet chain")
_write_step(result, path)
def _write_complex_same_radius_filleted_box_model(path: Path, radius: float) -> None:
shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape()
records = _line_edge_endpoint_records(shape)
complex_chain_edge_indices = (0, 1, 2, 4, 9)
if len(records) <= max(complex_chain_edge_indices):
raise SystemExit("not enough line edges found in generated box for complex fillet chain")
maker = BRepFilletAPI_MakeFillet(shape)
for index in complex_chain_edge_indices:
maker.Add(float(radius), records[index]["edge"])
result = _finalize_builder_result(maker, "verify source box complex same-radius fillet chain")
_write_step(result, path)
def _write_mixed_radius_filleted_box_model(path: Path, radius1: float, radius2: 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 mixed-radius fillet source")
maker = BRepFilletAPI_MakeFillet(shape)
maker.Add(float(radius1), first["edge"])
maker.Add(float(radius2), second["edge"])
result = _finalize_builder_result(maker, "verify source box mixed-radius 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)):
@@ -151,6 +205,33 @@ def _first_existing_fillet_face(model: StepModel, radius: float, tolerance: floa
raise SystemExit(f"no existing fillet candidate near radius {radius:g}; loose matches: {detail or '<none>'}")
def _existing_chamfer_faces_near_distance(
model: StepModel,
distance: float,
tolerance: float,
) -> list[tuple[int, float, str]]:
matches: list[tuple[int, float, str]] = []
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("existing_chamfer_status") != "candidate":
continue
value = float(info.get("existing_chamfer_distance_estimate") or 0.0)
if abs(value - distance) <= tolerance:
matches.append((face_id, value, str(info.get("feature_type") or "")))
return matches
def _first_existing_chamfer_face(model: StepModel, distance: float, tolerance: float) -> int:
matches = _existing_chamfer_faces_near_distance(model, distance, tolerance)
if matches:
return matches[0][0]
detail = ", ".join(
f"Face {face_id}: distance={value:g}, type={feature_type}"
for face_id, value, feature_type in _existing_chamfer_faces_near_distance(model, distance, max(tolerance, distance))
)
raise SystemExit(f"no existing chamfer candidate near distance {distance:g}; loose matches: {detail or '<none>'}")
def _assert_existing_fillet_plan_topology(plan: dict[str, object]) -> None:
if plan.get("topology_relation_depth") != 1:
raise SystemExit(f"existing fillet plan should expose first-level depth: {plan}")
@@ -394,17 +475,69 @@ def _run_existing_fillet_arc_length_case(
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")
def _run_existing_chamfer_case(source_distance: float, target_distance: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_chamfer_") as temp_dir:
model_path = Path(temp_dir) / "chamfered_box.step"
_write_chamfered_box_model(model_path, source_distance)
model = StepModel.load(model_path)
face_id = _first_existing_chamfer_face(model, source_distance, tolerance)
source_info = model.feature_info(face_id)
if source_info.get("feature_type") != "已有倒角平面候选":
raise SystemExit(f"existing chamfer should use a clear feature type: {source_info}")
ready_actions = str(source_info.get("recognition_ready_actions") or "")
if "已有倒角距离" not in ready_actions:
raise SystemExit(f"existing chamfer should expose distance as ready action: {source_info}")
if "当前面面内长度" in ready_actions or "平面拉伸" in ready_actions:
raise SystemExit(f"existing chamfer should not leak generic Face edits: {source_info}")
editable_candidates = model.editable_feature_candidates(limit=20, detailed=False)
editable_chamfers = [
item
for item in editable_candidates
if item.get("operation_key") == "inspect_existing_chamfer" and item.get("target_id") == face_id
]
if not editable_chamfers:
raise SystemExit(f"editable scan did not expose existing chamfer distance: {editable_candidates}")
plane_leaks = [
item
for item in editable_candidates
if item.get("operation_key") == "push_pull_plane" and item.get("target_id") == face_id
]
if plane_leaks:
raise SystemExit(f"editable scan leaked existing chamfer as generic plane edit: {plane_leaks}")
before = model.stats()
plan = model.existing_chamfer_resize_plan(face_id, target_distance)
if plan.get("status") == "blocked":
raise SystemExit(f"existing chamfer plan was blocked: {plan['message']}")
if plan.get("topology_relation_depth") != 1:
raise SystemExit(f"existing chamfer plan should expose first-level depth: {plan}")
result = model.resize_existing_chamfer(face_id, target_distance)
after = model.stats()
matches = _existing_chamfer_faces_near_distance(model, target_distance, max(tolerance, target_distance * 0.03))
old_matches = _existing_chamfer_faces_near_distance(model, source_distance, max(tolerance, source_distance * 0.02))
if after.solids != before.solids:
raise SystemExit(f"existing chamfer resize changed solid count: before={before.solids}, after={after.solids}")
if not matches:
raise SystemExit(f"existing chamfer verification failed: no planar chamfer near distance {target_distance:g}")
if old_matches:
raise SystemExit(f"existing chamfer still has old distance matches: {old_matches}")
if "Existing chamfer result check" not in result:
raise SystemExit(f"existing chamfer result did not report result check: {result}")
if "first_level_topology_matched=True" not in result:
raise SystemExit(f"existing chamfer result did not verify first-level topology: {result}")
print("mode=existing_chamfer")
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(f"target_distance={target_distance:.6f}")
print(f"matched_faces={matches}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_chain_guard_case(source_radius: float, target_radius: float, tolerance: float) -> None:
def _run_existing_fillet_chain_resize_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)
@@ -414,17 +547,19 @@ def _run_existing_fillet_chain_guard_case(source_radius: float, target_radius: f
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}")
if feature.get("existing_fillet_status") != "candidate" or feature.get("existing_fillet_risk") != "high":
raise SystemExit(f"same-radius fillet chain should be editable but high risk at recognition level: {feature}")
if feature.get("existing_fillet_chain_status") != "same-radius-chain-candidate":
raise SystemExit(f"same-radius fillet chain status is unclear: {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 feature.get("recognition_decision") == "已阻止" or recognition_blockers:
raise SystemExit(f"same-radius fillet chain should not be globally blocked: {feature}")
if "已有圆角半径" not in recognition_ready_actions:
raise SystemExit(f"same-radius fillet chain should expose existing fillet radius as ready: {feature}")
if "已有圆角半径" in recognition_limited_actions:
raise SystemExit(f"same-radius fillet chain should not list existing fillet radius as limited: {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):
@@ -435,30 +570,312 @@ def _run_existing_fillet_chain_guard_case(source_radius: float, target_radius: f
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 plan.get("status") == "blocked":
raise SystemExit(f"same-radius existing fillet chain resize should not be blocked: {plan}")
if plan.get("resize_strategy") != "defeature-existing-fillet-chain-then-refillet-axis-edges":
raise SystemExit(f"same-radius fillet chain should use chain refillet strategy: {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}")
if tuple(plan.get("feature_existing_fillet_resize_face_ids") or ()) != chain_face_ids:
raise SystemExit(f"fillet chain plan should resize every chain face: {plan}")
scan_candidates = model.editable_feature_candidates(limit=20, detailed=False)
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 not chain_candidates:
raise SystemExit(f"same-radius fillet chain should be listed as an editable fillet operation: {scan_candidates}")
before = model.stats()
result = model.resize_existing_fillet(face_id, target_radius)
after = model.stats()
matches = _cylindrical_faces_near_radius(model, target_radius, tolerance, require_fillet_guess=True)
if len(matches) < len(chain_face_ids):
raise SystemExit(f"same-radius fillet chain result should contain target fillet faces: matches={matches}, result={result}")
if "required_matches" not in result or "first_level_topology_matched=True" not in result:
raise SystemExit(f"same-radius fillet chain result should report chain result checks: {result}")
print("mode=existing_fillet_chain")
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(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"matches={matches}")
print(message.encode("ascii", "backslashreplace").decode("ascii"))
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_chain_arc_length_case(
source_radius: float,
target_arc_length: float,
tolerance: float,
) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_chain_arc_") 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)
source_info = model.feature_info(face_id)
chain_face_ids = tuple(source_info.get("feature_existing_fillet_chain_face_ids") or ())
if len(chain_face_ids) < 2:
raise SystemExit(f"same-radius fillet chain source should expose connected faces: {source_info}")
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"same-radius fillet chain 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"same-radius fillet chain arc-length plan was blocked: {plan['message']}")
if plan.get("resize_strategy") != "defeature-existing-fillet-chain-then-refillet-axis-edges":
raise SystemExit(f"same-radius fillet chain arc-length should use chain refillet strategy: {plan}")
if tuple(plan.get("feature_existing_fillet_resize_face_ids") or ()) != chain_face_ids:
raise SystemExit(f"same-radius fillet chain arc-length should resize every chain face: {plan}")
result = model.resize_existing_fillet(face_id, target_radius)
after = model.stats()
matches = _cylindrical_faces_near_radius(model, target_radius, tolerance, require_fillet_guess=True)
if len(matches) < len(chain_face_ids):
raise SystemExit(
f"same-radius fillet chain arc-length result should contain target fillet faces: "
f"matches={matches}, result={result}"
)
arc_errors: list[tuple[int, float, float]] = []
for verified_face_id, _radius, _span, _guess in matches[: len(chain_face_ids)]:
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)
arc_errors.append((verified_face_id, verified_arc, arc_error))
if arc_error > max(tolerance * 4.0, target_arc_length * 5e-4):
raise SystemExit(
f"same-radius fillet chain arc-length verification failed: "
f"target={target_arc_length:g}, face={verified_face_id}, value={verified_arc:g}, error={arc_error:g}"
)
if after.solids != before.solids:
raise SystemExit(
f"same-radius fillet chain arc-length resize changed solid count: "
f"before={before.solids}, after={after.solids}"
)
if "required_matches" not in result or "first_level_topology_matched=True" not in result:
raise SystemExit(f"same-radius fillet chain arc-length result should report chain checks: {result}")
print("mode=existing_fillet_chain_arc_length")
print(f"face_id={face_id}")
print(f"chain_face_ids={chain_face_ids}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"target_arc_length={target_arc_length:.6f}")
print(f"target_radius={target_radius:.6f}")
print(f"matches={matches}")
print(f"arc_errors={arc_errors}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_transitive_chain_case(source_radius: float, target_radius: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_transitive_chain_") as temp_dir:
model_path = Path(temp_dir) / "transitive_fillet_chain_box.step"
_write_transitive_chained_filleted_box_model(model_path, source_radius)
model = StepModel.load(model_path)
chain_rows: list[tuple[int, tuple[int, ...], dict[str, object]]] = []
for candidate_face_id in range(len(model.faces)):
info = model.face_info(candidate_face_id)
if info.get("feature_guess") != "round/fillet candidate":
continue
feature = model.feature_info(candidate_face_id)
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
if feature.get("existing_fillet_chain_status") == "same-radius-chain-candidate":
chain_rows.append((candidate_face_id, chain_face_ids, feature))
chain_rows.sort(key=lambda item: (-len(item[1]), item[0]))
if not chain_rows:
raise SystemExit("transitive same-radius fillet chain source did not expose an editable chain")
face_id, chain_face_ids, feature = chain_rows[0]
if len(chain_face_ids) != 4:
raise SystemExit(
"transitive same-radius fillet chain should include every connected chain Face, "
f"expected 4, got {chain_face_ids}: {feature}"
)
if feature.get("existing_fillet_status") != "candidate" or feature.get("existing_fillet_risk") != "high":
raise SystemExit(f"transitive same-radius fillet chain should be a high-risk editable candidate: {feature}")
plan = model.existing_fillet_resize_plan(face_id, target_radius)
if plan.get("status") == "blocked":
raise SystemExit(f"transitive same-radius fillet chain resize should not be blocked: {plan}")
if plan.get("resize_strategy") != "defeature-existing-fillet-chain-then-refillet-axis-edges":
raise SystemExit(f"transitive same-radius fillet chain should use chain refillet strategy: {plan}")
if tuple(plan.get("feature_existing_fillet_resize_face_ids") or ()) != chain_face_ids:
raise SystemExit(f"transitive same-radius fillet chain should resize the full connected chain: {plan}")
before = model.stats()
result = model.resize_existing_fillet(face_id, target_radius)
after = model.stats()
matches = _cylindrical_faces_near_radius(model, target_radius, tolerance, require_fillet_guess=True)
if len(matches) < len(chain_face_ids):
raise SystemExit(f"transitive same-radius fillet chain result should contain target fillet faces: {matches}")
if "required_matches=4" not in result or "first_level_topology_matched=True" not in result:
raise SystemExit(f"transitive same-radius fillet chain result should report four target checks: {result}")
print("mode=existing_fillet_transitive_chain")
print(f"face_id={face_id}")
print(f"chain_face_ids={chain_face_ids}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"matches={matches}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_mixed_radius_chain_guard_case(
source_radius: float,
adjacent_radius: float,
target_radius: float,
tolerance: float,
) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_mixed_chain_") as temp_dir:
model_path = Path(temp_dir) / "mixed_radius_fillet_chain_box.step"
_write_mixed_radius_filleted_box_model(model_path, source_radius, adjacent_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 ())
mixed_radius_face_ids = tuple(feature.get("feature_existing_fillet_mixed_radius_chain_face_ids") or ())
radius_rows = tuple(feature.get("feature_existing_fillet_adjacent_radius_rows") 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"mixed-radius fillet chain should be blocked at recognition level: {feature}")
if feature.get("existing_fillet_chain_status") != "variable-radius-chain-candidate":
raise SystemExit(f"mixed-radius fillet chain should expose variable-radius status: {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"mixed-radius fillet recognition summary should explain the blocker: {feature}")
if "已有圆角半径" in recognition_ready_actions:
raise SystemExit(f"mixed-radius chain should not expose existing fillet radius as ready: {feature}")
if "已有圆角半径" not in recognition_limited_actions:
raise SystemExit(f"mixed-radius chain should list existing fillet radius as a limited action: {feature}")
if len(chain_face_ids) < 2 or not chain_adjacent_face_ids or not mixed_radius_face_ids:
raise SystemExit(f"mixed-radius fillet chain should expose connected fillet faces: {feature}")
if set(chain_adjacent_face_ids) & set(support_face_ids):
raise SystemExit(
"connected mixed-radius fillet faces should not be counted as support faces: "
f"chain_adjacent={chain_adjacent_face_ids}, support={support_face_ids}"
)
if not any(abs(float(row[1]) - adjacent_radius) <= tolerance for row in radius_rows if len(row) >= 2):
raise SystemExit(f"mixed-radius fillet should record adjacent radii: {feature}")
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"mixed-radius existing fillet resize should be blocked before geometry execution: {plan}")
if "变半径" not in f"{message} {blockers}" or "暂未实现" not in f"{message} {blockers}":
raise SystemExit(f"mixed-radius blocker should explain the unsupported capability: {plan}")
if tuple(plan.get("feature_existing_fillet_mixed_radius_chain_face_ids") or ()) != mixed_radius_face_ids:
raise SystemExit(f"mixed-radius fillet plan should retain mixed-radius face ids: {plan}")
scan_candidates = model.editable_feature_candidates(limit=30, 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}")
raise SystemExit(
f"mixed-radius fillet chain should not be listed as an editable fillet operation: {leaked_chain_candidates}"
)
print("mode=existing_fillet_chain_guard")
print("mode=existing_fillet_mixed_radius_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"mixed_radius_face_ids={mixed_radius_face_ids}")
print(f"adjacent_radius_rows={radius_rows}")
print(f"support_face_ids={support_face_ids}")
print(message.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_complex_same_radius_chain_guard_case(
source_radius: float,
target_radius: float,
tolerance: float,
) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_complex_same_chain_") as temp_dir:
model_path = Path(temp_dir) / "complex_same_radius_fillet_chain_box.step"
_write_complex_same_radius_filleted_box_model(model_path, source_radius)
model = StepModel.load(model_path)
blocked_rows: list[tuple[int, tuple[int, ...], dict[str, object]]] = []
for candidate_face_id in range(len(model.faces)):
info = model.face_info(candidate_face_id)
if info.get("feature_guess") != "round/fillet candidate":
continue
feature = model.feature_info(candidate_face_id)
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
if feature.get("existing_fillet_chain_status") == "complex-same-radius-chain-candidate":
blocked_rows.append((candidate_face_id, chain_face_ids, feature))
blocked_rows.sort(key=lambda item: (-len(item[1]), item[0]))
if not blocked_rows:
raise SystemExit("complex same-radius fillet chain was not recognized as blocked")
face_id, chain_face_ids, feature = blocked_rows[0]
blockers = str(feature.get("existing_fillet_blockers") or "")
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 len(chain_face_ids) <= 4:
raise SystemExit(f"complex same-radius fillet chain should contain more than 4 faces: {feature}")
if feature.get("existing_fillet_status") != "blocked" or feature.get("existing_fillet_risk") != "blocked":
raise SystemExit(f"complex same-radius fillet chain should be blocked at recognition level: {feature}")
if "复杂长链" not in blockers or "2 到 4" not in blockers:
raise SystemExit(f"complex same-radius blocker should explain the supported chain size: {feature}")
if feature.get("recognition_decision") != "已阻止" or "复杂长链" not in recognition_blockers:
raise SystemExit(f"complex same-radius chain should be globally blocked: {feature}")
if "已有圆角半径" in recognition_ready_actions:
raise SystemExit(f"complex same-radius chain should not expose existing fillet radius as ready: {feature}")
if "已有圆角半径" not in recognition_limited_actions:
raise SystemExit(f"complex same-radius chain should list existing fillet radius as limited: {feature}")
plan = model.existing_fillet_resize_plan(face_id, target_radius)
message = str(plan.get("message") or "")
plan_blockers = str(plan.get("blockers") or "")
if plan.get("status") != "blocked" or plan.get("risk") != "blocked":
raise SystemExit(f"complex same-radius fillet chain resize should be blocked: {plan}")
if "复杂长链" not in f"{message} {plan_blockers}" or "2 到 4" not in f"{message} {plan_blockers}":
raise SystemExit(f"complex same-radius fillet chain plan should explain the limit: {plan}")
if tuple(plan.get("feature_existing_fillet_chain_face_ids") or ()) != chain_face_ids:
raise SystemExit(f"complex same-radius fillet chain plan should retain full chain ids: {plan}")
scan_candidates = model.editable_feature_candidates(limit=30, detailed=False)
leaked_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_candidates:
raise SystemExit(
f"complex same-radius fillet chain should not be listed as editable: {leaked_candidates}"
)
print("mode=existing_fillet_complex_same_radius_chain_guard")
print(f"face_id={face_id}")
print(f"chain_face_ids={chain_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(
@@ -472,7 +889,13 @@ def main() -> int:
"distance_angle_chamfer",
"existing_fillet",
"existing_fillet_arc_length",
"existing_chamfer",
"existing_fillet_chain",
"existing_fillet_chain_arc_length",
"existing_fillet_transitive_chain",
"existing_fillet_chain_guard",
"existing_fillet_complex_same_radius_chain_guard",
"existing_fillet_mixed_radius_chain_guard",
],
help="Edge rounding/chamfering edit mode to verify.",
)
@@ -483,8 +906,11 @@ def main() -> int:
parser.add_argument("--distance-angle-distance", type=float, default=1.0)
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("--adjacent-fillet-radius", type=float, default=1.8)
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("--source-chamfer-distance", type=float, default=1.5)
parser.add_argument("--target-chamfer-distance", type=float, default=2.0)
parser.add_argument("--tolerance", type=float, default=2e-4)
args = parser.parse_args()
@@ -496,7 +922,12 @@ def main() -> int:
"distance_angle_chamfer",
"existing_fillet",
"existing_fillet_arc_length",
"existing_fillet_chain_guard",
"existing_chamfer",
"existing_fillet_chain",
"existing_fillet_chain_arc_length",
"existing_fillet_transitive_chain",
"existing_fillet_complex_same_radius_chain_guard",
"existing_fillet_mixed_radius_chain_guard",
]
if args.mode == "all"
else [args.mode]
@@ -518,8 +949,39 @@ def main() -> int:
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)
elif mode == "existing_chamfer":
_run_existing_chamfer_case(
args.source_chamfer_distance,
args.target_chamfer_distance,
args.tolerance,
)
elif mode in {"existing_fillet_chain", "existing_fillet_chain_guard"}:
_run_existing_fillet_chain_resize_case(args.source_fillet_radius, args.target_fillet_radius, args.tolerance)
elif mode == "existing_fillet_chain_arc_length":
_run_existing_fillet_chain_arc_length_case(
args.source_fillet_radius,
args.target_fillet_arc_length,
args.tolerance,
)
elif mode == "existing_fillet_transitive_chain":
_run_existing_fillet_transitive_chain_case(
args.source_fillet_radius,
args.target_fillet_radius,
args.tolerance,
)
elif mode == "existing_fillet_complex_same_radius_chain_guard":
_run_existing_fillet_complex_same_radius_chain_guard_case(
args.source_fillet_radius,
args.target_fillet_radius,
args.tolerance,
)
elif mode == "existing_fillet_mixed_radius_chain_guard":
_run_existing_fillet_mixed_radius_chain_guard_case(
args.source_fillet_radius,
args.adjacent_fillet_radius,
args.target_fillet_radius,
args.tolerance,
)
else:
raise SystemExit(f"unsupported mode: {mode}")
return 0
+16
View File
@@ -14,6 +14,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"face width, current face only",
("verify_face_resize_semantics.py", "--strategy", "local", "--axis", "width", "--target-size", "15"),
),
(
"face width, keep first-level planar relations",
("verify_face_resize_semantics.py", "--strategy", "keep_relations", "--axis", "width", "--target-size", "15"),
),
(
"Face first-level shared-edge topology",
("verify_face_first_level_topology.py",),
@@ -30,6 +34,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"face height, current face only",
("verify_face_resize_semantics.py", "--strategy", "local", "--axis", "height", "--target-size", "15"),
),
(
"face height, keep first-level planar relations",
("verify_face_resize_semantics.py", "--strategy", "keep_relations", "--axis", "height", "--target-size", "15"),
),
(
"face height, owning feature",
("verify_face_resize_semantics.py", "--strategy", "owning", "--axis", "height", "--target-size", "15"),
@@ -50,6 +58,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"face center, current face only",
("verify_face_resize_semantics.py", "--property", "center", "--strategy", "local", "--center-offset", "2,0,3"),
),
(
"face center, keep first-level planar relations",
("verify_face_resize_semantics.py", "--property", "center", "--strategy", "keep_relations", "--center-offset", "2,0,3"),
),
(
"face center, owning feature",
("verify_face_resize_semantics.py", "--property", "center", "--strategy", "owning", "--center-offset", "2,0,3"),
@@ -58,6 +70,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"face offset, push pull",
("verify_face_resize_semantics.py", "--property", "offset", "--strategy", "push_pull", "--offset-distance", "1"),
),
(
"face offset, keep first-level planar relations",
("verify_face_resize_semantics.py", "--property", "offset", "--strategy", "keep_relations", "--offset-distance", "1"),
),
(
"face offset, push pull inward cut",
("verify_face_resize_semantics.py", "--property", "offset", "--strategy", "push_pull", "--offset-distance", "-1"),
@@ -15,12 +15,12 @@ from scripts.verify_large_stepped_cap_push_pull import _large_multi_boundary_cap
MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
FACE_ID = 594
BASE_FACE_KEYS = (
"area",
"local_face_width",
"local_face_height",
"face_center_position",
"face_target_normal_position",
)
RESULT_ONLY_KEYS = ("area",)
class _Probe(WindowStateMixin):
@@ -71,6 +71,12 @@ def _assert_contains(keys: tuple[str, ...], expected: tuple[str, ...], label: st
raise AssertionError(f"{label} missing {missing}, got {keys}")
def _assert_absent(keys: tuple[str, ...], forbidden: tuple[str, ...], label: str) -> None:
leaked = [key for key in forbidden if key in keys]
if leaked:
raise AssertionError(f"{label} should keep result-only values out of feature parameters: {leaked}")
def _assert_face_594_stays_stable_after_remote_cap_edit() -> None:
model = StepModel.load(MODEL_PATH)
source_face_id = _large_stepped_cap_face(model)
@@ -123,16 +129,19 @@ def main() -> int:
before_cached_rows = _feature_rows(model, FACE_ID)
_assert_contains(before_cached_rows, BASE_FACE_KEYS, "Face 594 before full feature cache")
_assert_absent(before_cached_rows, RESULT_ONLY_KEYS, "Face 594 before full feature cache")
full_info = model.feature_info(FACE_ID)
if full_info.get("shell_region_status") == "candidate":
dimension_keys = _feature_dimension_keys(full_info)
_assert_contains(dimension_keys, BASE_FACE_KEYS, "shell candidate feature dimensions")
_assert_absent(dimension_keys, RESULT_ONLY_KEYS, "shell candidate feature dimensions")
if "shell_thickness_estimate" not in dimension_keys:
raise AssertionError(f"shell candidate should keep shell thickness as an extra dimension: {dimension_keys}")
after_cached_rows = _feature_rows(model, FACE_ID)
_assert_contains(after_cached_rows, BASE_FACE_KEYS, "Face 594 after full feature cache")
_assert_absent(after_cached_rows, RESULT_ONLY_KEYS, "Face 594 after full feature cache")
if before_cached_rows != after_cached_rows:
raise AssertionError(
"Face 594 current-only feature rows changed after full recognition cache: "
+8 -2
View File
@@ -144,12 +144,18 @@ def _assert_selection_exposes_first_level(model: StepModel, face_id: int) -> Non
raise SystemExit(f"Selected Face should expose 4 first-level adjacent Faces, got {feature_info}")
editable_specs, _used = probe._editable_property_specs(feature_info)
feature_rows = probe._feature_property_specs(editable_specs, feature_info)
topology_row = _spec(feature_rows, "face_first_level_topology")
topology_row = _spec(editable_specs, "face_first_level_topology")
topology_text = str(topology_row.get("current_text") or "")
for fragment in ("Face 区域 1 个", "边界 Edge 4 条", "共享边相邻 Face 4 个"):
if fragment not in topology_text:
raise SystemExit(f"Selected Face topology row is not clear enough: {topology_row}")
feature_rows = probe._feature_property_specs(editable_specs, feature_info)
feature_row_keys = {str(spec.get("key", "")) for spec in feature_rows}
if "face_first_level_topology" in feature_row_keys:
raise SystemExit(
"Feature parameter table should keep first-level topology in diagnostics, "
f"not in editable feature rows: {feature_rows}"
)
def main() -> int:
+5 -2
View File
@@ -120,8 +120,11 @@ def main() -> int:
feature_keys = {str(spec.get("key")) for spec in feature_rows}
if "no_editable_feature_dimensions" not in feature_keys:
raise SystemExit(f"feature mode should say there are no reliable dimensions: {feature_rows}")
if "freeform_surface_edit_semantics" not in feature_keys:
raise SystemExit(f"feature mode should keep the freeform limitation explanation: {feature_rows}")
if "freeform_surface_edit_semantics" in feature_keys:
raise SystemExit(
"feature parameter table should keep freeform limitations in diagnostics, "
f"not mixed into editable parameters: {feature_rows}"
)
_assert_no_editable_actions(feature_rows, "feature mode rows")
print(
+29 -1
View File
@@ -166,6 +166,19 @@ def _assert_local_scope_explains_curved_owner(specs: list[dict[str, object]], ke
raise SystemExit(f"{key}/local disabled tip should mention curved owner: {disabled_tip}")
def _assert_keep_relations_blocked(plan: dict[str, object], label: str, *expected_fragments: str) -> None:
if plan.get("status") != "blocked":
raise SystemExit(f"{label} should be blocked before execution: {plan}")
if plan.get("face_push_pull_planar_relation_constraint_requested") is not True:
raise SystemExit(f"{label} should record the requested keep-relation constraint: {plan}")
if plan.get("face_push_pull_planar_constraint_status") != "blocked":
raise SystemExit(f"{label} should expose blocked keep-relation constraint status: {plan}")
message = str(plan.get("message") or "") + " " + str(plan.get("blockers") or "")
for fragment in expected_fragments:
if fragment not in message:
raise SystemExit(f"{label} blocker should mention {fragment!r}: {plan}")
def _bbox_height(model: StepModel) -> float:
info = model.part_info(1)
bbox_size = info.get("bbox_size")
@@ -199,6 +212,12 @@ def main() -> int:
_assert_blocked(model.face_center_local_move_plan(face_id, target_center), "Face center local move")
_assert_blocked(model.face_area_local_resize_plan(face_id, area * 1.1), "Face area local resize")
_assert_blocked(model.face_plane_offset_local_plan(face_id, 1.0), "Face plane offset local move")
_assert_keep_relations_blocked(
model.push_pull_keep_relations_plan(face_id, 1.0),
"planar cylinder cap keep-relations push/pull",
"非平面",
"平面邻域",
)
specs = _specs(face_id, info)
semantics = _spec(specs, "face_edit_semantics")
@@ -206,11 +225,20 @@ def main() -> int:
raise SystemExit(f"Face edit semantics should summarize the local blocker: {semantics}")
if "曲面" not in str(semantics.get("disabled_tip") or ""):
raise SystemExit(f"Face edit semantics tip should include the curved-owner blocker: {semantics}")
for key in ("area", "face_center_position", "face_target_normal_position"):
for key in ("face_center_position", "face_target_normal_position"):
_assert_local_scope_explains_curved_owner(specs, key)
push_pull_mode = _scope_mode(specs, "face_target_normal_position", "push_pull")
if not bool(push_pull_mode.get("enabled", False)):
raise SystemExit("planar cylinder cap push/pull scope should remain available")
keep_relations_mode = _scope_mode(specs, "face_target_normal_position", "keep_relations")
if bool(keep_relations_mode.get("enabled", True)):
raise SystemExit(f"planar cylinder cap keep-relations scope should be disabled: {keep_relations_mode}")
keep_relations_tip = str(keep_relations_mode.get("disabled_tip") or "")
if "非平面" not in keep_relations_tip and "曲面" not in keep_relations_tip:
raise SystemExit(
"planar cylinder cap keep-relations disabled tip should mention non-planar adjacency: "
f"{keep_relations_tip}"
)
huge_push_plan = model.push_pull_plan(face_id, 50.0)
if huge_push_plan.get("status") == "blocked":
@@ -32,6 +32,22 @@ def _write_triangular_prism(path: Path) -> None:
_write_step(prism, path)
def _write_trapezoid_prism(path: Path) -> None:
polygon = BRepBuilderAPI_MakePolygon()
polygon.Add(gp_Pnt(0.0, 0.0, 0.0))
polygon.Add(gp_Pnt(12.0, 0.0, 0.0))
polygon.Add(gp_Pnt(10.0, 0.0, 6.0))
polygon.Add(gp_Pnt(0.0, 0.0, 8.0))
polygon.Close()
if hasattr(polygon, "IsDone") and not polygon.IsDone():
raise RuntimeError("Could not create trapezoid prism profile.")
face_maker = BRepBuilderAPI_MakeFace(polygon.Wire())
if hasattr(face_maker, "IsDone") and not face_maker.IsDone():
raise RuntimeError("Could not create trapezoid prism face.")
prism = BRepPrimAPI_MakePrism(face_maker.Face(), gp_Vec(0.0, 6.0, 0.0)).Shape()
_write_step(prism, path)
def _triangle_face_id(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
@@ -42,6 +58,19 @@ def _triangle_face_id(model: StepModel) -> int:
raise SystemExit("no triangular planar Face was found")
def _sloped_quad_face_id(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
normal = info.get("normal") or info.get("push_pull_outward_direction")
if not isinstance(normal, tuple) or len(normal) != 3:
continue
if abs(float(normal[0])) > 0.1 and abs(float(normal[2])) > 0.1:
return face_id
raise SystemExit("no sloped quadrilateral planar Face was found")
def _triangle_infos(model: StepModel) -> list[dict[str, object]]:
infos: list[dict[str, object]] = []
for face_id in range(len(model.faces)):
@@ -89,6 +118,20 @@ def _assert_not_blocked(plan: dict[str, object], label: str) -> None:
raise SystemExit(f"{label} should be available for a simple triangular Face: {plan}")
def _assert_keep_relations_blocked_for_angled(plan: dict[str, object], label: str) -> None:
if plan.get("status") != "blocked":
raise SystemExit(f"{label} should be blocked before execution: {plan}")
if plan.get("face_push_pull_planar_relation_constraint_requested") is not True:
raise SystemExit(f"{label} should record the requested keep-relation constraint: {plan}")
if plan.get("face_push_pull_planar_constraint_status") != "blocked":
raise SystemExit(f"{label} should expose blocked keep-relation constraint status: {plan}")
if int(plan.get("face_push_pull_planar_constraint_angled_count", 0) or 0) <= 0:
raise SystemExit(f"{label} should count at least one angled relation: {plan}")
message = str(plan.get("message") or "") + " " + str(plan.get("blockers") or "")
if "斜交" not in message:
raise SystemExit(f"{label} blocker should explain angled first-level planar relations: {plan}")
def _assert_single_solid(model: StepModel, label: str) -> None:
stats = model.stats()
if stats.solids != 1:
@@ -162,6 +205,15 @@ def main() -> int:
model.push_pull_face(face_id, 1.0)
_assert_single_solid(model, "triangular Face push/pull")
wedge_path = Path(temp_dir) / "trapezoid_prism.step"
_write_trapezoid_prism(wedge_path)
wedge = StepModel.load(wedge_path)
wedge_face_id = _sloped_quad_face_id(wedge)
plan = wedge.push_pull_plan(wedge_face_id, 0.5)
_assert_not_blocked(plan, "sloped planar Face push/pull")
keep_plan = wedge.push_pull_keep_relations_plan(wedge_face_id, 0.5)
_assert_keep_relations_blocked_for_angled(keep_plan, "sloped planar Face keep-relations push/pull")
model, face_id, info = _fresh_model(path)
current_area = float(info.get("area") or 0.0)
target_area = current_area * 1.44
+41 -3
View File
@@ -15,16 +15,20 @@ DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
FACE_CASES = (
("size", "local", "width"),
("size", "keep_relations", "width"),
("size", "owning", "width"),
("size", "local", "height"),
("size", "keep_relations", "height"),
("size", "owning", "height"),
("area", "local", "width"),
("area", "owning", "width"),
("center", "local", "width"),
("center", "keep_relations", "width"),
("center", "owning", "width"),
("offset", "local", "width"),
("offset", "owning", "width"),
("offset", "push_pull", "width"),
("offset", "keep_relations", "width"),
)
@@ -170,7 +174,7 @@ def main() -> int:
parser.add_argument("--target-area", type=float, default=144.0)
parser.add_argument("--center-offset", default="2,0,3")
parser.add_argument("--offset-distance", type=float, default=1.0)
parser.add_argument("--strategy", default="local", choices=["local", "owning", "push_pull"])
parser.add_argument("--strategy", default="local", choices=["local", "owning", "push_pull", "keep_relations"])
parser.add_argument("--tolerance", type=float, default=1e-5)
args = parser.parse_args()
@@ -184,11 +188,17 @@ def main() -> int:
if args.strategy == "push_pull" and args.property != "offset":
raise SystemExit("--strategy push_pull is only valid with --property offset")
if args.strategy == "keep_relations" and args.property not in {"size", "center", "offset"}:
raise SystemExit("--strategy keep_relations is only valid with --property size/center/offset")
if args.property == "size" and args.strategy == "local":
plan = model.face_size_local_resize_plan(face_id, args.target_size, args.axis)
result = model.resize_face_size_local(face_id, args.target_size, args.axis)
expected_strategy = f"local-face-{args.axis}-only-deform"
elif args.property == "size" and args.strategy == "keep_relations":
plan = model.face_size_local_resize_keep_relations_plan(face_id, args.target_size, args.axis)
result = model.resize_face_size_local_keep_relations(face_id, args.target_size, args.axis)
expected_strategy = f"axis-scale-owning-shape-from-face-{args.axis}"
elif args.property == "size":
plan = model.face_size_owning_scale_plan(face_id, args.target_size, args.axis)
result = model.resize_face_size_owning_scale(face_id, args.target_size, args.axis)
@@ -201,7 +211,7 @@ def main() -> int:
plan = model.face_area_scale_plan(face_id, args.target_area)
result = model.resize_face_area(face_id, args.target_area)
expected_strategy = "uniform-scale-face-area-fallback"
elif args.property == "center" and args.strategy == "local":
elif args.property == "center" and args.strategy in {"local", "keep_relations"}:
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
@@ -211,6 +221,11 @@ def main() -> int:
float(current_center[1]) + offset[1],
float(current_center[2]) + offset[2],
)
if args.strategy == "keep_relations":
plan = model.face_center_local_move_keep_relations_plan(face_id, target_center)
result = model.move_face_center_local_keep_relations(face_id, target_center)
expected_strategy = "translate-owning-shape-from-face-center"
else:
plan = model.face_center_local_move_plan(face_id, target_center)
result = model.move_face_center_local(face_id, target_center)
expected_strategy = "local-face-only-deform"
@@ -259,7 +274,7 @@ def main() -> int:
plan = model.face_plane_offset_owning_translation_plan(face_id, args.offset_distance)
result = model.translate_face_plane_offset_owning(face_id, args.offset_distance)
expected_strategy = "translate-owning-shape-from-plane-offset"
else:
elif args.property == "offset" and args.strategy == "push_pull":
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face does not have a stable plane offset frame")
@@ -275,12 +290,35 @@ def main() -> int:
plan = model.push_pull_plan(face_id, args.offset_distance)
result = model.push_pull_face(face_id, args.offset_distance)
expected_strategy = "push-pull-planar-face"
else:
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face does not have a stable plane offset frame")
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
_origin, direction, _position = frame
target_center = (
float(current_center[0]) + direction[0] * args.offset_distance,
float(current_center[1]) + direction[1] * args.offset_distance,
float(current_center[2]) + direction[2] * args.offset_distance,
)
plan = model.push_pull_keep_relations_plan(face_id, args.offset_distance)
result = model.push_pull_face_keep_relations(face_id, args.offset_distance)
expected_strategy = "push-pull-planar-face-keep-relations"
resolved_strategy = str(plan.get("resize_strategy", ""))
if args.property == "offset" and args.strategy == "push_pull":
resolved_strategy = "push-pull-planar-face"
if resolved_strategy != expected_strategy:
raise SystemExit(f"expected {expected_strategy}, got {resolved_strategy or '<none>'}")
if args.strategy == "keep_relations":
if plan.get("face_push_pull_planar_relation_constraint_requested") is not True:
raise SystemExit(f"keep-relations plan should record requested relation constraint: {plan}")
if plan.get("face_push_pull_planar_constraint_status") != "ready":
raise SystemExit(f"keep-relations plan should be ready on cube Face: {plan}")
if "Planar relation check: ok" not in result:
raise SystemExit(f"keep-relations result should include planar relation check, got: {result}")
if "Face result check:" not in result:
raise SystemExit(f"Face edit result message should include a result check, got: {result}")
if "First-level check:" not in result:
+19 -2
View File
@@ -14,6 +14,7 @@ if str(PROJECT_ROOT) not in sys.path:
EXPECTED_FACE_ISOLATED_OPERATIONS = {
"push_pull_face",
"push_pull_face_keep_relations",
"move_face_plane_offset_local",
"resize_face_area_local",
"resize_face_area",
@@ -54,6 +55,7 @@ EXPECTED_EDGE_ISOLATED_OPERATIONS = {
"move_circular_edge_axis_center",
"resize_ellipse_edge_axis_radius",
"resize_existing_fillet",
"resize_existing_chamfer",
"fillet_edge",
"chamfer_edge",
"chamfer_edge_asymmetric",
@@ -121,9 +123,8 @@ def _assert_same(label: str, actual: set[str], expected: set[str]) -> None:
PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION = {
"push_pull_face": "push_pull_face",
"push_pull_face_keep_relations": "push_pull_face_keep_relations",
"move_selected_face_plane_position_local": "move_face_plane_offset_local",
"resize_face_area_local": "resize_face_area_local",
"resize_face_area": "resize_face_area",
"resize_face_width_local": "resize_face_size_local",
"resize_face_height_local": "resize_face_size_local",
"resize_face_width_owning_scale": "resize_face_size_owning_scale",
@@ -139,6 +140,7 @@ PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION = {
"resize_sphere_radius": "resize_sphere_radius",
"resize_torus_major_radius": "resize_torus_radius",
"resize_torus_minor_radius": "resize_torus_radius",
"resize_existing_chamfer": "resize_existing_chamfer",
}
@@ -151,6 +153,8 @@ PROPERTY_FACE_ACTIONS_ALLOWED_IN_MAIN_THREAD = {
ACTION_IMPLEMENTATION_METHOD = {
"push_pull_face": "_push_pull_face_action",
"push_pull_face_keep_relations": "_push_pull_face_action",
"resize_face_width_local": "_resize_face_size_local",
"resize_face_height_local": "_resize_face_size_local",
"resize_face_width_owning_scale": "_resize_face_size_owning_scale",
@@ -277,6 +281,19 @@ def _face_property_actions_from_specs() -> set[str]:
(shell_info, {"shell_thickness_estimate"}),
(generic_cylinder_info, {"cylinder_height"}),
(boss_info, {"boss_height"}),
(
{
**plane_info,
"feature_guess": "chamfer candidate",
"feature_type": "已有倒角平面候选",
"existing_chamfer_status": "candidate",
"existing_chamfer_distance_estimate": 1.5,
"existing_chamfer_cross_edge_length_estimate": 2.12132,
"feature_existing_chamfer_support_face_ids": (0, 5),
"feature_existing_chamfer_long_edge_ids": (8,),
},
{"existing_chamfer_distance_estimate"},
),
(
{
"surface": "cone",
+92 -1
View File
@@ -17,9 +17,14 @@ 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_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_slot_resize import _first_slot_face, _write_cross_obround_slot_model, _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
from verify_edge_round_chamfer import ( # noqa: E402
_first_existing_fillet_face,
_write_mixed_radius_filleted_box_model,
)
from verify_shell_thickness_resize import _first_open_shell_wall_face, _write_open_thin_wall_box_model # noqa: E402
def _assert(condition: bool, message: str) -> None:
@@ -288,6 +293,89 @@ def _verify_ellipse_edge_scan_entries(root: Path) -> None:
raise AssertionError(f"ellipse Edge scan should not expose generic length editing: {item}")
def _verify_complex_slot_guard(root: Path) -> None:
path = root / "cross_obround_slot.step"
_write_cross_obround_slot_model(path)
model = StepModel.load(path)
blocked_slot_faces: list[int] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "cylinder":
continue
feature = model.feature_info(face_id)
if feature.get("slot_kind") == "partial-cylindrical-groove" and feature.get("slot_status") == "blocked":
blocked_slot_faces.append(face_id)
blockers = str(feature.get("slot_blockers") or feature.get("recognition_blockers") or "")
_assert("交叉槽" in blockers or "多槽组" in blockers, f"complex slot blocker is unclear: {feature}")
_assert("复杂槽" in str(feature.get("feature_type") or ""), f"complex slot label is unclear: {feature}")
_assert(blocked_slot_faces, "cross obround slot should be recognized as blocked complex slots")
candidates = model.editable_feature_candidates(limit=80, detailed=False)
forbidden_actions = {
"resize_cylinder",
"resize_slot_width",
"resize_slot_depth",
"resize_slot_arc_length",
"resize_slot_angular_span",
}
leaked = [
(item.get("operation_key"), item.get("target_id"))
for item in candidates
if int(item.get("target_id", -1)) in blocked_slot_faces and item.get("operation_key") in forbidden_actions
]
_assert(not leaked, f"complex slot should not leak editable scan entries: {leaked}")
def _verify_mixed_radius_fillet_chain_guard(root: Path) -> None:
path = root / "mixed_radius_fillet_chain.step"
_write_mixed_radius_filleted_box_model(path, 1.0, 1.8)
model = StepModel.load(path)
face_id = _first_existing_fillet_face(model, 1.0, 2e-4)
feature = model.feature_info(face_id)
_assert(feature.get("existing_fillet_status") == "blocked", f"mixed-radius fillet chain should be blocked: {feature}")
_assert(
feature.get("existing_fillet_chain_status") == "variable-radius-chain-candidate",
f"mixed-radius fillet chain should expose variable-radius status: {feature}",
)
blockers = str(feature.get("recognition_blockers") or feature.get("existing_fillet_blockers") or "")
_assert("变半径" in blockers, f"mixed-radius fillet blocker should mention variable radius: {feature}")
_assert(feature.get("recognition_decision") == "已阻止", f"mixed-radius fillet should be globally blocked: {feature}")
_assert(
"已有圆角半径" not in str(feature.get("recognition_ready_actions") or ""),
f"mixed-radius fillet chain should not be ready-editable: {feature}",
)
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
candidates = model.editable_feature_candidates(limit=40, detailed=False)
leaked = [
(item.get("operation_key"), item.get("target_id"))
for item in candidates
if item.get("operation_key") == "inspect_existing_fillet" and int(item.get("target_id", -1)) in chain_face_ids
]
_assert(not leaked, f"mixed-radius fillet chain should not leak editable scan entries: {leaked}")
def _verify_open_shell_context_summary(root: Path) -> None:
path = root / "open_thin_wall_box.step"
_write_open_thin_wall_box_model(path)
model = StepModel.load(path)
face_id = _first_open_shell_wall_face(model, 2.0, 2e-4)
info = model.feature_info(face_id)
_assert(info.get("open_shell_context_status") == "limited", f"open shell context should be limited: {info}")
_assert_summary(info, "open shell context", {"surface", "first_level_topology", "open_shell_context"})
_assert(
"完整抽壳/开口面编辑" in str(info.get("recognition_limited_actions") or ""),
f"open shell should list full shell/opening edit as limited: {info}",
)
_assert(
"完整抽壳/开口面" in str(info.get("recognition_limitations") or ""),
f"open shell limitation should explain unsupported complete shell edit: {info}",
)
_assert(
"壳体厚度" in str(info.get("recognition_ready_actions") or ""),
f"open shell wall should keep local shell thickness ready: {info}",
)
def main() -> int:
for key in (
"recognition_summary",
@@ -318,6 +406,9 @@ def main() -> int:
_verify_torus_summary(root)
_verify_user_priority_scan_order(root)
_verify_ellipse_edge_scan_entries(root)
_verify_complex_slot_guard(root)
_verify_mixed_radius_fillet_chain_guard(root)
_verify_open_shell_context_summary(root)
print("feature recognition summary ok")
return 0
+111
View File
@@ -10,10 +10,13 @@ for path in (PROJECT_ROOT, SCRIPTS_DIR):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from step_editor.geometry_utils import _int_values
from step_editor.model import StepModel
from step_editor.ui_helpers import INFO_LABELS
from step_editor.window_state import WindowStateMixin
from verify_face_coplanar_push_pull import _top_faces, _write_split_top_box # noqa: E402
from verify_face_mixed_surface_guard import _top_planar_cap_face, _write_hollow_cylinder # noqa: E402
from verify_hole_resize import _first_hole_face, _write_through_hole_model # noqa: E402
from verify_shell_thickness_resize import _first_shell_face, _write_plate_model # noqa: E402
from verify_slot_resize import _first_slot_face, _write_half_round_slot_model # noqa: E402
@@ -80,6 +83,7 @@ def _assert_common_facts(facts: dict[str, object], label: str) -> None:
ignored = tuple(facts.get("first_level_fact_ignored_relation_depths", ()) or ())
_assert("second-level" in ignored and "third-level" in ignored, f"{label}: ignored depths are missing")
_assert(str(facts.get("first_level_fact_summary") or ""), f"{label}: summary is missing")
_assert("first_level_planar_relation_summary" in facts, f"{label}: planar relation summary is missing")
def _assert_plan_facts(plan: dict[str, object], label: str, scope: str) -> None:
@@ -104,6 +108,35 @@ def _verify_planar_face_facts() -> None:
_assert(int(facts.get("first_level_fact_boundary_vertex_count", 0) or 0) == 4, f"cube vertex count: {facts}")
_assert(int(facts.get("first_level_fact_adjacent_face_count", 0) or 0) == 4, f"cube adjacent count: {facts}")
_assert(int(facts.get("first_level_fact_included_face_count", 0) or 0) == 5, f"cube included count: {facts}")
_assert(
facts.get("first_level_planar_relation_status") == "ready",
f"cube planar Face should expose planar relation facts: {facts}",
)
_assert(
int(facts.get("first_level_planar_relation_count", 0) or 0) == 4,
f"cube top Face should have four subject-adjacent planar relations: {facts}",
)
_assert(
int(facts.get("first_level_planar_relation_perpendicular_count", 0) or 0) == 4,
f"cube top Face should have four perpendicular adjacent side Faces: {facts}",
)
_assert(
"一级平面关系" in str(facts.get("first_level_fact_summary") or ""),
f"cube planar Face fact summary should include planar relation facts: {facts}",
)
_assert(facts.get("first_level_same_domain_status") == "ready", f"cube same-domain status: {facts}")
_assert(
facts.get("first_level_same_domain_relation") == "coplanar",
f"cube same-domain relation should be coplanar: {facts}",
)
_assert(
int(facts.get("first_level_same_domain_face_count", 0) or 0) == 1,
f"cube should expose a single same-domain planar Face: {facts}",
)
_assert(
"first_level_coaxial_cylinder_summary" in facts,
f"cube should still expose coaxial-cylinder fact fields: {facts}",
)
probe = _FactProbe(model)
fields = probe._face_first_level_selection_fields(face_id)
@@ -137,6 +170,80 @@ def _verify_planar_face_facts() -> None:
_assert_plan_facts(plan, label, "face")
def _verify_coplanar_same_domain_facts(root: Path) -> None:
split_path = root / "split_top_box.step"
_write_split_top_box(split_path)
model = StepModel.load(split_path)
top_faces = _top_faces(model, 10.0, 2e-4)
_assert(len(top_faces) == 2, f"split-top box should have two coplanar top Faces, got {top_faces}")
face_id = top_faces[0]
facts = model.face_first_level_facts(face_id, scope="face")
_assert_common_facts(facts, "split coplanar Face")
_assert(
facts.get("first_level_same_domain_status") == "ready",
f"split coplanar Face should expose same-domain facts: {facts}",
)
_assert(
facts.get("first_level_same_domain_relation") == "coplanar",
f"split coplanar Face should be marked coplanar: {facts}",
)
_assert(
set(_int_values(facts.get("first_level_same_domain_face_ids"))) == set(top_faces),
f"split coplanar facts should include both top Faces: {facts}",
)
_assert(
int(facts.get("first_level_same_domain_fragment_face_count", 0) or 0) == 1,
f"split coplanar facts should expose one synchronized fragment: {facts}",
)
_assert(
"共面" in str(facts.get("first_level_same_domain_summary") or ""),
f"split coplanar summary should mention coplanar relation: {facts}",
)
plan = model.push_pull_plan(face_id, 1.0)
_assert_plan_facts(plan, "split coplanar push-pull plan", "face")
_assert(
set(_int_values(plan.get("first_level_same_domain_face_ids"))) == set(top_faces),
f"split coplanar plan should carry same-domain facts: {plan}",
)
def _verify_coaxial_cylinder_facts(root: Path) -> None:
hollow_path = root / "hollow_cylinder.step"
_write_hollow_cylinder(hollow_path)
model = StepModel.load(hollow_path)
cap_face_id = _top_planar_cap_face(model)
facts = model.face_first_level_facts(cap_face_id, scope="face")
_assert_common_facts(facts, "hollow cylinder cap")
_assert(
facts.get("first_level_coaxial_cylinder_status") == "ready",
f"hollow cylinder cap should expose ready coaxial-cylinder facts: {facts}",
)
_assert(
int(facts.get("first_level_coaxial_cylinder_count", 0) or 0) >= 1,
f"hollow cylinder cap should have at least one coaxial cylinder relation: {facts}",
)
_assert(
int(facts.get("first_level_coaxial_cylinder_face_count", 0) or 0) >= 2,
f"hollow cylinder cap should include inner and outer cylindrical Faces: {facts}",
)
rows = tuple(row for row in facts.get("first_level_coaxial_cylinder_rows", ()) or () if isinstance(row, dict))
radii = sorted({round(float(radius), 4) for row in rows for radius in tuple(row.get("radii") or ())})
_assert(radii == [3.0, 8.0], f"hollow cylinder cap should expose inner/outer coaxial radii, got {radii}: {facts}")
_assert(
"同轴圆柱" in str(facts.get("first_level_coaxial_cylinder_summary") or ""),
f"hollow cylinder cap summary should mention coaxial cylinders: {facts}",
)
plan = model.push_pull_plan(cap_face_id, 1.0)
_assert_plan_facts(plan, "hollow cylinder cap push-pull plan", "face")
_assert(
plan.get("first_level_coaxial_cylinder_status") == "ready",
f"hollow cylinder cap plan should carry coaxial-cylinder facts: {plan}",
)
def _verify_shell_plan_facts(root: Path) -> None:
shell_path = root / "thin_plate.step"
_write_plate_model(shell_path)
@@ -200,12 +307,16 @@ def main() -> int:
"first_level_fact_summary",
"first_level_fact_subject_face_count",
"first_level_fact_adjacent_face_count",
"first_level_same_domain_summary",
"first_level_coaxial_cylinder_summary",
):
_assert(key in INFO_LABELS, f"{key} should have a user-facing label")
_verify_planar_face_facts()
with tempfile.TemporaryDirectory(prefix="geom_param_first_level_facts_") as temp_dir:
root = Path(temp_dir)
_verify_coplanar_same_domain_facts(root)
_verify_coaxial_cylinder_facts(root)
_verify_shell_plan_facts(root)
_verify_cylindrical_facts(root)
_verify_slot_facts(root)
@@ -136,7 +136,11 @@ def main() -> int:
face_id = _large_stepped_cap_face(model)
logical_id = model.face_region_logical_id(face_id)
before_topology = _face_topology_counts(model, face_id)
started = time.perf_counter()
plan = model.push_pull_plan(face_id, 89.0)
plan_elapsed = time.perf_counter() - started
if plan_elapsed > 5.0:
raise SystemExit(f"large stepped cap push/pull plan took too long: {plan_elapsed:.3f}s; plan={plan}")
if plan.get("cylindrical_cap_extension_kind") != "coaxial-stepped-cap":
raise SystemExit(f"large stepped cap should be recognized as stepped cap: {plan}")
if plan.get("cylindrical_cap_extension_method") != "local-shell-rebuild":
@@ -432,7 +436,8 @@ def main() -> int:
print(
"large stepped cap push/pull ok: "
f"face_id={face_id}, elapsed={elapsed:.3f}s, isolated_elapsed={isolated_elapsed:.3f}s, "
f"face_id={face_id}, plan_elapsed={plan_elapsed:.3f}s, "
f"elapsed={elapsed:.3f}s, isolated_elapsed={isolated_elapsed:.3f}s, "
f"topology_before={before_topology}, topology_after={after_topology}, result={result}"
)
print(
+57
View File
@@ -146,6 +146,62 @@ def assert_rectangular_feature_ui(
raise AssertionError(f"unexpected rectangular feature UI parameters: {actual}")
def find_multistep_transition_face(model: StepModel) -> tuple[int, dict[str, object]]:
matches: list[tuple[int, dict[str, object]]] = []
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("multistep_prismatic_status") != "blocked":
continue
center = info.get("area_center")
z_value = float(center[2]) if isinstance(center, (list, tuple)) and len(center) >= 3 else -1.0
matches.append((face_id, info | {"_z_sort": z_value}))
if not matches:
raise AssertionError("expected at least one blocked multistep transition face")
matches.sort(key=lambda item: float(item[1].get("_z_sort", -1.0)), reverse=True)
face_id, info = matches[0]
info.pop("_z_sort", None)
return face_id, info
def assert_multistep_transition_guard(model: StepModel) -> None:
face_id, info = find_multistep_transition_face(model)
blockers = str(info.get("multistep_prismatic_blockers") or info.get("recognition_blockers") or "")
if "多台阶" not in blockers or "暂不支持" not in blockers:
raise AssertionError(f"multistep transition blocker should explain unsupported scope: {info}")
if "复杂多台阶凸台" not in str(info.get("feature_type")):
raise AssertionError(f"unexpected multistep transition label: {info.get('feature_type')}")
if feature_dimension_rows(model, face_id, info) != ():
raise AssertionError("blocked multistep transition face should not expose editable dimensions")
target_center = info.get("area_center")
if not isinstance(target_center, (list, tuple)) or len(target_center) != 3:
raise AssertionError(f"multistep transition face lacks a stable center: {info}")
center_target = (float(target_center[0]) + 0.5, float(target_center[1]), float(target_center[2]))
plans = (
model.push_pull_plan(face_id, 1.0),
model.shell_thickness_plan(face_id, 2.5),
model.face_size_local_resize_plan(face_id, 9.0, "width"),
model.face_center_local_move_plan(face_id, center_target),
)
for plan in plans:
if plan.get("status") != "blocked":
raise AssertionError(f"multistep transition plan should be blocked: {plan}")
message = str(plan.get("message") or plan.get("blockers") or "")
if "多台阶" not in message or "暂不支持" not in message:
raise AssertionError(f"multistep transition plan should explain unsupported scope: {plan}")
leaked_entries = [
item
for item in model.editable_feature_candidates(limit=80, detailed=False)
if int(item.get("target_id", -1)) == face_id
and str(item.get("operation_key") or "") in {"push_pull_plane", "resize_shell_thickness"}
]
if leaked_entries:
raise AssertionError(
f"blocked multistep transition face should not leak editable scan entries: {leaked_entries}"
)
def assert_prismatic_feature_display_labels() -> None:
state = object.__new__(WindowStateMixin)
if state._feature_display_label("矩形口袋候选") != "矩形槽/口袋":
@@ -432,6 +488,7 @@ def main() -> int:
multistep_path = temp_path / "rectangular-multistep-boss.step"
write_rectangular_multistep_boss_model(multistep_path)
multistep_model = StepModel.load(multistep_path)
assert_multistep_transition_guard(multistep_model)
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(
+156 -3
View File
@@ -491,6 +491,40 @@ def main() -> int:
_assert_label(plane_specs, "local_face_width", "面内长度")
_assert_label(plane_specs, "local_face_height", "面内宽度")
_assert_label(plane_specs, "face_target_normal_position", "偏移")
offset_keep_relations = _scope_mode(plane_specs, "face_target_normal_position", "keep_relations")
if str(offset_keep_relations.get("label") or "") != "保持关系":
raise SystemExit(f"Face plane-offset keep-relations scope should be labelled clearly: {offset_keep_relations}")
if str(offset_keep_relations.get("action") or "") != "push_pull_face_keep_relations":
raise SystemExit(f"Face plane-offset keep-relations scope should use its own action: {offset_keep_relations}")
if not bool(offset_keep_relations.get("enabled", False)):
raise SystemExit(f"Face plane-offset keep-relations scope should be available on simple planar Face: {offset_keep_relations}")
width_keep_relations = _scope_mode(plane_specs, "local_face_width", "keep_relations")
if str(width_keep_relations.get("label") or "") != "保持关系":
raise SystemExit(f"Face width keep-relations scope should be labelled clearly: {width_keep_relations}")
if str(width_keep_relations.get("action") or "") != "resize_face_width_keep_relations":
raise SystemExit(f"Face width keep-relations scope should use its own action: {width_keep_relations}")
if not bool(width_keep_relations.get("enabled", False)):
raise SystemExit(f"Face width keep-relations scope should be available on simple planar Face: {width_keep_relations}")
height_keep_relations = _scope_mode(plane_specs, "local_face_height", "keep_relations")
if str(height_keep_relations.get("label") or "") != "保持关系":
raise SystemExit(f"Face height keep-relations scope should be labelled clearly: {height_keep_relations}")
if str(height_keep_relations.get("action") or "") != "resize_face_height_keep_relations":
raise SystemExit(f"Face height keep-relations scope should use its own action: {height_keep_relations}")
if not bool(height_keep_relations.get("enabled", False)):
raise SystemExit(f"Face height keep-relations scope should be available on simple planar Face: {height_keep_relations}")
center_keep_relations = _scope_mode(plane_specs, "face_center_position", "keep_relations")
if str(center_keep_relations.get("label") or "") != "保持关系":
raise SystemExit(f"Face center keep-relations scope should be labelled clearly: {center_keep_relations}")
if str(center_keep_relations.get("action") or "") != "move_selected_face_center_keep_relations":
raise SystemExit(f"Face center keep-relations scope should use its own action: {center_keep_relations}")
if not bool(center_keep_relations.get("enabled", False)):
raise SystemExit(f"Face center keep-relations scope should be available on simple planar Face: {center_keep_relations}")
_assert_scoped_hint_fragments(
plane_specs,
"face_target_normal_position",
"keep_relations",
("一级", "平行/垂直", "会直接阻止"),
)
_assert_actionable_rows_first(
plane_info,
(
@@ -568,21 +602,21 @@ 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 ("local_face_width", "local_face_height"):
for mode in ("local", "owning"):
for mode in ("local", "keep_relations", "owning"):
_assert_scoped_hint_fragments(
plane_specs,
key,
mode,
("5%", "5 倍", "会被阻止"),
)
for mode in ("push_pull", "local", "owning"):
for mode in ("push_pull", "keep_relations", "local", "owning"):
_assert_scoped_hint_fragments(
plane_specs,
"face_target_normal_position",
mode,
("会被阻止",),
)
for mode in ("local", "owning"):
for mode in ("local", "keep_relations", "owning"):
_assert_scoped_hint_fragments(
plane_specs,
"face_center_position",
@@ -788,6 +822,8 @@ def main() -> int:
"slot_chord_width_estimate": 6.0,
"slot_sagitta_depth_estimate": 3.0,
"slot_arc_length_estimate": 9.42477796076938,
"slot_status": "candidate",
"slot_kind": "partial-cylindrical-groove",
"area": 188.0,
"area_center": (0.0, 0.0, 0.0),
"bbox_center": (0.0, 0.0, 0.0),
@@ -815,6 +851,27 @@ def main() -> int:
("槽宽", "槽深", "轴心"),
"slot/half-hole feature",
)
blocked_slot_specs = _display_specs(
{
**slot_info,
"slot_status": "blocked",
"slot_blockers": "交叉槽/多槽组暂未实现稳定修改。",
"recognition_risk": "blocked",
"recognition_blockers": "交叉槽/多槽组暂未实现稳定修改。",
}
)
_assert_keys_absent(
blocked_slot_specs,
(
"slot_axis_center",
"slot_chord_width_estimate",
"slot_sagitta_depth_estimate",
"slot_arc_length_estimate",
"slot_angular_span_degrees",
"slot_open_angle_degrees",
),
"blocked complex slot display specs",
)
boss_info = {
"surface": "cylinder",
@@ -878,6 +935,94 @@ def main() -> int:
("工程特征", "倒圆角", "重新倒圆"),
"existing fillet feature",
)
fillet_arc_local = _scope_mode(fillet_specs, "existing_fillet_arc_length_estimate", "local")
if fillet_arc_local.get("action") != "resize_existing_fillet":
raise SystemExit(f"existing fillet arc length should rebuild existing fillet: {fillet_arc_local}")
if fillet_arc_local.get("target_transform") != "arc_length_to_radius":
raise SystemExit(f"existing fillet arc length should transform to target radius: {fillet_arc_local}")
chain_fillet_info = dict(fillet_info)
chain_fillet_info.update(
{
"feature_type": "简单等半径圆角链候选",
"feature_existing_fillet_chain_face_ids": (10, 11),
"feature_existing_fillet_chain_adjacent_face_ids": (11,),
"existing_fillet_chain_status": "same-radius-chain-candidate",
"existing_fillet_status": "candidate",
"existing_fillet_risk": "high",
}
)
chain_fillet_specs = _specs(chain_fillet_info)
chain_fillet_keys = {str(spec.get("key", "")) for spec in chain_fillet_specs}
_assert_contains(
chain_fillet_keys,
{"existing_fillet_radius_estimate", "existing_fillet_arc_length_estimate"},
"same-radius existing fillet chain feature",
)
chain_arc_local = _scope_mode(chain_fillet_specs, "existing_fillet_arc_length_estimate", "local")
if chain_arc_local.get("action") != "resize_existing_fillet":
raise SystemExit(f"same-radius fillet chain arc length should rebuild the chain: {chain_arc_local}")
if chain_arc_local.get("target_transform") != "arc_length_to_radius":
raise SystemExit(f"same-radius fillet chain arc length should transform to target radius: {chain_arc_local}")
blocked_fillet_info = dict(fillet_info)
blocked_fillet_info.update(
{
"existing_fillet_status": "blocked",
"existing_fillet_risk": "blocked",
"existing_fillet_blockers": "当前圆角与不同半径的圆角面直接相连,属于变半径圆角链。",
"feature_existing_fillet_chain_face_ids": (10, 11),
"feature_existing_fillet_mixed_radius_chain_face_ids": (11,),
}
)
blocked_fillet_keys = _spec_keys(blocked_fillet_info)
if "existing_fillet_radius_estimate" in blocked_fillet_keys or "existing_fillet_arc_length_estimate" in blocked_fillet_keys:
raise SystemExit(f"blocked fillet chain should not expose editable fillet dimensions: {blocked_fillet_keys}")
complex_same_radius_fillet_info = dict(fillet_info)
complex_same_radius_fillet_info.update(
{
"existing_fillet_status": "blocked",
"existing_fillet_risk": "blocked",
"existing_fillet_blockers": "当前圆角链包含至少 5 个同半径圆角面;复杂长链暂未实现稳定重建。",
"existing_fillet_chain_status": "complex-same-radius-chain-candidate",
"feature_existing_fillet_chain_face_ids": (10, 11, 12, 13, 14),
"feature_existing_fillet_same_radius_chain_face_ids": (11, 12, 13, 14),
}
)
complex_same_radius_fillet_keys = _spec_keys(complex_same_radius_fillet_info)
if (
"existing_fillet_radius_estimate" in complex_same_radius_fillet_keys
or "existing_fillet_arc_length_estimate" in complex_same_radius_fillet_keys
):
raise SystemExit(
"complex same-radius fillet chain should not expose editable fillet dimensions: "
f"{complex_same_radius_fillet_keys}"
)
chamfer_info = {
"kind": "feature",
"surface": "plane",
"feature_guess": "chamfer candidate",
"feature_type": "已有倒角平面候选",
"existing_chamfer_status": "candidate",
"existing_chamfer_distance_estimate": 1.5,
"existing_chamfer_cross_edge_length_estimate": 2.121320343559643,
"feature_existing_chamfer_support_face_ids": (1, 2),
"feature_existing_chamfer_long_edge_ids": (10, 11),
"local_face_width": 10.0,
"local_face_height": 2.121320343559643,
"area_center": (0.75, 0.75, 5.0),
"face_center_position": (0.75, 0.75, 5.0),
}
chamfer_specs = _specs(chamfer_info)
chamfer_keys = {str(spec.get("key", "")) for spec in chamfer_specs}
if "existing_chamfer_distance_estimate" not in chamfer_keys:
raise SystemExit(f"existing chamfer should expose distance: {chamfer_keys}")
for leaked_key in ("local_face_width", "local_face_height", "face_center_position", "face_target_normal_position"):
if leaked_key in chamfer_keys:
raise SystemExit(f"existing chamfer should not expose generic Face edit {leaked_key}: {chamfer_keys}")
_assert_label(chamfer_specs, "existing_chamfer_distance_estimate", "倒角距离")
chamfer_distance_spec = _spec(chamfer_specs, "existing_chamfer_distance_estimate")
if chamfer_distance_spec.get("action") != "resize_existing_chamfer":
raise SystemExit(f"existing chamfer distance should call resize_existing_chamfer: {chamfer_distance_spec}")
analytic_cases = (
(
@@ -1002,6 +1147,14 @@ def main() -> int:
{"length", "edge_first_level_topology"},
"line Edge",
)
keep_relation_mode = _scope_mode(edge_specs, "length", "keep-first-level-planar-relations")
if str(keep_relation_mode.get("label") or "") != "保持关系":
raise SystemExit(f"line Edge should expose a compact keep-relations scope: {keep_relation_mode}")
if not bool(keep_relation_mode.get("enabled")):
raise SystemExit(f"line Edge keep-relations scope should be enabled: {keep_relation_mode}")
keep_relation_tip = f"{keep_relation_mode.get('enabled_tip', '')} {keep_relation_mode.get('range_hint', '')}"
if "一级平面" not in keep_relation_tip or "平行/垂直" not in keep_relation_tip:
raise SystemExit(f"line Edge keep-relations scope should explain the planar relation constraint: {keep_relation_tip}")
_assert_contains(
{str(spec.get("key", "")) for spec in _edge_display_specs({"curve": "line", "length": 10.0})},
{"length", "edge_length_anchor_mode"},
+1 -1
View File
@@ -12,7 +12,7 @@ SCRIPT_DIR = PROJECT_ROOT / "scripts"
CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"Edge fillet/chamfer and existing fillet rebuild checks",
"Edge fillet/chamfer plus existing fillet/chamfer rebuild checks",
("verify_edge_round_chamfer.py",),
),
(
+123 -1
View File
@@ -5,7 +5,9 @@ from pathlib import Path
import sys
import tempfile
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
from OCC.Core.gp import gp_Pnt
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
@@ -20,6 +22,29 @@ def _write_plate_model(path: Path) -> None:
_write_step(shape, path)
def _fuse_shapes(left, right):
op = BRepAlgoAPI_Fuse(left, right)
op.SetFuzzyValue(1e-6)
op.Build()
if not op.IsDone():
raise RuntimeError("open shell fixture fuse failed")
return op.Shape()
def _write_open_thin_wall_box_model(path: Path) -> None:
bottom = BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape()
walls = (
BRepPrimAPI_MakeBox(gp_Pnt(0.0, 0.0, 2.0), 2.0, 20.0, 10.0).Shape(),
BRepPrimAPI_MakeBox(gp_Pnt(28.0, 0.0, 2.0), 2.0, 20.0, 10.0).Shape(),
BRepPrimAPI_MakeBox(gp_Pnt(2.0, 0.0, 2.0), 26.0, 2.0, 10.0).Shape(),
BRepPrimAPI_MakeBox(gp_Pnt(2.0, 18.0, 2.0), 26.0, 2.0, 10.0).Shape(),
)
shape = bottom
for wall in walls:
shape = _fuse_shapes(shape, wall)
_write_step(shape, path)
def _first_shell_face(model: StepModel, thickness: float, tolerance: float) -> int:
candidates: list[tuple[int, int]] = []
for face_id in range(len(model.faces)):
@@ -258,9 +283,101 @@ def _run_case(mode: str, source_thickness: float, target_thickness: float, toler
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _first_open_shell_wall_face(model: StepModel, thickness: float, tolerance: float) -> int:
open_shell_faces: list[int] = []
candidates: list[tuple[int, int]] = []
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("open_shell_context_status") == "limited":
open_shell_faces.append(face_id)
if info.get("surface") != "plane":
continue
if info.get("shell_region_status") != "candidate":
continue
if info.get("open_shell_context_status") != "limited":
continue
current = float(info.get("shell_thickness_estimate") or 0.0)
if abs(current - thickness) > tolerance:
continue
confidence_rank = {"high": 0, "medium": 1, "low": 2}.get(str(info.get("shell_confidence")), 3)
candidates.append((confidence_rank, face_id))
if not open_shell_faces:
raise SystemExit("open thin-wall box should expose an open_shell limited context")
if not candidates:
raise SystemExit(f"no open shell wall face with thickness near {thickness:g}")
candidates.sort()
return candidates[0][1]
def _assert_no_open_shell_context(model: StepModel, label: str) -> None:
leaked = [
face_id
for face_id in range(len(model.faces))
if model.feature_info(face_id).get("open_shell_context_status") == "limited"
]
if leaked:
raise SystemExit(f"{label} should not be recognized as open shell context: {leaked}")
def _run_open_shell_context_case(source_thickness: float, target_thickness: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_open_shell_context_") as temp_dir:
root = Path(temp_dir)
open_path = root / "open_thin_wall_box.step"
_write_open_thin_wall_box_model(open_path)
model = StepModel.load(open_path)
face_id = _first_open_shell_wall_face(model, source_thickness, tolerance)
info = model.feature_info(face_id)
limited_actions = str(info.get("recognition_limited_actions") or "")
if "完整抽壳/开口面编辑" not in limited_actions:
raise SystemExit(f"open shell should show full shell/opening edit as limited: {info}")
ready_actions = str(info.get("recognition_ready_actions") or "")
if "壳体厚度" not in ready_actions:
raise SystemExit(f"open shell wall should keep local thickness editable: {info}")
evidence_keys = tuple(info.get("recognition_evidence_keys") or ())
if "open_shell_context" not in evidence_keys:
raise SystemExit(f"open shell recognition evidence should be explicit: {info}")
local_plan = model.shell_thickness_plan(face_id, target_thickness)
if local_plan.get("status") == "blocked":
raise SystemExit(f"open shell local thickness should remain available: {local_plan}")
if local_plan.get("open_shell_context_status") != "limited":
raise SystemExit(f"open shell fields should be present in local plan: {local_plan}")
if "完整抽壳/开口面" not in str(local_plan.get("message") or ""):
raise SystemExit(f"open shell local plan should explain the limitation: {local_plan}")
owning_plan = model.shell_thickness_owning_scale_plan(face_id, target_thickness)
if owning_plan.get("status") == "blocked":
raise SystemExit(f"open shell owning thickness should remain available: {owning_plan}")
if owning_plan.get("open_shell_context_status") != "limited":
raise SystemExit(f"open shell fields should be present in owning plan: {owning_plan}")
editable_candidates = model.editable_feature_candidates(limit=30, detailed=False)
shell_entries = [
item
for item in editable_candidates
if item.get("operation_key") == "resize_shell_thickness" and int(item.get("target_id", -1)) == face_id
]
if not shell_entries:
raise SystemExit(f"open shell wall should be visible as editable shell thickness candidate: {editable_candidates}")
shell_note = str(shell_entries[0].get("note") or "")
if "完整抽壳/开口面" not in shell_note:
raise SystemExit(f"open shell candidate should explain full shell limitation: {shell_entries[0]}")
closed_path = root / "closed_plate.step"
_write_plate_model(closed_path)
closed_model = StepModel.load(closed_path)
_assert_no_open_shell_context(closed_model, "closed plate")
print("open_shell_context=limited")
print(f"face_id={face_id}")
print(f"limited_actions={limited_actions}")
print(f"local_plan_status={local_plan.get('status')}")
print(f"owning_plan_status={owning_plan.get('status')}")
def main() -> int:
parser = argparse.ArgumentParser(description="Verify shell/thin-wall thickness edit operations.")
parser.add_argument("--mode", default="all", choices=["all", "local", "owning"])
parser.add_argument("--mode", default="all", choices=["all", "local", "owning", "open-shell-context"])
parser.add_argument("--source-thickness", type=float, default=2.0)
parser.add_argument("--target-thickness", type=float, default=3.0)
parser.add_argument("--tolerance", type=float, default=2e-4)
@@ -268,7 +385,12 @@ def main() -> int:
modes = ["local", "owning"] if args.mode == "all" else [args.mode]
for mode in modes:
if mode == "open-shell-context":
_run_open_shell_context_case(args.source_thickness, args.target_thickness, args.tolerance)
else:
_run_case(mode, args.source_thickness, args.target_thickness, args.tolerance)
if args.mode == "all":
_run_open_shell_context_case(args.source_thickness, args.target_thickness, args.tolerance)
return 0
+100 -6
View File
@@ -45,6 +45,32 @@ def _write_obround_slot_model(path: Path) -> None:
_write_step(shape, path)
def _write_cross_obround_slot_model(path: Path) -> None:
plate = BRepPrimAPI_MakeBox(40.0, 40.0, 6.0).Shape()
def capsule_tool_x() -> object:
axis_1 = gp_Ax2(gp_Pnt(14.0, 20.0, -1.0), gp_Dir(0.0, 0.0, 1.0))
axis_2 = gp_Ax2(gp_Pnt(26.0, 20.0, -1.0), gp_Dir(0.0, 0.0, 1.0))
cylinder_1 = BRepPrimAPI_MakeCylinder(axis_1, 3.0, 8.0).Shape()
cylinder_2 = BRepPrimAPI_MakeCylinder(axis_2, 3.0, 8.0).Shape()
connector = BRepPrimAPI_MakeBox(gp_Pnt(14.0, 17.0, -1.0), 12.0, 6.0, 8.0).Shape()
fuse_1 = _finalize_boolean_result(BRepAlgoAPI_Fuse(cylinder_1, connector), "verify cross slot x tool fuse")
return _finalize_boolean_result(BRepAlgoAPI_Fuse(fuse_1, cylinder_2), "verify cross slot x tool second fuse")
def capsule_tool_y() -> object:
axis_1 = gp_Ax2(gp_Pnt(20.0, 14.0, -1.0), gp_Dir(0.0, 0.0, 1.0))
axis_2 = gp_Ax2(gp_Pnt(20.0, 26.0, -1.0), gp_Dir(0.0, 0.0, 1.0))
cylinder_1 = BRepPrimAPI_MakeCylinder(axis_1, 3.0, 8.0).Shape()
cylinder_2 = BRepPrimAPI_MakeCylinder(axis_2, 3.0, 8.0).Shape()
connector = BRepPrimAPI_MakeBox(gp_Pnt(17.0, 14.0, -1.0), 6.0, 12.0, 8.0).Shape()
fuse_1 = _finalize_boolean_result(BRepAlgoAPI_Fuse(cylinder_1, connector), "verify cross slot y tool fuse")
return _finalize_boolean_result(BRepAlgoAPI_Fuse(fuse_1, cylinder_2), "verify cross slot y tool second fuse")
cut_x = _finalize_boolean_result(BRepAlgoAPI_Cut(plate, capsule_tool_x()), "verify cross slot x cut")
cut_xy = _finalize_boolean_result(BRepAlgoAPI_Cut(cut_x, capsule_tool_y()), "verify cross slot y cut")
_write_step(cut_xy, path)
def _slot_face_ids(model: StepModel) -> list[int]:
face_ids: list[int] = []
for face_id in range(len(model.faces)):
@@ -151,6 +177,69 @@ def _obround_total_lengths(model: StepModel) -> list[tuple[int, int, float, floa
return rows
def _complex_slot_face_ids(model: StepModel) -> list[int]:
face_ids: list[int] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "cylinder":
continue
feature = model.feature_info(face_id)
if feature.get("slot_kind") == "partial-cylindrical-groove" and feature.get("slot_status") == "blocked":
face_ids.append(face_id)
return face_ids
def _run_complex_slot_guard() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_cross_slot_guard_") as temp_dir:
model_path = Path(temp_dir) / "cross_obround_slot.step"
_write_cross_obround_slot_model(model_path)
model = StepModel.load(model_path)
face_ids = _complex_slot_face_ids(model)
if len(face_ids) < 4:
raise SystemExit(f"cross obround slot should expose blocked slot ends, got {face_ids}")
face_id = face_ids[0]
feature = model.feature_info(face_id)
blockers = str(feature.get("slot_blockers") or feature.get("recognition_blockers") or "")
if "交叉槽" not in blockers and "多槽组" not in blockers and "复杂草图槽" not in blockers:
raise SystemExit(f"cross slot blocker should explain the unsupported reason: {feature}")
if "复杂槽" not in str(feature.get("feature_type") or ""):
raise SystemExit(f"cross slot should be labeled as a complex slot: {feature.get('feature_type')}")
candidates = model.editable_feature_candidates(limit=80, detailed=False)
forbidden_actions = {
"resize_cylinder",
"resize_slot_width",
"resize_slot_depth",
"resize_slot_arc_length",
"resize_slot_angular_span",
}
leaked = [
(item.get("operation_key"), item.get("target_id"))
for item in candidates
if int(item.get("target_id", -1)) in face_ids and item.get("operation_key") in forbidden_actions
]
if leaked:
raise SystemExit(f"cross slot should not leak editable slot entries: {leaked}")
width_plan = model.cylindrical_slot_resize_plan(face_id, 7.0, "width")
angle_plan = model.cylindrical_slot_angular_span_plan(face_id, math.pi * 0.75)
current_center = _slot_axis_center(model, face_id)
axis_plan = model.cylindrical_slot_axis_move_plan(face_id, (current_center[0] + 1.0, current_center[1], current_center[2]))
total_plan = model.cylindrical_slot_total_length_plan(face_id, 16.0)
for label, plan in (
("width", width_plan),
("angle", angle_plan),
("axis", axis_plan),
("total_length", total_plan),
):
if plan.get("status") != "blocked":
raise SystemExit(f"cross slot {label} plan should be blocked: {plan}")
text = f"{plan.get('message')} {plan.get('blockers')}"
if "交叉槽" not in text and "多槽组" not in text and "复杂草图槽" not in text:
raise SystemExit(f"cross slot {label} plan should explain unsupported complex slot: {plan}")
print(f"complex_slot_guard=blocked_faces {face_ids}")
def _nearest_obround_total_length(model: StepModel, target_total_length: float) -> tuple[int, int, float, float, float]:
best: tuple[int, int, float, float, float] | None = None
for face_id, pair_face_id, total_length, center_distance in _obround_total_lengths(model):
@@ -404,6 +493,7 @@ def main() -> int:
"obround_axis_center",
"total_length",
"center_distance",
"complex_slot_guard",
],
help="Slot metric to verify.",
)
@@ -418,8 +508,8 @@ def main() -> int:
parser.add_argument("--tolerance", type=float, default=2e-4)
args = parser.parse_args()
cases = (
[
if args.mode == "all":
cases = [
("width", args.width),
("depth", args.depth),
("arc_length", args.arc_length),
@@ -428,12 +518,16 @@ def main() -> int:
("obround_axis_center", args.obround_axis_center),
("total_length", args.total_length),
("center_distance", args.center_distance),
("complex_slot_guard", 0.0),
]
if args.mode == "all"
else [(args.mode, getattr(args, args.mode.replace("-", "_")))]
)
elif args.mode == "complex_slot_guard":
cases = [("complex_slot_guard", 0.0)]
else:
cases = [(args.mode, getattr(args, args.mode.replace("-", "_")))]
for mode, target in cases:
if mode == "total_length":
if mode == "complex_slot_guard":
_run_complex_slot_guard()
elif mode == "total_length":
_run_total_length_case(float(target), args.tolerance)
elif mode == "center_distance":
_run_center_distance_case(float(target), args.tolerance)
+152 -22
View File
@@ -302,9 +302,11 @@ class FeatureMixin:
depth_count = 0
suppress_count = 0
existing_fillet_count = 0
existing_chamfer_count = 0
depth_limit = max(2, min(per_type_limit, limit // 12))
suppress_limit = max(2, min(per_type_limit, limit // 12))
existing_fillet_limit = max(2, min(per_type_limit, limit // 12))
existing_chamfer_limit = max(2, min(per_type_limit, limit // 12))
slot_width_limit = max(2, min(per_type_limit, limit // 10))
slot_depth_limit = max(2, min(per_type_limit, limit // 10))
slot_arc_length_limit = max(2, min(per_type_limit, limit // 10))
@@ -323,15 +325,27 @@ class FeatureMixin:
if existing_fillet_status == "blocked":
continue
support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ()))
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids", ()))
chain_status = str(feature.get("existing_fillet_chain_status") or "")
is_same_radius_chain = chain_status == "same-radius-chain-candidate" and len(chain_face_ids) > 1
support_note = (
f"支撑 Face: {support_face_ids}"
if support_face_ids
else "暂未识别出稳定支撑 Face。"
)
fillet_note = (
f"这是简单等半径圆角链候选,链上 Face: {chain_face_ids};点击后会选中并预填目标半径/弧长,"
"在特征参数表修改后点击“参数化建模”,会尝试一起 defeature 后重新倒圆。"
if is_same_radius_chain
else (
"这是已有圆角/倒圆候选;点击后会选中并预填目标半径/弧长,"
"在特征参数表修改后点击“参数化建模”,会尝试 defeature 后重新倒圆。"
)
)
candidates.append(
{
"operation_key": "inspect_existing_fillet",
"operation": "修改已有圆角半径",
"operation": "修改已有圆角半径/弧长" if is_same_radius_chain else "修改已有圆角半径/弧长",
"target_kind": "face",
"target_id": item["face_id"],
"face_id": item["face_id"],
@@ -342,13 +356,9 @@ class FeatureMixin:
"current_value": feature.get("existing_fillet_radius_estimate", item["radius"]),
"current_value_label": "radius",
"status": "caution",
"risk": "medium" if len(support_face_ids) >= 2 else "high",
"risk": "high" if is_same_radius_chain or len(support_face_ids) < 2 else "medium",
"confidence": item["confidence"],
"note": (
"这是已有圆角/倒圆候选;点击后会选中并预填目标半径,"
"再点击“修改已有圆角半径”会尝试 defeature 后重新倒圆。"
f" {support_note}"
),
"note": f"{fillet_note} {support_note}",
}
)
existing_fillet_count += 1
@@ -356,7 +366,7 @@ class FeatureMixin:
if diameter_count < per_type_limit and feature_guess not in {
"round/fillet candidate",
"boss/outer-round candidate",
}:
} and str(item.get("resize_status") or "") != "blocked":
candidates.append(
{
"operation_key": "resize_cylinder",
@@ -388,6 +398,8 @@ class FeatureMixin:
and float(item.get("angular_span", 0.0)) < math.tau * 0.92
):
feature = self.feature_info(int(item["face_id"]))
if str(feature.get("slot_status") or "") != "candidate":
continue
slot_width = feature.get("slot_chord_width_estimate")
if slot_width_count < slot_width_limit and isinstance(slot_width, (int, float)) and float(slot_width) > 0:
candidates.append(
@@ -666,17 +678,70 @@ class FeatureMixin:
plane_count = 0
shell_thickness_count = 0
shell_thickness_limit = max(2, min(per_type_limit, limit // 10))
shell_candidate_rows: list[dict[str, object]] = []
shell_scan_pool_limit = max(shell_thickness_limit * 4, 12)
face_scan_limit = len(self.faces) if max_scan_faces is None else min(len(self.faces), max(0, int(max_scan_faces)))
for face_id, face in enumerate(self.faces[:face_scan_limit]):
if progress_callback is not None and face_id % 30 == 0:
progress_callback()
if plane_count >= per_type_limit and shell_thickness_count >= shell_thickness_limit:
if (
plane_count >= per_type_limit
and len(shell_candidate_rows) >= shell_scan_pool_limit
and existing_chamfer_count >= existing_chamfer_limit
):
break
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
continue
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
feature: dict[str, object] | None = None
quick_info: dict[str, object] | None = None
if existing_chamfer_count < existing_chamfer_limit:
quick_info = self.quick_face_info(face_id)
width = _float_or_none(quick_info.get("local_face_width"))
height = _float_or_none(quick_info.get("local_face_height"))
aspect = max(width or 0.0, height or 0.0) / max(min(width or 0.0, height or 0.0), 1e-9)
if (
int(quick_info.get("boundary_edges", 0) or 0) == 4
and not bool(quick_info.get("has_inner_boundaries"))
and aspect >= 1.8
):
feature = self.feature_info(face_id)
if str(feature.get("existing_chamfer_status") or "") == "candidate":
support_face_ids = tuple(feature.get("feature_existing_chamfer_support_face_ids", ()))
long_edge_ids = tuple(feature.get("feature_existing_chamfer_long_edge_ids", ()))
candidates.append(
{
"operation_key": "inspect_existing_chamfer",
"operation": "修改已有倒角距离",
"target_kind": "face",
"target_id": face_id,
"face_id": face_id,
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"surface": "plane",
"feature_guess": "chamfer candidate",
"current_value": feature.get("existing_chamfer_distance_estimate"),
"current_value_label": "chamfer_distance",
"status": "caution",
"risk": str(feature.get("existing_chamfer_risk") or "medium"),
"confidence": str(feature.get("recognition_confidence") or "medium"),
"note": (
"这是简单已有倒角斜面;点击后会选中该 Face,并把倒角距离填入参数表。"
f" 支撑 Face: {support_face_ids}; 长边 Edge: {long_edge_ids}"
),
}
)
existing_chamfer_count += 1
continue
if plane_count < per_type_limit or len(shell_candidate_rows) < shell_scan_pool_limit:
quick_info = quick_info or self.quick_face_info(face_id)
boundary_edges = int(quick_info.get("boundary_edges", 0) or 0)
if bool(quick_info.get("has_inner_boundaries")) or boundary_edges > 4:
feature = feature or self.feature_info(face_id)
if feature.get("multistep_prismatic_status") == "blocked":
continue
if plane_count < per_type_limit:
if detailed:
direction_info = self._plane_push_pull_direction(face_id, surf)
@@ -709,12 +774,21 @@ class FeatureMixin:
}
)
plane_count += 1
if shell_thickness_count < shell_thickness_limit:
feature = self.feature_info(face_id)
if len(shell_candidate_rows) < shell_scan_pool_limit:
feature = feature or self.feature_info(face_id)
if feature.get("shell_region_status") == "candidate":
shell_confidence = str(feature.get("shell_confidence", "low"))
shell_risk = "low" if shell_confidence == "high" else "medium" if shell_confidence == "medium" else "high"
candidates.append(
shell_note = (
"快速扫描:已找到投影重叠的相对平面;点击后会填入参考目标厚度,"
"执行时会移动当前平面区域来改变局部壳体厚度。"
)
if feature.get("open_shell_context_status") == "limited":
shell_note = (
f"{shell_note} 已识别为开口薄壁壳体上下文;"
"完整抽壳/开口面编辑暂未实现,当前只开放壳体厚度、平面推拉或整体缩放。"
)
shell_candidate_rows.append(
{
"operation_key": "resize_shell_thickness",
"operation": "调整壳体厚度",
@@ -730,12 +804,25 @@ class FeatureMixin:
"status": "ready" if shell_confidence == "high" else "caution",
"risk": shell_risk,
"confidence": shell_confidence,
"note": (
"快速扫描:已找到投影重叠的相对平面;点击后会填入参考目标厚度,"
"执行时会移动当前平面区域来改变局部壳体厚度。"
),
"note": shell_note,
"open_shell_context_status": feature.get("open_shell_context_status", ""),
}
)
def _shell_candidate_sort_key(item: dict[str, object]) -> tuple[int, int, int, float, int]:
confidence_rank = {"high": 0, "medium": 1, "low": 2}.get(str(item.get("confidence") or ""), 3)
risk_rank = {"low": 0, "medium": 1, "high": 2, "blocked": 3}.get(str(item.get("risk") or ""), 3)
thickness = _float_or_none(item.get("current_value"))
return (
0 if item.get("open_shell_context_status") == "limited" else 1,
confidence_rank,
risk_rank,
thickness if thickness is not None else float("inf"),
int(item.get("face_id", 10**9) or 10**9),
)
for shell_candidate in sorted(shell_candidate_rows, key=_shell_candidate_sort_key)[:shell_thickness_limit]:
candidates.append(shell_candidate)
shell_thickness_count += 1
fillet_edge_count = 0
@@ -959,6 +1046,32 @@ class FeatureMixin:
candidate.update(self._cylinder_end_opening_info(face_id, surf))
candidate.update(_cylinder_resize_readiness(candidate))
candidate.update(_cylinder_boss_resize_readiness(candidate))
try:
feature = self.feature_info(face_id)
for key in (
"feature_guess",
"feature_type",
"feature_edit_actions",
"confidence",
"slot_kind",
"slot_status",
"slot_risk",
"slot_blockers",
"resize_status",
"resize_risk",
"resize_blockers",
"resize_note",
"boss_resize_status",
"boss_resize_risk",
"boss_resize_blockers",
"boss_resize_note",
"recognition_risk",
"recognition_blockers",
):
if feature.get(key) not in {None, ""}:
candidate[key] = feature.get(key)
except Exception:
pass
candidates.append(candidate)
if len(candidates) >= limit:
break
@@ -976,6 +1089,24 @@ class FeatureMixin:
}
current_diameter = float(info["diameter"])
feature = self.feature_info(face_id)
if str(feature.get("slot_status") or "") == "blocked":
blocker = str(feature.get("slot_blockers") or "当前槽/半孔属于复杂槽或多槽组,暂不开放稳定修改。")
return {
"status": "blocked",
"risk": "blocked",
"message": blocker,
"warnings": "",
"blockers": blocker,
"face_id": face_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id"),
"current_diameter": current_diameter,
"target_diameter": new_diameter,
"feature_type": feature.get("feature_type"),
"feature_guess": feature.get("feature_guess"),
"slot_status": feature.get("slot_status"),
"slot_blockers": blocker,
}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
axis_range = self._cylindrical_axis_range(
face_id,
@@ -1271,8 +1402,7 @@ class FeatureMixin:
if angular_span is None or angular_span <= 1e-6 or angular_span >= math.tau * 0.92:
blockers.append("Selected slot does not have a stable partial-cylinder angular span.")
if slot_status and slot_status != "candidate":
risk = _max_risk(risk, "high")
warnings.append(f"Slot candidate status is {slot_status}.")
blockers.append(str(feature.get("slot_blockers") or info.get("slot_blockers") or f"Slot candidate status is {slot_status}."))
span = min(max(float(angular_span or 0.0), 1e-6), math.tau - 1e-6)
sin_half_span = math.sin(span / 2.0)
@@ -1464,8 +1594,7 @@ class FeatureMixin:
if target_angular_span >= math.tau * 0.92:
blockers.append("Target slot angular span must remain below a near-full cylinder.")
if slot_status and slot_status != "candidate":
risk = _max_risk(risk, "high")
warnings.append(f"Slot candidate status is {slot_status}.")
blockers.append(str(feature.get("slot_blockers") or info.get("slot_blockers") or f"Slot candidate status is {slot_status}."))
delta_span = None if current_span <= 0 else target_angular_span - current_span
delta_ratio = (
@@ -1676,8 +1805,7 @@ class FeatureMixin:
risk = _max_risk(risk, "medium")
warnings.append("Slot recognition confidence is low.")
if slot_status and slot_status != "candidate":
risk = _max_risk(risk, "high")
warnings.append(f"Slot candidate status is {slot_status}.")
blockers.append(str(feature.get("slot_blockers") or info.get("slot_blockers") or f"Slot candidate status is {slot_status}."))
cutter_plan: dict[str, object] = {}
fill_plan: dict[str, object] = {}
@@ -2053,6 +2181,8 @@ class FeatureMixin:
blockers.append("Current slot face is not recognized as one end of a paired obround slot.")
else:
blockers.append("Manual paired Face ID could not be used as a compatible obround slot end.")
if str(feature.get("slot_status") or "") not in {"", "candidate"}:
blockers.append(str(feature.get("slot_blockers") or f"Slot candidate status is {feature.get('slot_status')}."))
if target_total_length <= 0:
blockers.append("Target slot total length must be greater than 0.")
+14
View File
@@ -24,6 +24,8 @@ def _point3(value: object, operation: str) -> tuple[float, float, float]:
def _execute(model: StepModel, operation: str, args: list[object]) -> str:
if operation == "push_pull_face":
return model.push_pull_face(int(args[0]), float(args[1]))
if operation == "push_pull_face_keep_relations":
return model.push_pull_face_keep_relations(int(args[0]), float(args[1]))
if operation == "move_face_plane_offset_local":
return model.move_face_plane_offset_local(int(args[0]), float(args[1]))
if operation == "resize_face_area_local":
@@ -32,6 +34,8 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
return model.resize_face_area(int(args[0]), float(args[1]))
if operation == "resize_face_size_local":
return model.resize_face_size_local(int(args[0]), float(args[1]), str(args[2]))
if operation == "resize_face_size_local_keep_relations":
return model.resize_face_size_local_keep_relations(int(args[0]), float(args[1]), str(args[2]))
if operation == "resize_face_size_owning_scale":
return model.resize_face_size_owning_scale(int(args[0]), float(args[1]), str(args[2]))
if operation == "move_face_center_local":
@@ -42,6 +46,14 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
int(args[0]),
(float(center[0]), float(center[1]), float(center[2])),
)
if operation == "move_face_center_local_keep_relations":
center = list(args[1])
if len(center) != 3:
raise ValueError("move_face_center_local_keep_relations requires a 3D target center.")
return model.move_face_center_local_keep_relations(
int(args[0]),
(float(center[0]), float(center[1]), float(center[2])),
)
if operation == "resize_shell_thickness":
return model.resize_shell_thickness(int(args[0]), float(args[1]))
if operation == "resize_shell_thickness_owning_scale":
@@ -117,6 +129,8 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
return model.resize_ellipse_edge_axis_radius(int(args[0]), float(args[1]), axis_kind=axis_kind)
if operation == "resize_existing_fillet":
return model.resize_existing_fillet(int(args[0]), float(args[1]))
if operation == "resize_existing_chamfer":
return model.resize_existing_chamfer(int(args[0]), float(args[1]))
if operation == "fillet_edge":
return model.fillet_edge(int(args[0]), float(args[1]))
if operation == "chamfer_edge":
+1252 -43
View File
File diff suppressed because it is too large Load Diff
+936 -59
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -17,6 +17,7 @@ USER_OPERATION_PRIORITY: dict[str, int] = {
"resize_boss_height": 41,
"move_boss_axis": 44,
"inspect_existing_fillet": 50,
"inspect_existing_chamfer": 51,
"fillet_edge": 52,
"chamfer_edge": 53,
"resize_shell_thickness": 60,
@@ -77,6 +78,9 @@ def feature_recognition_priority(info: Mapping[str, object]) -> int:
combined_text = "".join(item for item in (feature_type, feature_actions, ready_actions) if item)
if _text(info.get("existing_chamfer_status")) == "candidate" or feature_guess == "chamfer candidate":
return 50
if surface == "plane":
return 10
+184
View File
@@ -79,6 +79,63 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [
"first_level_fact_ignored_relation_depths",
"first_level_fact_ignored_relation_note",
"first_level_fact_summary",
"first_level_planar_relation_status",
"first_level_planar_relation_scope",
"first_level_planar_relation_count",
"first_level_planar_relation_parallel_count",
"first_level_planar_relation_perpendicular_count",
"first_level_planar_relation_angled_count",
"first_level_planar_relation_face_face_count",
"first_level_planar_relation_edge_axis_count",
"first_level_planar_relation_rows",
"first_level_planar_relation_observed_modes",
"first_level_planar_relation_summary",
"first_level_planar_relation_note",
"first_level_same_domain_status",
"first_level_same_domain_scope",
"first_level_same_domain_relation",
"first_level_same_domain_relation_label",
"first_level_same_domain_surface",
"first_level_same_domain_face_ids",
"first_level_same_domain_face_count",
"first_level_same_domain_fragment_face_ids",
"first_level_same_domain_fragment_face_count",
"first_level_same_domain_summary",
"first_level_same_domain_note",
"first_level_coaxial_cylinder_status",
"first_level_coaxial_cylinder_scope",
"first_level_coaxial_cylinder_count",
"first_level_coaxial_cylinder_face_ids",
"first_level_coaxial_cylinder_face_count",
"first_level_coaxial_cylinder_candidate_face_ids",
"first_level_coaxial_cylinder_candidate_face_count",
"first_level_coaxial_cylinder_rows",
"first_level_coaxial_cylinder_summary",
"first_level_coaxial_cylinder_note",
"face_push_pull_planar_relation_constraint_requested",
"face_push_pull_planar_relation_constraint_label",
"face_push_pull_planar_constraint_status",
"face_push_pull_planar_constraint_summary",
"face_push_pull_planar_constraint_relation_count",
"face_push_pull_planar_constraint_parallel_count",
"face_push_pull_planar_constraint_perpendicular_count",
"face_push_pull_planar_constraint_angled_count",
"face_push_pull_planar_constraint_adjacent_face_count",
"face_push_pull_planar_constraint_non_planar_face_ids",
"face_push_pull_planar_constraint_observed_modes",
"face_push_pull_planar_constraint_blockers",
"face_push_pull_planar_constraint_warnings",
"edge_length_planar_relation_constraint_requested",
"edge_length_planar_relation_constraint_label",
"edge_length_planar_constraint_status",
"edge_length_planar_constraint_summary",
"edge_length_planar_constraint_side_face_count",
"edge_length_planar_constraint_parallel_count",
"edge_length_planar_constraint_perpendicular_count",
"edge_length_planar_constraint_angled_count",
"edge_length_planar_constraint_observed_modes",
"edge_length_planar_constraint_blockers",
"edge_length_planar_constraint_warnings",
"selected_edge_ids",
"selected_edge_count",
"first_level_vertex_points",
@@ -146,6 +203,11 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [
"existing_fillet_radius_estimate",
"existing_fillet_angular_span",
"existing_fillet_arc_length_estimate",
"existing_chamfer_distance_estimate",
"existing_chamfer_cross_edge_length_estimate",
"existing_chamfer_long_edge_length_estimate",
"existing_chamfer_support_angle_degrees",
"existing_chamfer_face_angle_degrees",
"current_depth",
"target_depth",
"delta_depth",
@@ -219,6 +281,25 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [
"shell_movement_alignment",
"shell_note",
"shell_region_note",
"open_shell_context_status",
"open_shell_kind",
"open_shell_axis",
"open_shell_side",
"open_shell_open_direction",
"open_shell_wall_thickness_estimate",
"open_shell_open_side_area_ratio",
"open_shell_closed_side_area_ratio",
"open_shell_current_face_role",
"open_shell_current_face_id",
"open_shell_rim_face_ids",
"open_shell_closed_side_face_ids",
"open_shell_inner_face_ids",
"open_shell_thin_wall_face_ids",
"open_shell_related_face_ids",
"open_shell_highlight_face_ids",
"open_shell_limited_action",
"open_shell_blockers",
"open_shell_note",
],
),
(
@@ -293,6 +374,17 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [
"existing_fillet_kind",
"existing_fillet_status",
"existing_fillet_note",
"feature_existing_chamfer_face_ids",
"feature_existing_chamfer_support_face_ids",
"feature_existing_chamfer_end_face_ids",
"feature_existing_chamfer_short_edge_ids",
"feature_existing_chamfer_long_edge_ids",
"existing_chamfer_kind",
"existing_chamfer_status",
"existing_chamfer_risk",
"existing_chamfer_warnings",
"existing_chamfer_blockers",
"existing_chamfer_note",
"feature_edit_actions",
"resize_status",
"resize_strategy",
@@ -496,6 +588,63 @@ INFO_LABELS = {
"first_level_fact_ignored_relation_depths": "暂不传播层级",
"first_level_fact_ignored_relation_note": "暂不传播说明",
"first_level_fact_summary": "一级事实摘要",
"first_level_planar_relation_status": "一级平面关系状态",
"first_level_planar_relation_scope": "一级平面关系范围",
"first_level_planar_relation_count": "一级平面关系数",
"first_level_planar_relation_parallel_count": "一级平行关系数",
"first_level_planar_relation_perpendicular_count": "一级垂直关系数",
"first_level_planar_relation_angled_count": "一级斜交关系数",
"first_level_planar_relation_face_face_count": "Face-Face 平面关系数",
"first_level_planar_relation_edge_axis_count": "Face-Edge 方向关系数",
"first_level_planar_relation_rows": "一级平面关系列表",
"first_level_planar_relation_observed_modes": "已识别平面关系",
"first_level_planar_relation_summary": "一级平面关系摘要",
"first_level_planar_relation_note": "一级平面关系说明",
"first_level_same_domain_status": "一级同域状态",
"first_level_same_domain_scope": "一级同域范围",
"first_level_same_domain_relation": "一级同域关系",
"first_level_same_domain_relation_label": "一级同域关系名称",
"first_level_same_domain_surface": "一级同域曲面",
"first_level_same_domain_face_ids": "一级同域 Face",
"first_level_same_domain_face_count": "一级同域 Face 数",
"first_level_same_domain_fragment_face_ids": "一级同域碎片 Face",
"first_level_same_domain_fragment_face_count": "一级同域碎片数",
"first_level_same_domain_summary": "一级同域摘要",
"first_level_same_domain_note": "一级同域说明",
"first_level_coaxial_cylinder_status": "一级同轴圆柱状态",
"first_level_coaxial_cylinder_scope": "一级同轴圆柱范围",
"first_level_coaxial_cylinder_count": "一级同轴圆柱关系数",
"first_level_coaxial_cylinder_face_ids": "一级同轴圆柱 Face",
"first_level_coaxial_cylinder_face_count": "一级同轴圆柱 Face 数",
"first_level_coaxial_cylinder_candidate_face_ids": "一级圆柱候选 Face",
"first_level_coaxial_cylinder_candidate_face_count": "一级圆柱候选数",
"first_level_coaxial_cylinder_rows": "一级同轴圆柱关系列表",
"first_level_coaxial_cylinder_summary": "一级同轴圆柱摘要",
"first_level_coaxial_cylinder_note": "一级同轴圆柱说明",
"face_push_pull_planar_relation_constraint_requested": "Face保持关系已请求",
"face_push_pull_planar_relation_constraint_label": "Face保持关系约束",
"face_push_pull_planar_constraint_status": "Face保持关系状态",
"face_push_pull_planar_constraint_summary": "Face保持关系摘要",
"face_push_pull_planar_constraint_relation_count": "Face保持关系数",
"face_push_pull_planar_constraint_parallel_count": "Face保持平行关系数",
"face_push_pull_planar_constraint_perpendicular_count": "Face保持垂直关系数",
"face_push_pull_planar_constraint_angled_count": "Face保持斜交关系数",
"face_push_pull_planar_constraint_adjacent_face_count": "Face保持相邻面数",
"face_push_pull_planar_constraint_non_planar_face_ids": "Face保持非平面相邻面",
"face_push_pull_planar_constraint_observed_modes": "Face保持已识别关系",
"face_push_pull_planar_constraint_blockers": "Face保持关系阻止原因",
"face_push_pull_planar_constraint_warnings": "Face保持关系警告",
"edge_length_planar_relation_constraint_requested": "已请求保持一级平面关系",
"edge_length_planar_relation_constraint_label": "一级平面关系约束",
"edge_length_planar_constraint_status": "端面推拉约束状态",
"edge_length_planar_constraint_summary": "端面推拉约束摘要",
"edge_length_planar_constraint_side_face_count": "端面推拉侧面数",
"edge_length_planar_constraint_parallel_count": "端面推拉平行关系数",
"edge_length_planar_constraint_perpendicular_count": "端面推拉垂直关系数",
"edge_length_planar_constraint_angled_count": "端面推拉斜交关系数",
"edge_length_planar_constraint_observed_modes": "端面推拉已识别关系",
"edge_length_planar_constraint_blockers": "端面推拉阻止原因",
"edge_length_planar_constraint_warnings": "端面推拉警告",
"selected_edge_ids": "当前 Edge",
"selected_edge_count": "当前 Edge 数",
"first_level_vertex_points": "一级端点 Vertex",
@@ -671,6 +820,25 @@ INFO_LABELS = {
"shell_movement_alignment": "壳体移动方向匹配度",
"shell_note": "壳体识别说明",
"shell_region_note": "壳体识别说明",
"open_shell_context_status": "开口壳体上下文状态",
"open_shell_kind": "开口壳体类型",
"open_shell_axis": "开口方向轴",
"open_shell_side": "开口侧",
"open_shell_open_direction": "开口方向",
"open_shell_wall_thickness_estimate": "开口壳体壁厚估算",
"open_shell_open_side_area_ratio": "开口侧面积占比",
"open_shell_closed_side_area_ratio": "封闭侧面积占比",
"open_shell_current_face_role": "当前 Face 壳体角色",
"open_shell_current_face_id": "当前开口壳体 Face",
"open_shell_rim_face_ids": "开口边界 Face",
"open_shell_closed_side_face_ids": "封闭侧 Face",
"open_shell_inner_face_ids": "内壁/内底 Face",
"open_shell_thin_wall_face_ids": "薄壁 Face",
"open_shell_related_face_ids": "开口壳体相关 Face",
"open_shell_highlight_face_ids": "开口壳体高亮 Face",
"open_shell_limited_action": "受限壳体操作",
"open_shell_blockers": "开口壳体限制原因",
"open_shell_note": "开口壳体说明",
"u_range": "U 参数范围",
"v_range": "V 参数范围",
"first_parameter": "起始参数",
@@ -723,6 +891,22 @@ INFO_LABELS = {
"existing_fillet_kind": "已有圆角类型",
"existing_fillet_status": "已有圆角识别状态",
"existing_fillet_radius_estimate": "已有圆角半径估算",
"feature_existing_chamfer_face_ids": "已有倒角 Face",
"feature_existing_chamfer_support_face_ids": "已有倒角支撑 Face",
"feature_existing_chamfer_end_face_ids": "已有倒角端面 Face",
"feature_existing_chamfer_short_edge_ids": "已有倒角短边 Edge",
"feature_existing_chamfer_long_edge_ids": "已有倒角长边 Edge",
"existing_chamfer_kind": "已有倒角类型",
"existing_chamfer_status": "已有倒角识别状态",
"existing_chamfer_risk": "已有倒角风险",
"existing_chamfer_warnings": "已有倒角警告",
"existing_chamfer_blockers": "已有倒角阻止原因",
"existing_chamfer_distance_estimate": "已有倒角距离估算",
"existing_chamfer_cross_edge_length_estimate": "已有倒角截面边长估算",
"existing_chamfer_long_edge_length_estimate": "已有倒角长度估算",
"existing_chamfer_support_angle_degrees": "已有倒角支撑面夹角",
"existing_chamfer_face_angle_degrees": "已有倒角面角度",
"existing_chamfer_note": "已有倒角识别说明",
"feature_reference_radius": "特征参考半径",
"feature_reference_diameter": "特征参考直径",
"feature_cone_small_radius": "锥孔小端半径",
+298 -42
View File
@@ -347,6 +347,12 @@ class WindowActionMixin:
self.statusBar().showMessage("未选择可修复对象")
def push_pull_face(self) -> None:
self._push_pull_face_action(keep_relations=False)
def push_pull_face_keep_relations(self) -> None:
self._push_pull_face_action(keep_relations=True)
def _push_pull_face_action(self, *, keep_relations: bool) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再执行新的拉伸/切除。"):
@@ -360,10 +366,11 @@ class WindowActionMixin:
QMessageBox.critical(self, "距离无效", "请输入数字形式的面移动距离。")
return
face_id = self.selected_face_id
plan = self._push_pull_plan_for_action(face_id, distance)
plan = self._push_pull_plan_for_action(face_id, distance, keep_relations=keep_relations)
operation_name = "拉伸/切除平面(保持关系)" if keep_relations else "拉伸/切除平面"
if plan["status"] == "blocked":
self._show_blocked_plan_message("拉伸/切除平面", plan, "拉伸/切除平面已阻止")
self._show_blocked_plan_message(operation_name, plan, "拉伸/切除平面已阻止")
return
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
@@ -383,6 +390,7 @@ class WindowActionMixin:
f"目标法向位置: {_format_value(plan.get('target_plane_position'))}\n"
f"执行策略: {_format_value(plan.get('edit_strategy_label'))}\n"
f"执行影响: {_format_value(plan.get('edit_semantics'))}\n"
f"关系约束: {_format_value(plan.get('face_push_pull_planar_constraint_summary', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
@@ -395,6 +403,9 @@ class WindowActionMixin:
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消拉伸/切除平面")
return
if keep_relations:
isolation = self._isolation_for_plan(plan, "push_pull_face_keep_relations", [face_id, distance])
else:
isolation = self._isolation_for_plan(plan, "push_pull_face", [face_id, distance])
if isolation is None:
self._show_push_pull_preview(face_id, distance, plan=plan)
@@ -402,11 +413,13 @@ class WindowActionMixin:
self.clear_edit_preview(render=False)
def action():
if keep_relations:
return self.model.push_pull_face_keep_relations(face_id, distance)
return self.model.push_pull_face(face_id, distance)
self._run_edit_action(
action,
operation_name="拉伸/切除平面",
operation_name=operation_name,
target=f"Face {face_id}",
parameters={
"part_id": plan.get("part_id"),
@@ -449,6 +462,31 @@ class WindowActionMixin:
"cylindrical_cap_extension_kind": plan.get("cylindrical_cap_extension_kind"),
"cylindrical_cap_extension_method": plan.get("cylindrical_cap_extension_method"),
"cap_extra_adjacent_face_count": plan.get("cap_extra_adjacent_face_count"),
"face_push_pull_planar_relation_constraint_requested": plan.get(
"face_push_pull_planar_relation_constraint_requested"
),
"face_push_pull_planar_relation_constraint_label": plan.get(
"face_push_pull_planar_relation_constraint_label"
),
"face_push_pull_planar_constraint_status": plan.get("face_push_pull_planar_constraint_status"),
"face_push_pull_planar_constraint_summary": plan.get("face_push_pull_planar_constraint_summary"),
"face_push_pull_planar_constraint_relation_count": plan.get(
"face_push_pull_planar_constraint_relation_count"
),
"face_push_pull_planar_constraint_parallel_count": plan.get(
"face_push_pull_planar_constraint_parallel_count"
),
"face_push_pull_planar_constraint_perpendicular_count": plan.get(
"face_push_pull_planar_constraint_perpendicular_count"
),
"face_push_pull_planar_constraint_angled_count": plan.get(
"face_push_pull_planar_constraint_angled_count"
),
"face_push_pull_planar_constraint_adjacent_face_count": plan.get(
"face_push_pull_planar_constraint_adjacent_face_count"
),
"face_push_pull_planar_constraint_blockers": plan.get("face_push_pull_planar_constraint_blockers"),
"face_push_pull_planar_constraint_warnings": plan.get("face_push_pull_planar_constraint_warnings"),
},
target_kind="face",
target_id=face_id,
@@ -700,15 +738,50 @@ class WindowActionMixin:
)
return plan
def _push_pull_plan_for_action(self, face_id: int, distance: float) -> dict[str, object]:
def _push_pull_plan_for_action(
self,
face_id: int,
distance: float,
*,
keep_relations: bool = False,
) -> dict[str, object]:
quick_plan = self._quick_push_pull_plan(face_id, distance)
if quick_plan.get("status") == "blocked":
return quick_plan
if self.model is None:
return quick_plan
if self._should_defer_push_pull_model_plan(face_id, distance, quick_plan):
if keep_relations:
blocked_plan = dict(quick_plan)
message = (
"当前 Face 属于复杂大 STEP 的多边界平面区域;“保持关系”需要在界面提交前完成一级平面关系验证。"
"为避免长时间卡住,当前版本暂不支持这种组合。请先选择简单平面 Face,"
"或改用普通“拉伸/切除”。"
)
blocked_plan.update(
{
"status": "blocked",
"risk": "blocked",
"message": message,
"blockers": message,
"resize_strategy": "push-pull-planar-face-keep-relations",
"edit_strategy_label": "保持一级平面关系的拉伸/切除",
"edit_semantics": (
"沿当前 Face 法向拉伸/切除,同时要求一级相邻平面关系可验证。"
),
"face_push_pull_planar_relation_constraint_requested": True,
"face_push_pull_planar_relation_constraint_label": "保持一级平面关系",
"face_push_pull_planar_constraint_status": "blocked",
"face_push_pull_planar_constraint_summary": "Face保持关系:当前复杂多边界平面需要完整验证,暂未开放。",
"face_push_pull_planar_constraint_blockers": message,
}
)
return blocked_plan
return self._deferred_push_pull_model_plan(quick_plan)
try:
if keep_relations:
model_plan = self.model.push_pull_keep_relations_plan(face_id, distance)
else:
model_plan = self.model.push_pull_plan(face_id, distance)
except Exception as exc:
blocked_plan = dict(quick_plan)
@@ -1434,10 +1507,13 @@ class WindowActionMixin:
"面面积(缩放特征)",
"面内长度(局部重建)",
"面内宽度(局部重建)",
"面内长度(保持关系)",
"面内宽度(保持关系)",
"面内长度(缩放特征)",
"面内宽度(缩放特征)",
"偏移(局部重建)",
"中心(局部重建)",
"中心(保持关系)",
"圆柱高度调整",
"高度(缩放特征)缩放所属对象",
}
@@ -1453,12 +1529,15 @@ class WindowActionMixin:
risk = str(plan.get("risk", ""))
isolated_geometry_operations = {
"push_pull_face",
"push_pull_face_keep_relations",
"move_face_plane_offset_local",
"resize_face_area_local",
"resize_face_area",
"resize_face_size_local",
"resize_face_size_local_keep_relations",
"resize_face_size_owning_scale",
"move_face_center_local",
"move_face_center_local_keep_relations",
"resize_shell_thickness",
"resize_shell_thickness_owning_scale",
"resize_cylindrical_height",
@@ -1487,6 +1566,7 @@ class WindowActionMixin:
"move_circular_edge_axis_center",
"resize_ellipse_edge_axis_radius",
"resize_existing_fillet",
"resize_existing_chamfer",
"fillet_edge",
"chamfer_edge",
"chamfer_edge_asymmetric",
@@ -4101,6 +4181,92 @@ class WindowActionMixin:
isolation=isolation,
)
def resize_existing_chamfer(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再修改已有倒角。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择已有倒角", "请先选择一个已有倒角斜面 Face。")
return
try:
target_distance = float(self.edge_chamfer_distance_input.text())
except ValueError:
QMessageBox.critical(self, "距离无效", "请输入数字形式的目标倒角距离。")
return
face_id = self.selected_face_id
plan = self.model.existing_chamfer_resize_plan(face_id, target_distance)
isolation = self._isolation_for_plan(plan, "resize_existing_chamfer", [face_id, target_distance])
if plan["status"] == "blocked":
self._show_blocked_plan_message("修改已有倒角", plan, "已有倒角距离修改已阻止")
return
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
result = QMessageBox.question(
self,
"确认修改已有倒角距离",
(
f"Face: {plan['face_id']}\n"
f"当前估算距离: {_format_value(plan['current_distance'])}\n"
f"目标距离: {_format_value(plan['target_distance'])}\n"
f"距离变化量: {_format_value(plan['delta_distance'])}\n"
f"距离变化比例: {_format_percent(plan['distance_delta_ratio'])}\n"
f"支撑 Face: {_format_value(plan.get('feature_existing_chamfer_support_face_ids', ''))}\n"
f"长边 Edge: {_format_value(plan.get('feature_existing_chamfer_long_edge_ids', ''))}\n"
f"策略: {plan['resize_strategy']}\n"
f"执行策略: {_format_value(plan.get('edit_strategy_label'))}\n"
f"建模意图: {_format_value(plan.get('edit_semantics'))}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会先尝试移除当前倒角斜面,再对恢复出的锐边重新倒角。\n"
"这不是 CAD 历史特征参数编辑,复杂倒角可能失败。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消修改已有倒角距离")
return
def action():
return self.model.resize_existing_chamfer(face_id, target_distance)
self._run_edit_action(
action,
operation_name="修改已有倒角距离",
target=f"Face {face_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"old_distance": plan.get("current_distance"),
"new_distance": target_distance,
"delta_distance": plan.get("delta_distance"),
"distance_delta_ratio": plan.get("distance_delta_ratio"),
"feature_type": plan.get("feature_type"),
"feature_guess": plan.get("feature_guess"),
"confidence": plan.get("confidence"),
"existing_chamfer_status": plan.get("status"),
"existing_chamfer_risk": plan.get("risk"),
"existing_chamfer_message": plan.get("message"),
"existing_chamfer_warnings": plan.get("warnings"),
"existing_chamfer_blockers": plan.get("blockers"),
"feature_existing_chamfer_support_face_ids": plan.get("feature_existing_chamfer_support_face_ids"),
"feature_existing_chamfer_long_edge_ids": plan.get("feature_existing_chamfer_long_edge_ids"),
"resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"),
"resize_note": plan.get("resize_note"),
"ui_preview": "skipped-for-existing-chamfer",
},
target_kind="face",
target_id=face_id,
isolation=isolation,
)
def fillet_edge(self) -> None:
if self.model is None:
return
@@ -4483,6 +4649,7 @@ class WindowActionMixin:
f"建模意图: {_format_value(plan.get('edge_length_strategy_label', ''))}\n"
f"固定约束: {_format_value(plan.get('edge_length_constraint_summary', ''))}\n"
f"执行影响: {_format_value(plan.get('edge_length_impact_summary', ''))}\n"
f"平面关系约束: {_format_value(plan.get('edge_length_planar_constraint_summary', ''))}\n"
f"策略: {_format_value(plan.get('resize_strategy', ''))}\n"
f"局部形变目标: {_format_value(plan.get('local_edge_deform_target_kind', ''))}\n"
f"局部形变: {_format_value(plan.get('local_edge_deform_anchor', ''))}\n"
@@ -4539,6 +4706,26 @@ class WindowActionMixin:
"edge_length_strategy_label": plan.get("edge_length_strategy_label"),
"edge_length_constraint_summary": plan.get("edge_length_constraint_summary"),
"edge_length_impact_summary": plan.get("edge_length_impact_summary"),
"edge_length_planar_relation_constraint_requested": plan.get(
"edge_length_planar_relation_constraint_requested"
),
"edge_length_planar_relation_constraint_label": plan.get(
"edge_length_planar_relation_constraint_label"
),
"edge_length_planar_constraint_status": plan.get("edge_length_planar_constraint_status"),
"edge_length_planar_constraint_summary": plan.get("edge_length_planar_constraint_summary"),
"edge_length_planar_constraint_side_face_count": plan.get(
"edge_length_planar_constraint_side_face_count"
),
"edge_length_planar_constraint_parallel_count": plan.get(
"edge_length_planar_constraint_parallel_count"
),
"edge_length_planar_constraint_perpendicular_count": plan.get(
"edge_length_planar_constraint_perpendicular_count"
),
"edge_length_planar_constraint_angled_count": plan.get("edge_length_planar_constraint_angled_count"),
"edge_length_planar_constraint_blockers": plan.get("edge_length_planar_constraint_blockers"),
"edge_length_planar_constraint_warnings": plan.get("edge_length_planar_constraint_warnings"),
"edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"),
"local_edge_deform_target_kind": plan.get("local_edge_deform_target_kind"),
@@ -4650,6 +4837,7 @@ class WindowActionMixin:
f"建模意图: {_format_value(plan.get('edge_length_strategy_label', ''))}\n"
f"固定约束: {_format_value(plan.get('edge_length_constraint_summary', ''))}\n"
f"执行影响: {_format_value(plan.get('edge_length_impact_summary', ''))}\n"
f"平面关系约束: {_format_value(plan.get('edge_length_planar_constraint_summary', ''))}\n"
f"策略: {_format_value(strategy)}\n"
f"局部形变目标: {_format_value(plan.get('local_edge_deform_target_kind', ''))}\n"
f"局部形变: {_format_value(plan.get('local_edge_deform_anchor', ''))}\n"
@@ -4707,6 +4895,26 @@ class WindowActionMixin:
"edge_length_strategy_label": plan.get("edge_length_strategy_label"),
"edge_length_constraint_summary": plan.get("edge_length_constraint_summary"),
"edge_length_impact_summary": plan.get("edge_length_impact_summary"),
"edge_length_planar_relation_constraint_requested": plan.get(
"edge_length_planar_relation_constraint_requested"
),
"edge_length_planar_relation_constraint_label": plan.get(
"edge_length_planar_relation_constraint_label"
),
"edge_length_planar_constraint_status": plan.get("edge_length_planar_constraint_status"),
"edge_length_planar_constraint_summary": plan.get("edge_length_planar_constraint_summary"),
"edge_length_planar_constraint_side_face_count": plan.get(
"edge_length_planar_constraint_side_face_count"
),
"edge_length_planar_constraint_parallel_count": plan.get(
"edge_length_planar_constraint_parallel_count"
),
"edge_length_planar_constraint_perpendicular_count": plan.get(
"edge_length_planar_constraint_perpendicular_count"
),
"edge_length_planar_constraint_angled_count": plan.get("edge_length_planar_constraint_angled_count"),
"edge_length_planar_constraint_blockers": plan.get("edge_length_planar_constraint_blockers"),
"edge_length_planar_constraint_warnings": plan.get("edge_length_planar_constraint_warnings"),
"edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"),
"local_edge_deform_target_kind": plan.get("local_edge_deform_target_kind"),
@@ -4894,7 +5102,7 @@ class WindowActionMixin:
float(getattr(self, f"{prefix}_z_input").text()),
)
except (AttributeError, ValueError):
QMessageBox.critical(self, "Edge endpoint coordinate invalid", "Please enter numeric X, Y and Z values.")
QMessageBox.critical(self, "Edge端点坐标无效", "请输入数字形式的 X、Y、Z 目标坐标。")
return None
def move_edge_start_point(self) -> None:
@@ -4992,10 +5200,10 @@ class WindowActionMixin:
def move_edge_center_point(self) -> None:
if self.model is None:
return
if self._edit_busy("Please wait for the current edit to finish before moving an Edge center."):
if self._edit_busy("请等待当前编辑完成后再移动 Edge 中心。"):
return
if self.selected_edge_id is None:
QMessageBox.information(self, "No Edge selected", "Please select an Edge first.")
QMessageBox.information(self, "未选择Edge", "请先选择一条 Edge。")
return
try:
target_center = (
@@ -5004,7 +5212,7 @@ class WindowActionMixin:
float(self.edge_center_z_input.text()),
)
except (AttributeError, ValueError):
QMessageBox.critical(self, "Edge center coordinate invalid", "Please enter numeric X, Y and Z values.")
QMessageBox.critical(self, "Edge中心坐标无效", "请输入数字形式的 X、Y、Z 目标坐标。")
return
edge_id = self.selected_edge_id
@@ -5012,12 +5220,13 @@ class WindowActionMixin:
isolation = self._isolation_for_plan(plan, "move_edge_center", [edge_id, target_center])
lines = [
f"Edge: {edge_id}",
f"Current center: {_format_value(plan.get('current_edge_center'))}",
f"Target center: {_format_value(plan.get('target_edge_center'))}",
f"Move vector: {_format_value(plan.get('moved_edge_center_delta'))}",
f"Edge length: {_format_value(plan.get('current_length'))}",
f"Strategy: {_format_value(plan.get('resize_strategy'))}",
f"Rebuilt faces: {_format_value(plan.get('local_edge_deform_face_count'))}",
f"当前中心: {_format_value(plan.get('current_edge_center'))}",
f"目标中心: {_format_value(plan.get('target_edge_center'))}",
f"移动量: {_format_value(plan.get('moved_edge_center_delta'))}",
f"Edge长度: {_format_value(plan.get('current_length'))}",
f"建模语义: {_format_value(plan.get('edit_strategy_label'))}",
f"影响范围: {_format_value(plan.get('edit_semantics'))}",
f"重建Face数: {_format_value(plan.get('local_edge_deform_face_count'))}",
]
if not self._confirm_quick_edit_plan(
"移动Edge中心",
@@ -5054,6 +5263,8 @@ class WindowActionMixin:
"local_edge_deform_start_move": plan.get("local_edge_deform_start_move"),
"local_edge_deform_end_move": plan.get("local_edge_deform_end_move"),
"resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"),
"resize_status": plan.get("status"),
"resize_risk": plan.get("risk"),
"resize_message": plan.get("message"),
@@ -5068,10 +5279,10 @@ class WindowActionMixin:
def _move_edge_endpoint(self, endpoint_role: str) -> None:
if self.model is None:
return
if self._edit_busy("Please wait for the current edit to finish before moving an Edge endpoint."):
if self._edit_busy("请等待当前编辑完成后再移动 Edge 端点。"):
return
if self.selected_edge_id is None:
QMessageBox.information(self, "No Edge selected", "Please select an Edge first.")
QMessageBox.information(self, "未选择Edge", "请先选择一条 Edge。")
return
target_point = self._edge_endpoint_target_from_inputs(endpoint_role)
@@ -5084,14 +5295,15 @@ class WindowActionMixin:
title = "移动Edge起点" if endpoint_role == "start" else "移动Edge终点"
lines = [
f"Edge: {edge_id}",
f"Endpoint: {_format_value(plan.get('edge_endpoint_label'))}",
f"Current endpoint: {_format_value(plan.get('current_endpoint_point'))}",
f"Target endpoint: {_format_value(plan.get('target_endpoint_point'))}",
f"Move vector: {_format_value(plan.get('moved_endpoint_delta'))}",
f"Current Edge length: {_format_value(plan.get('current_length'))}",
f"Result Edge length: {_format_value(plan.get('target_length'))}",
f"Strategy: {_format_value(plan.get('resize_strategy'))}",
f"Rebuilt faces: {_format_value(plan.get('local_edge_deform_face_count'))}",
f"端点: {_format_value(plan.get('edge_endpoint_label'))}",
f"当前端点: {_format_value(plan.get('current_endpoint_point'))}",
f"目标端点: {_format_value(plan.get('target_endpoint_point'))}",
f"移动量: {_format_value(plan.get('moved_endpoint_delta'))}",
f"当前Edge长度: {_format_value(plan.get('current_length'))}",
f"结果Edge长度: {_format_value(plan.get('target_length'))}",
f"建模语义: {_format_value(plan.get('edit_strategy_label'))}",
f"影响范围: {_format_value(plan.get('edit_semantics'))}",
f"重建Face数: {_format_value(plan.get('local_edge_deform_face_count'))}",
]
if not self._confirm_quick_edit_plan(
title,
@@ -5133,6 +5345,8 @@ class WindowActionMixin:
"local_edge_deform_start_move": plan.get("local_edge_deform_start_move"),
"local_edge_deform_end_move": plan.get("local_edge_deform_end_move"),
"resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"),
"resize_status": plan.get("status"),
"resize_risk": plan.get("risk"),
"resize_message": plan.get("message"),
@@ -5711,10 +5925,16 @@ class WindowActionMixin:
)
def resize_face_width_local(self) -> None:
self._resize_face_size_local("width")
self._resize_face_size_local("width", keep_relations=False)
def resize_face_height_local(self) -> None:
self._resize_face_size_local("height")
self._resize_face_size_local("height", keep_relations=False)
def resize_face_width_keep_relations(self) -> None:
self._resize_face_size_local("width", keep_relations=True)
def resize_face_height_keep_relations(self) -> None:
self._resize_face_size_local("height", keep_relations=True)
def resize_face_width_owning_scale(self) -> None:
self._resize_face_size_owning_scale("width")
@@ -5722,12 +5942,13 @@ class WindowActionMixin:
def resize_face_height_owning_scale(self) -> None:
self._resize_face_size_owning_scale("height")
def _resize_face_size_local(self, axis: str) -> None:
def _resize_face_size_local(self, axis: str, *, keep_relations: bool) -> None:
if self.model is None:
return
axis_key = "height" if str(axis).lower() == "height" else "width"
axis_label = "面内宽度" if axis_key == "height" else "面内长度"
if self._edit_busy(f"请等待当前编辑完成后再修改{axis_label}(局部重建)"):
mode_label = f"{axis_label}(保持关系)" if keep_relations else f"{axis_label}(局部重建)"
if self._edit_busy(f"请等待当前编辑完成后再修改{mode_label}"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择Face", "请先选择一个平面 Face。")
@@ -5744,10 +5965,13 @@ class WindowActionMixin:
face_id = self.selected_face_id
plan = self._quick_blocked_face_size_local_plan(face_id, target_size, axis_key)
if plan is None:
if keep_relations:
plan = self.model.face_size_local_resize_keep_relations_plan(face_id, target_size, axis_key)
else:
plan = self.model.face_size_local_resize_plan(face_id, target_size, axis_key)
lines = [
f"Face: {face_id}",
f"修改对象: {axis_label}(局部重建)",
f"修改对象: {mode_label}",
f"当前面内长度: {_format_value(plan.get('current_face_width'))}",
f"目标面内长度: {_format_value(plan.get('target_face_width'))}",
f"当前面内宽度: {_format_value(plan.get('current_face_height'))}",
@@ -5761,21 +5985,23 @@ class WindowActionMixin:
f"影响目标: {_format_value(plan.get('local_face_deform_target_kind'))}",
]
if not self._confirm_quick_edit_plan(
f"{axis_label}(局部重建)",
mode_label,
plan,
lines,
f"{axis_label}(局部重建)已阻止",
f"已取消{axis_label}(局部重建)",
f"{mode_label}已阻止",
f"已取消{mode_label}",
):
return
self.clear_edit_preview(render=False)
def action():
if keep_relations:
return self.model.resize_face_size_local_keep_relations(face_id, target_size, axis_key)
return self.model.resize_face_size_local(face_id, target_size, axis_key)
self._run_edit_action(
action,
operation_name=f"修改{axis_label}(局部重建)",
operation_name=f"修改{mode_label}",
target=f"Face {face_id}",
parameters={
"face_id": face_id,
@@ -5804,6 +6030,11 @@ class WindowActionMixin:
"resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"),
"face_planar_relation_constraint_requested": plan.get("face_planar_relation_constraint_requested"),
"face_push_pull_planar_constraint_summary": plan.get("face_push_pull_planar_constraint_summary"),
"face_push_pull_planar_constraint_parallel_count": plan.get("face_push_pull_planar_constraint_parallel_count"),
"face_push_pull_planar_constraint_perpendicular_count": plan.get("face_push_pull_planar_constraint_perpendicular_count"),
"face_push_pull_planar_constraint_angled_count": plan.get("face_push_pull_planar_constraint_angled_count"),
"resize_status": plan.get("status"),
"resize_risk": plan.get("risk"),
"resize_message": plan.get("message"),
@@ -5813,7 +6044,11 @@ class WindowActionMixin:
},
target_kind="face",
target_id=face_id,
isolation=self._isolation_for_plan(plan, "resize_face_size_local", [face_id, target_size, axis_key]),
isolation=self._isolation_for_plan(
plan,
"resize_face_size_local_keep_relations" if keep_relations else "resize_face_size_local",
[face_id, target_size, axis_key],
),
)
def _resize_face_size_owning_scale(self, axis: str) -> None:
@@ -6152,9 +6387,16 @@ class WindowActionMixin:
)
def move_selected_face_center_local(self) -> None:
self._move_selected_face_center_local(keep_relations=False)
def move_selected_face_center_keep_relations(self) -> None:
self._move_selected_face_center_local(keep_relations=True)
def _move_selected_face_center_local(self, *, keep_relations: bool) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再执行中心(局部重建)"):
mode_label = "中心(保持关系)" if keep_relations else "中心(局部重建)"
if self._edit_busy(f"请等待当前编辑完成后再执行{mode_label}"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择Face", "请先选择一个平面 Face。")
@@ -6169,9 +6411,9 @@ class WindowActionMixin:
current_center = self._selected_face_center_for_translation(face_id)
if current_center is None:
self._show_immediate_cannot_edit_message(
"中心(局部重建)",
mode_label,
"当前 Face 缺少稳定中心坐标。",
"中心(局部重建)已阻止",
f"{mode_label}已阻止",
)
return
target_center = (
@@ -6181,6 +6423,9 @@ class WindowActionMixin:
)
plan = self._quick_blocked_face_center_local_plan(face_id, target_center)
if plan is None:
if keep_relations:
plan = self.model.face_center_local_move_keep_relations_plan(face_id, target_center)
else:
plan = self.model.face_center_local_move_plan(face_id, target_center)
lines = [
f"Face: {face_id}",
@@ -6194,21 +6439,23 @@ class WindowActionMixin:
f"影响目标: {_format_value(plan.get('local_face_deform_target_kind'))}",
]
if not self._confirm_quick_edit_plan(
"中心(局部重建)",
mode_label,
plan,
lines,
"中心(局部重建)已阻止",
"已取消中心(局部重建)",
f"{mode_label}已阻止",
f"已取消{mode_label}",
):
return
self.clear_edit_preview(render=False)
def action():
if keep_relations:
return self.model.move_face_center_local_keep_relations(face_id, target_center)
return self.model.move_face_center_local(face_id, target_center)
self._run_edit_action(
action,
operation_name="中心(局部重建)",
operation_name=mode_label,
target=f"Face {face_id}",
parameters={
"face_id": face_id,
@@ -6227,6 +6474,11 @@ class WindowActionMixin:
"resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"),
"face_planar_relation_constraint_requested": plan.get("face_planar_relation_constraint_requested"),
"face_push_pull_planar_constraint_summary": plan.get("face_push_pull_planar_constraint_summary"),
"face_push_pull_planar_constraint_parallel_count": plan.get("face_push_pull_planar_constraint_parallel_count"),
"face_push_pull_planar_constraint_perpendicular_count": plan.get("face_push_pull_planar_constraint_perpendicular_count"),
"face_push_pull_planar_constraint_angled_count": plan.get("face_push_pull_planar_constraint_angled_count"),
"resize_status": plan.get("status"),
"resize_risk": plan.get("risk"),
"resize_message": plan.get("message"),
@@ -6236,7 +6488,11 @@ class WindowActionMixin:
},
target_kind="face",
target_id=face_id,
isolation=self._isolation_for_plan(plan, "move_face_center_local", [face_id, list(target_center)]),
isolation=self._isolation_for_plan(
plan,
"move_face_center_local_keep_relations" if keep_relations else "move_face_center_local",
[face_id, list(target_center)],
),
)
def move_selected_axis_center_by_translation(self) -> None:
+4
View File
@@ -2358,6 +2358,10 @@ class WindowCoreMixin:
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择已有圆角/倒圆候选Face {target_id}")
elif action == "inspect_existing_chamfer":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择已有倒角候选Face {target_id}")
elif action == "fillet_edge":
self._set_selection_mode("Edge")
self.select_edge(int(target_id))
+296 -12
View File
@@ -156,6 +156,10 @@ def _feature_dimension_keys(action_info: dict[str, object]) -> tuple[str, ...]:
angular_span = _float_or_none(action_info.get("angular_span"))
if surface == "plane":
if action_info.get("multistep_prismatic_status") == "blocked":
return ()
if action_info.get("existing_chamfer_status") == "candidate":
return ("existing_chamfer_distance_estimate",)
if action_info.get("prismatic_profile_status") == "candidate":
keys = ["local_face_width", "local_face_height"]
if action_info.get("prismatic_extrusion_status") == "candidate":
@@ -195,6 +199,8 @@ 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":
if str(action_info.get("existing_fillet_status") or "") == "blocked":
return ()
return ("existing_fillet_radius_estimate", "existing_fillet_arc_length_estimate")
return ("generic_cylinder_diameter", "cylinder_height")
@@ -1029,13 +1035,23 @@ class WindowStateMixin:
is_generic_surface = has_face and surface in FREEFORM_FACE_SURFACES
is_hole_or_groove = is_cylinder and feature_guess == "hole/groove candidate"
is_boss = is_cylinder and feature_guess == "boss/outer-round candidate"
is_existing_fillet = is_cylinder and feature_guess == "round/fillet candidate"
is_existing_fillet = (
is_cylinder
and feature_guess == "round/fillet candidate"
and str(action_info.get("existing_fillet_status") or "") != "blocked"
)
is_existing_chamfer = (
is_plane
and feature_guess == "chamfer candidate"
and str(action_info.get("existing_chamfer_status") or "") == "candidate"
)
is_full_cylinder = _is_effectively_full_cylinder(action_info)
is_slot_or_half_hole = (
is_hole_or_groove
and not is_full_cylinder
and angular_span is not None
and angular_span < math.tau * 0.92
and str(action_info.get("slot_status") or "") == "candidate"
and _float_or_none(action_info.get("slot_chord_width_estimate")) is not None
)
is_blind = action_info.get("cylinder_end_type") == "blind"
@@ -1049,8 +1065,8 @@ class WindowStateMixin:
)
has_fillet_support = len(_int_values(action_info.get("feature_existing_fillet_support_face_ids"))) >= 2
is_line_edge = has_edge and curve == "line"
can_push = has_model and is_plane
can_resize_shell = has_model and is_shell_candidate
can_push = has_model and is_plane and not is_existing_chamfer
can_resize_shell = has_model and is_shell_candidate and not is_existing_chamfer
can_resize_hole = has_model and is_hole_or_groove
can_resize_slot = has_model and is_slot_or_half_hole
can_resize_boss = has_model and is_boss and is_full_cylinder
@@ -1535,8 +1551,9 @@ class WindowStateMixin:
"Face:面内长度/宽度、中心、偏移、壳体厚度。\n"
"孔/槽:孔径、轴心、封堵、盲孔/盲槽深度、槽宽/槽深/弧长/总长。\n"
"凸台:圆柱凸台直径/高度/轴心;矩形凸台/矩形槽口袋长宽、中心、高度/深度;多台阶矩形凸台顶层规则台阶。\n"
"圆角/倒角:简单已有圆角半径/弧长,直线 Edge 新增圆角/倒角。\n"
"圆角/倒角:简单已有圆角半径/弧长、简单等半径圆角链半径/弧长、已有等距倒角距离,直线 Edge 新增圆角/倒角。\n"
"Edge/解析曲面:直线 Edge 长度/端点/中心、圆/椭圆 Edge、简单圆锥/球/环面。\n"
"一级关系:Face/Edge 平行垂直事实、Face 共面/同域碎片、一级同轴圆柱事实;部分 Face/Edge 操作可选择保持关系。\n"
"受限:复杂链式特征、二级/三级拓扑传播和原 CAD 历史恢复。"
)
return headline, detail, tooltip
@@ -2450,13 +2467,37 @@ class WindowStateMixin:
root_specs: list[dict[str, object]],
action_info: dict[str, object],
) -> list[dict[str, object]]:
root_rows = self._feature_property_specs(root_specs, action_info)
associated = action_info.get("associated_feature_infos")
if not isinstance(associated, (list, tuple)) or not associated:
return root_rows
def no_editable_feature_dimensions_spec() -> dict[str, object]:
reason = str(
action_info.get("freeform_face_blockers")
or action_info.get("recognition_blockers")
or action_info.get("local_face_deform_blocker")
or action_info.get("feature_edit_actions")
or "当前识别结果没有稳定可修改参数;详细原因请看诊断信息。"
).strip()
if not reason:
reason = "当前识别结果没有稳定可修改参数;详细原因请看诊断信息。"
return {
"key": "no_editable_feature_dimensions",
"label": "可修改参数",
"current_text": "当前无可修改参数",
"current_raw": "",
"target_text": "",
"editable": False,
"enabled": False,
"status_text": "不可修改",
"disabled_tip": reason,
"span_value_columns": True,
"parameter_role": "empty_state",
}
root_rows = self._feature_property_specs(root_specs, action_info)
root_dimensions = [dict(spec) for spec in root_rows if spec.get("parameter_role") == "dimension"]
related_rows: list[dict[str, object]] = []
associated = action_info.get("associated_feature_infos")
if not isinstance(associated, (list, tuple)) or not associated:
return root_dimensions or [no_editable_feature_dimensions_spec()]
for index, related_info in enumerate(associated, start=1):
if not isinstance(related_info, dict):
continue
@@ -2476,7 +2517,8 @@ class WindowStateMixin:
related["association_index"] = index
related_rows.append(related)
return root_dimensions + related_rows
rows = root_dimensions + related_rows
return rows or [no_editable_feature_dimensions_spec()]
def _ordered_property_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]:
items = self._ordered_info_items(info)
@@ -2566,7 +2608,16 @@ class WindowStateMixin:
is_generic_surface = has_face and surface in FREEFORM_FACE_SURFACES
is_hole_or_groove = is_cylinder and feature_guess == "hole/groove candidate"
is_boss = is_cylinder and feature_guess == "boss/outer-round candidate"
is_existing_fillet = is_cylinder and feature_guess == "round/fillet candidate"
is_existing_fillet = (
is_cylinder
and feature_guess == "round/fillet candidate"
and str(action_info.get("existing_fillet_status") or "") != "blocked"
)
is_existing_chamfer = (
is_plane
and feature_guess == "chamfer candidate"
and str(action_info.get("existing_chamfer_status") or "") == "candidate"
)
recognition_fields_present = any(
key in action_info
for key in (
@@ -2605,6 +2656,7 @@ class WindowStateMixin:
and not is_full_cylinder
and angular_span is not None
and angular_span < math.tau * 0.92
and str(action_info.get("slot_status") or "") == "candidate"
and _float_or_none(action_info.get("slot_chord_width_estimate")) is not None
)
is_blind = action_info.get("cylinder_end_type") == "blind"
@@ -2613,7 +2665,7 @@ class WindowStateMixin:
_int_values(action_info.get("feature_start_end_face_ids"))
or _int_values(action_info.get("feature_end_end_face_ids"))
)
show_generic_face_edit_specs = bool(has_face and (is_plane or is_shell_candidate))
show_generic_face_edit_specs = bool(has_face and (is_plane or is_shell_candidate) and not is_existing_chamfer)
local_face_deform_ready = bool(action_info.get("local_face_deform_ready", True))
local_face_deform_blocker = str(action_info.get("local_face_deform_blocker") or "").strip()
has_fillet_support = len(_int_values(action_info.get("feature_existing_fillet_support_face_ids"))) >= 2
@@ -2978,6 +3030,11 @@ class WindowStateMixin:
"工程特征 / 倒圆角:移除已有圆角后按目标半径重新倒圆。",
"STEP 没有原始倒圆角历史时,只能基于当前圆角面和支撑面推断;复杂 blend 会阻止或回滚。",
)
if is_existing_chamfer:
return (
"工程特征 / 倒角:移除已有倒角斜面后按目标距离重新倒角。",
"STEP 没有原始倒角历史时,只能基于当前斜面、支撑面和恢复出的锐边推断;倒角链或不等距倒角先保持受限。",
)
if is_shell_candidate:
return (
"工程特征 / 抽壳:按壳体厚度或相对面距离调整薄壁区域。",
@@ -3062,6 +3119,11 @@ class WindowStateMixin:
"优先改圆角半径;复杂圆角链先不要用整体缩放替代。",
"已有圆角需要先恢复支撑锐边再重新倒圆,支撑面不明确时失败回滚是正常保护。",
)
if is_existing_chamfer:
return (
"优先改倒角距离;复杂倒角链先保持只读或重新选 Edge 新增倒角。",
"已有倒角需要先移除斜面、恢复原始锐边再重新倒角;如果支撑面或长边不明确,会在计划阶段阻止。",
)
if is_shell_candidate:
return (
"优先改壳体厚度;普通位置变化再用偏移。",
@@ -3243,6 +3305,16 @@ class WindowStateMixin:
"再寻找可重新倒圆的锐边。复杂 blend 或支撑面不稳定时会阻止或回滚。"
),
)
elif is_existing_chamfer:
add_readonly_spec(
key="existing_chamfer_edit_semantics",
label="建模意图",
text="倒角:移除已有倒角斜面,再尝试按目标距离重新倒角。",
tip=(
"已有倒角修改不是恢复 CAD 历史参数;当前会先 defeature 当前倒角斜面,"
"再寻找恢复出的原始锐边并按目标距离重新倒角。复杂倒角链、不等距倒角或支撑面不稳定时会阻止或回滚。"
),
)
elif is_cone or is_sphere or is_torus:
add_readonly_spec(
key="analytic_surface_edit_semantics",
@@ -3356,6 +3428,43 @@ class WindowStateMixin:
_triple_or_none(action_info.get("area_center"))
or _triple_or_none(action_info.get("bbox_center"))
)
keep_relation_fields_present = any(
key in action_info
for key in (
"first_level_planar_relation_status",
"first_level_planar_relation_count",
"first_level_planar_relation_angled_count",
"first_level_planar_relation_non_planar_face_ids",
"first_level_adjacent_surface_types",
)
)
face_keep_relation_enabled = bool(is_plane)
face_keep_relation_disabled_tip = "当前对象不是平面 Face,不能保持一级平面关系。"
if face_keep_relation_enabled and keep_relation_fields_present:
relation_status = str(action_info.get("first_level_planar_relation_status") or "")
relation_count = _int_or_none(action_info.get("first_level_planar_relation_count")) or 0
angled_count = _int_or_none(action_info.get("first_level_planar_relation_angled_count")) or 0
non_planar_face_ids = _int_values(action_info.get("first_level_planar_relation_non_planar_face_ids"))
adjacent_surfaces = tuple(action_info.get("first_level_adjacent_surface_types") or ())
non_plane_adjacent_surfaces = tuple(
item
for item in adjacent_surfaces
if isinstance(item, (tuple, list))
and len(item) >= 2
and str(item[1]) != "plane"
)
if non_planar_face_ids or non_plane_adjacent_surfaces:
face_keep_relation_enabled = False
face_keep_relation_disabled_tip = "一级相邻 Face 中包含非平面/曲面,当前“保持关系”只支持平面邻域。"
elif angled_count > 0:
face_keep_relation_enabled = False
face_keep_relation_disabled_tip = "一级相邻平面中存在斜交关系,当前“保持关系”只支持平行/垂直。"
elif relation_status != "ready":
face_keep_relation_enabled = False
face_keep_relation_disabled_tip = "当前一级平面关系还不可验证,不能使用“保持关系”。"
elif relation_count <= 0:
face_keep_relation_enabled = False
face_keep_relation_disabled_tip = "当前一级邻域没有识别到可保持的平行/垂直平面关系。"
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"))
@@ -3390,6 +3499,31 @@ class WindowStateMixin:
),
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_width, 0.25, 0.8, face_linear_hard_limit)}",
},
"keep_relations": {
"label": "保持关系",
"action": "resize_face_width_keep_relations",
"target_attr": "face_width_input",
"enabled": bool(
current_face_width is not None
and current_face_width > 0
and current_face_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
and face_keep_relation_enabled
),
"enabled_tip": (
"输入目标面内长度;程序会沿该方向缩放所属特征或 Solid,"
"并要求一级相邻平面的平行/垂直关系在执行前可验证、执行后可反查。"
),
"disabled_tip": (
face_keep_relation_disabled_tip
if not face_keep_relation_enabled
else "当前 Face 缺少稳定面内长度、中心坐标或所属对象,不能保持关系地修改面内长度。"
),
"range_hint": (
"保持关系会把面内长度修改转换为所属对象单向缩放,并在结果上反查一级平面关系;"
f"{relative_range_hint(current_face_width, 0.25, 0.8, face_linear_hard_limit)}"
),
},
"owning": {
"label": "缩放特征",
"action": "resize_face_width_owning_scale",
@@ -3432,6 +3566,31 @@ class WindowStateMixin:
),
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_height, 0.25, 0.8, face_linear_hard_limit)}",
},
"keep_relations": {
"label": "保持关系",
"action": "resize_face_height_keep_relations",
"target_attr": "face_height_input",
"enabled": bool(
current_face_height is not None
and current_face_height > 0
and current_face_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
and face_keep_relation_enabled
),
"enabled_tip": (
"输入目标面内宽度;程序会沿该方向缩放所属特征或 Solid,"
"并要求一级相邻平面的平行/垂直关系在执行前可验证、执行后可反查。"
),
"disabled_tip": (
face_keep_relation_disabled_tip
if not face_keep_relation_enabled
else "当前 Face 缺少稳定面内宽度、中心坐标或所属对象,不能保持关系地修改面内宽度。"
),
"range_hint": (
"保持关系会把面内宽度修改转换为所属对象单向缩放,并在结果上反查一级平面关系;"
f"{relative_range_hint(current_face_height, 0.25, 0.8, face_linear_hard_limit)}"
),
},
"owning": {
"label": "缩放特征",
"action": "resize_face_height_owning_scale",
@@ -3478,6 +3637,33 @@ class WindowStateMixin:
"transform_context": {"current_center": current_face_center},
**face_vector_move_limit(current_face_center),
},
"keep_relations": {
"label": "保持关系",
"action": "move_selected_face_center_keep_relations",
"target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"),
"enabled": bool(
is_plane
and current_face_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
and face_keep_relation_enabled
),
"enabled_tip": (
"输入这个 Face 中心要移动到的目标 X, Y, Z 坐标;程序会平移所属特征或 Solid,"
"并要求一级相邻平面的平行/垂直关系在执行前可验证、执行后可反查。"
),
"disabled_tip": (
face_keep_relation_disabled_tip
if not face_keep_relation_enabled
else "当前 Face 缺少稳定中心坐标或所属对象,不能保持关系地修改中心位置。"
),
"range_hint": (
"保持关系会把中心修改转换为所属对象平移,并在结果上反查一级平面关系;"
f"{translation_hint()} {face_center_hard_limit}"
),
"target_transform": "target_center_to_translation",
"transform_context": {"current_center": current_face_center},
**face_vector_move_limit(current_face_center),
},
"owning": {
"label": "移动特征",
"action": "move_selected_face_center",
@@ -3502,7 +3688,7 @@ class WindowStateMixin:
used=("area_center", "bbox_center"),
)
if is_plane:
if is_plane and not is_existing_chamfer:
plane_origin = _triple_or_none(action_info.get("plane_origin"))
plane_direction = (
_triple_or_none(action_info.get("push_pull_outward_direction"))
@@ -3526,6 +3712,43 @@ class WindowStateMixin:
+ plane_origin[1] * plane_direction[1]
+ plane_origin[2] * plane_direction[2]
)
keep_relation_fields_present = any(
key in action_info
for key in (
"first_level_planar_relation_status",
"first_level_planar_relation_count",
"first_level_planar_relation_angled_count",
"first_level_planar_relation_non_planar_face_ids",
"first_level_adjacent_surface_types",
)
)
keep_relation_enabled = current_plane_position is not None
keep_relation_disabled_tip = "当前平面缺少稳定移动方向或基准点,不能保持一级平面关系。"
if keep_relation_enabled and keep_relation_fields_present:
relation_status = str(action_info.get("first_level_planar_relation_status") or "")
relation_count = _int_or_none(action_info.get("first_level_planar_relation_count")) or 0
angled_count = _int_or_none(action_info.get("first_level_planar_relation_angled_count")) or 0
non_planar_face_ids = _int_values(action_info.get("first_level_planar_relation_non_planar_face_ids"))
adjacent_surfaces = tuple(action_info.get("first_level_adjacent_surface_types") or ())
non_plane_adjacent_surfaces = tuple(
item
for item in adjacent_surfaces
if isinstance(item, (tuple, list))
and len(item) >= 2
and str(item[1]) != "plane"
)
if non_planar_face_ids or non_plane_adjacent_surfaces:
keep_relation_enabled = False
keep_relation_disabled_tip = "一级相邻 Face 中包含非平面/曲面,当前“保持关系”只支持平面邻域。"
elif angled_count > 0:
keep_relation_enabled = False
keep_relation_disabled_tip = "一级相邻平面中存在斜交关系,当前“保持关系”只支持平行/垂直。"
elif relation_status != "ready":
keep_relation_enabled = False
keep_relation_disabled_tip = "当前一级平面关系还不可验证,不能使用“保持关系”。"
elif relation_count <= 0:
keep_relation_enabled = False
keep_relation_disabled_tip = "当前一级邻域没有识别到可保持的平行/垂直平面关系。"
add_scoped_spec(
key="face_target_normal_position",
label="偏移",
@@ -3548,6 +3771,24 @@ class WindowStateMixin:
"target_transform": "plane_target_position_to_offset",
"transform_context": {"current_plane_position": current_plane_position},
},
"keep_relations": {
"label": "保持关系",
"action": "push_pull_face_keep_relations",
"target_attr": "offset_input",
"enabled": keep_relation_enabled,
"enabled_tip": (
"输入偏移的目标位置;程序会沿当前 Face 法向拉伸/切除,"
"并要求一级相邻平面的平行/垂直关系可验证。"
),
"disabled_tip": keep_relation_disabled_tip,
"range_hint": (
"这是带约束守门的拉伸/切除:只支持直接相邻平面关系清楚、且关系为平行/垂直的一级邻域;"
"遇到非平面相邻面、斜交关系或复杂多边界慢计划会直接阻止。"
f"{face_offset_hard_limit}"
),
"target_transform": "plane_target_position_to_offset",
"transform_context": {"current_plane_position": current_plane_position},
},
"local": {
"label": "局部重建",
"action": "move_selected_face_plane_position_local",
@@ -4547,6 +4788,35 @@ class WindowStateMixin:
used=("existing_fillet_arc_length_estimate", "existing_fillet_angular_span", "angular_span"),
**positive_minimum(),
)
if is_existing_chamfer:
current_distance = _float_or_none(action_info.get("existing_chamfer_distance_estimate"))
chamfer_hint = relative_range_hint(current_distance, 0.35, 1.0)
cross_length = _float_or_none(action_info.get("existing_chamfer_cross_edge_length_estimate"))
if cross_length is not None and cross_length > 0:
chamfer_hint = (
f"{chamfer_hint} 当前倒角截面边长约 {_format_float(cross_length)}"
"目标距离过大时恢复锐边再倒角更容易失败。"
)
add_spec(
key="existing_chamfer_distance_estimate",
label="倒角距离",
current_raw=current_distance if current_distance is not None else "",
target_text=numeric_text(current_distance),
action="resize_existing_chamfer",
target_attr="edge_chamfer_distance_input",
enabled=bool(current_distance is not None and current_distance > 0),
enabled_tip="输入已有倒角面的目标距离;程序会先确认支撑面,再尝试移除旧倒角后重新倒角。",
disabled_tip="当前倒角候选缺少稳定距离估算,暂不放行修改。",
value_type="positive",
range_hint=chamfer_hint,
used=(
"existing_chamfer_distance_estimate",
"existing_chamfer_cross_edge_length_estimate",
"feature_existing_chamfer_support_face_ids",
"feature_existing_chamfer_long_edge_ids",
),
**positive_minimum(),
)
def analytic_surface_enabled(value_present: bool, capability_supported: bool = True) -> bool:
return bool(value_present and capability_supported and analytic_surface_recognition_ready)
@@ -4773,6 +5043,7 @@ class WindowStateMixin:
"auto": "自动选择:按局部边形变、端面移动、相邻圆柱编辑、缩放所属对象的顺序寻找可用路径。",
"local-edge-only-deform": "只改当前Edge:移动被选Edge端点,相邻面自然变斜,整体端面不一起平移。",
"move-edge-end-plane-by-push-pull": "移动端面/保持垂直:端面和端面上的相关边一起移动,相邻平面尽量保持垂直,正方体会更像变成长方体。",
"keep-first-level-planar-relations": "保持关系:保持一级平面平行/垂直关系;只在直接相邻平面和移动端面能验证时执行,不能验证就直接阻止。",
"resize-adjacent-cylinder-from-circular-edge-length": "相邻圆柱直径:把圆Edge长度换算成圆柱直径,优先改孔/槽/凸台局部特征。",
"scale-owning-shape-from-edge": "缩放所属对象:所属特征或 Solid 整体跟随变化,其它尺寸也会变。",
}.get(strategy_mode, "自动选择:程序会在确认窗口显示实际采用的策略。")
@@ -4836,6 +5107,19 @@ class WindowStateMixin:
),
"preselect_combos": edge_length_strategy_preselect("move-edge-end-plane-by-push-pull"),
},
"keep-first-level-planar-relations": {
"label": "保持关系",
"action": "resize_any_edge_length",
"target_attr": "edge_target_length_input",
"enabled": bool(is_line_edge and current_length is not None),
"enabled_tip": "输入当前Edge的目标长度;程序会尝试移动端面,并要求一级平面平行/垂直关系可验证。",
"disabled_tip": "只有直线Edge才能尝试保持一级平面关系;圆Edge请用相邻圆柱,复杂曲线暂未实现。",
"range_hint": (
f"{edge_strategy_effect_text('keep-first-level-planar-relations', current_anchor_mode)} "
f"{relative_range_hint(current_length, 0.25, 0.5)}"
),
"preselect_combos": edge_length_strategy_preselect("keep-first-level-planar-relations"),
},
"resize-adjacent-cylinder-from-circular-edge-length": {
"label": "相邻圆柱",
"action": "resize_any_edge_length",