diff --git a/README.md b/README.md index d5554cb..21ebc52 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ git diff --check - 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。 - 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。 - 识别结果会区分 `当前可改`、`当前受限修改`、`识别限制` 和 `受限能力`:比如一个通孔可以改孔径,但盲孔深度属于受限能力;一个带内孔的平面可以拉伸/切除,但“局部重建尺寸/中心/偏移”会被标成受限修改。 -- 用户可以修改可控的特征,比如 Face 的面内长度、面内宽度、中心、偏移和壳体厚度,并通过 `建模意图` 选择拉伸/切除、局部重建、移动特征或缩放特征;面积是结果变量,放在诊断信息里查看,不作为特征参数表里的驱动变量。还可以做孔径/半径调整、孔/槽轴心并通过 `建模意图` 选择移动局部特征或移动特征、槽宽/槽深/弧长并通过 `建模意图` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、壳体厚度(局部拉伸/切除或整体缩放)、凸台直径/半径/高度/轴心并通过 `建模意图` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度并通过 `建模意图` 选择重建圆角或整体调整、圆锥参考半径/直径/半角(简单圆锥解析重建,嵌入式锥孔优先局部重切)、球面半径/直径(缩放特征)、环面主/小半径或直径(缩放特征)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `建模意图` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `建模意图` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。 +- 用户可以修改可控的特征,比如 Face 的面内长度、面内宽度、中心、偏移和壳体厚度,并通过 `建模意图` 选择拉伸/切除、局部重建、移动特征或缩放特征;面积是结果变量,放在诊断信息里查看,不作为特征参数表里的驱动变量。还可以做孔径/半径调整、孔/槽轴心并通过 `建模意图` 选择移动局部特征或移动特征、槽宽/槽深/弧长并通过 `建模意图` 选择局部重建或整体缩放、弧角、开口角和槽孔总长度调整、盲孔/盲槽深度(局部切补或整体缩放)、壳体厚度(局部拉伸/切除或整体缩放)、凸台直径/半径/高度/轴心并通过 `建模意图` 选择局部修改或整体调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度、简单等半径圆角链半径/弧长并通过 `建模意图` 选择重建圆角或整体调整、已有等距倒角距离、圆锥参考半径/直径/半角(简单圆锥解析重建,嵌入式锥孔优先局部重切)、球面半径/直径(缩放特征)、环面主/小半径或直径(缩放特征)、Edge倒圆、Edge倒角、圆Edge半径/直径并通过 `建模意图` 选择自动/相邻圆柱/缩放所属、修改Edge长度并通过 `建模意图` 选择自动/只改当前Edge/移动端面/相邻圆柱/缩放所属,以及直线Edge起点/终点坐标。 - 用户可以撤销/重做修改,避免一步编辑做坏。 - 用户可以导出修改后的完整模型。 - 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。 @@ -230,6 +230,8 @@ STEP/B-Rep 参数化编辑主线 │ │ └── 自动或手动配对两个圆弧端,槽宽保持不变。 │ ├── [x] [中心位置 -> 移动槽] │ │ └── 半圆槽和长圆槽都按特征整体补旧槽、切新槽。 +│ ├── [x] [复杂/交叉槽 -> 识别并快速阻止] +│ │ └── 多个同半径槽端但没有稳定共享侧壁配对时,会标成复杂槽/多槽组,不在扫描列表和参数表里暴露槽宽、槽深、弧长、弧角或轴心入口。 │ └── [ ] [复杂草图槽/跨复杂面槽 -> 稳定修改] │ └── 非圆柱槽、跨复杂面的槽暂不承诺稳定编辑;多槽组关联放到第 8 阶段。 │ @@ -248,28 +250,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 +285,11 @@ STEP/B-Rep 参数化编辑主线 │ ├── [x] [固定起点/终点/中心 -> Edge 长度锚点] │ │ └── 修改直线 Edge 长度时可选择固定起点、固定终点或固定中心,结果会按锚点反查。 │ ├── [x] [平行/垂直 -> 端面推拉一级关系守门] -│ │ └── 移动端面策略会确认直接相邻平面和移动端面可验证,执行后反查垂直/平行关系。 +│ │ └── 移动端面策略会确认直接相邻平面和移动端面可验证,执行前给出平面约束摘要,执行后反查垂直/平行关系。 +│ ├── [x] [平行/垂直 -> Face/Edge 一级事实识别] +│ │ └── Face 和 Edge 一级事实图会记录直接邻域内的平行、垂直、斜交关系数量和摘要,供 UI 说明、计划守门和后续约束求解复用。 │ ├── [~] [平行/垂直 -> 通用可选约束] -│ │ └── 跨多面、跨特征的平行/垂直约束仍需继续泛化,还不是通用约束求解。 +│ │ └── Edge 长度已开放受限的“保持关系”用户策略,会复用端面推拉并要求一级平面关系可验证;跨多面、跨特征的通用约束求解仍需继续泛化。 │ ├── [x] [复杂曲线 Edge -> 可编辑入口守门] │ │ └── B-spline / Bezier / 其它复杂曲线不再在扫描列表或参数表里伪装成稳定“改长度”入口。 │ └── [ ] [任意曲线 Edge -> 通用约束编辑] @@ -296,8 +304,8 @@ STEP/B-Rep 参数化编辑主线 │ │ └── 修改后仍需识别为目标球面。 │ ├── [x] [环面主/小半径 -> 缩放特征] │ │ └── 修改后仍需识别为目标环面。 -│ └── [ ] [完整抽壳/开口面编辑 -> 壳体特征级重建] -│ └── STEP 无历史下的通用抽壳关系恢复放在后期。 +│ └── [x] [完整抽壳/开口面编辑 -> 先识别开口薄壁上下文并明确限制] +│ └── 开口薄壁盒/槽腔类区域会标出“完整抽壳/开口面编辑”受限;可编辑对象扫描会优先露出真实薄壁的壳体厚度入口,当前仍只开放局部壳体厚度、平面推拉或整体缩放,不做通用抽壳历史恢复。 │ └── 8. 二级 / 三级关系,从“局部能改”走向“关系能保持” ├── [ ] [Face 二级传播 -> 孔底/槽底/台阶联动] @@ -332,7 +340,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 +398,8 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 | Face / 面 | 面内长度、面内宽度、中心、偏移、壳体厚度;圆柱端盖或完整圆柱侧面的高度。面积作为诊断结果只读展示。 | 拉伸/切除当前平面或圆柱端盖;移动当前面顶点并重建相邻面;或平移/缩放所属特征。 | 自由曲面会明确识别为只读,不开放伪参数化修改;带复杂内孔的面、复杂壳体局部区域、非完整圆柱侧面高度。 | | 孔 / 圆柱孔 | 规则孔的直径/半径、完整孔轴心、完整孔封堵、可识别盲孔/盲槽深度。 | 改孔径时同轴重切孔壁;移动孔时先补旧孔再切新孔;改深度时切削或补料孔底。 | 螺纹孔、锥孔、复杂孔组、底面识别不可靠的盲孔。 | | 槽 / 半孔 | 槽宽、槽深、弧长、弧角、开口角、简单轴心、长圆槽总长度/中心距;规则矩形槽/口袋的长宽、中心和深度。 | 按圆柱槽、长圆槽胶囊区域或矩形切除包络,先补旧槽,再切出新槽。 | 复杂草图槽、非圆柱槽、多槽联动、跨复杂面的槽;矩形槽中心沿深度方向移动暂不处理。 | -| 凸台 / 外圆 | 圆柱凸台的直径/半径、高度、轴心;规则矩形凸台/口袋的长度、宽度、中心、高度/深度;多台阶矩形凸台的顶层规则台阶。 | 移除旧特征包络后重建;或拉伸/切除端盖、轴向缩放。 | 异形凸台、多台阶凸台整组联动、复杂相邻拓扑;矩形长宽方向交换和沿高度/深度方向移动中心暂不在一次编辑中处理。 | -| 圆角 / 倒角 | 部分已有圆角的半径/弧长;直线边新增圆角或倒角;相邻同半径圆角链可识别并快速阻止。 | 已有简单圆角先移除再重建;新增圆角/倒角调用 OCCT 的圆角/倒角算法;圆角链不进入危险重建。 | 复杂 blend、圆角链稳定重建、变半径圆角、支撑面不明确的圆角。 | +| 凸台 / 外圆 | 圆柱凸台的直径/半径、高度、轴心;规则矩形凸台/口袋的长度、宽度、中心、高度/深度;多台阶矩形凸台的顶层规则台阶。 | 移除旧特征包络后重建;或拉伸/切除端盖、轴向缩放;多台阶中间过渡面会识别为受限并快速阻止。 | 异形凸台、多台阶凸台整组联动、复杂相邻拓扑;矩形长宽方向交换和沿高度/深度方向移动中心暂不在一次编辑中处理。 | +| 圆角 / 倒角 | 部分已有圆角的半径/弧长;简单等半径圆角链半径/弧长;直线边新增圆角或倒角;变半径/复杂圆角链可识别并快速阻止。 | 已有简单圆角和简单等半径圆角链先移除再重建;新增圆角/倒角调用 OCCT 的圆角/倒角算法;变半径圆角链和复杂 blend 不进入危险重建。 | 大链式 blend、变半径圆角稳定重建、复杂角部补面、支撑面不明确的圆角。 | | Solid / 整体特征 | 平移、旋转、包围盒尺寸、体积、表面积整体缩放。 | 对所属 Solid / 特征做整体变换。 | 装配约束、零件间关联约束、原 CAD 建模历史恢复。 | ## 当前可修改范围 @@ -403,9 +411,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 +455,7 @@ Face 编辑失败、超时或被稳定性保护阻止时,弹窗不应只显示 - 圆柱面会识别为圆柱孔候选、槽/半孔候选、圆角/倒圆候选、凸台/外圆候选或未明确圆柱特征。 - 圆柱特征会先把同一实体内同轴、同半径且轴向连续/重叠的完整圆柱Face当作同一侧壁区域,再尝试找出边界Edge、相邻Face、端面Face、开口端相邻Face和疑似底面 Face,并一起高亮相关局部区域。 - 槽/半孔候选会额外显示槽宽、圆弧长度、槽深估算和槽边界相邻Face。 - - 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑Face;如果直接相邻同半径圆角 Face 形成圆角链,会标出链内 Face 并在修改半径/弧长前快速阻止。 + - 圆角/倒圆候选会额外显示已有圆角半径估算、圆弧角度、圆弧长度和相邻支撑Face;如果共享边连通的同半径圆角 Face 形成 2 到 4 个面的简单小链,会标出链内 Face 并尝试整链半径/弧长重建;变半径或复杂长链会快速阻止。 - `特征探测级别` 切到 `探测相邻特征` 或 `探测二级特征` 后,关联孔、槽、凸台等可改尺寸可以作为 `关联 Face` 参数行合并到同一张参数表里;`关联探测` 本身属于诊断信息,不占用特征参数表。 - 当前选中对象表使用四列表格:`尺寸参数`、`当前值`、`建模意图` 和 `目标值`。表格只放可作为建模输入的尺寸,面积、识别摘要、一级关系、关联探测和其它只读诊断放在下方 `诊断信息(高级)` 中;用户改完目标值后统一点击 `参数化建模`,不再保留逐行执行入口。 - 属性面板会统一显示 `识别摘要`、`识别候选`、`识别置信度`、`识别风险`、`识别评分`、`识别结论`、`识别依据`、`识别限制` 和 `受限能力`。这些字段来自当前已知的 B-Rep 几何事实、一级拓扑关系、同域区域、材料采样和阻止原因,用来说明程序为什么把对象当作某类候选,而不是把猜测隐藏在代码里。`识别限制` 表示这个候选整体不能稳定编辑;`受限能力` 表示对象本身可识别、某些修改可用,但另一些子能力暂不开放。低置信解析曲面不会开放可编辑按钮,避免把“底层曲面类型像圆锥/球/环面”误当成稳定 CAD 设计参数。 @@ -572,13 +580,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 +728,8 @@ vertices: 3262 - 复杂特征识别:已经有平面、圆柱、边、孔/槽候选、槽/半孔候选、已有圆角候选、圆柱凸台候选等基础候选列表,但还不是完整 CAD 语义级识别。 - 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面 Face的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。 -- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度/深度/圆弧长度/圆弧角度/开口角度调整、槽宽/槽深/弧长的整体缩放所属对象语义、长圆槽总长度/中心距执行时自动配对和手动配对端 Face ID、完整圆柱凸台直径/半径/高度/轴心坐标修改、规则矩形凸台/口袋长度/宽度/中心/高度/深度局部重建、多台阶矩形凸台顶层规则台阶独立编辑、普通完整圆柱高度兜底拉伸/切除或通过 `建模意图` 做轴向整体缩放,以及平面壳体相对面厚度估算、局部厚度拉伸/切除和通过 `建模意图` 做厚度方向整体缩放;复杂槽、复杂凸台整体联动和完整壳体区域仍需要继续做。 -- 已有圆角/倒圆面半径:已经有当前版本的受限修改实现,使用 defeature + refillet 路线;直接相连的同半径圆角链会在识别层标成受限能力,不作为可编辑圆角项列出,选中后也会在计划阶段说明暂未实现稳定重建;复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。 +- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度/深度/圆弧长度/圆弧角度/开口角度调整、槽宽/槽深/弧长的整体缩放所属对象语义、长圆槽总长度/中心距执行时自动配对和手动配对端 Face ID、完整圆柱凸台直径/半径/高度/轴心坐标修改、规则矩形凸台/口袋长度/宽度/中心/高度/深度局部重建、多台阶矩形凸台顶层规则台阶独立编辑、多台阶中间过渡面识别和快速阻止、普通完整圆柱高度兜底拉伸/切除或通过 `建模意图` 做轴向整体缩放,以及平面壳体相对面厚度估算、局部厚度拉伸/切除和通过 `建模意图` 做厚度方向整体缩放;复杂槽、复杂凸台整体联动和完整壳体区域仍需要继续做。 +- 已有圆角/倒圆面半径/弧长:已经有当前版本的受限修改实现,使用 defeature + refillet 路线;2 到 4 个共享边连通面的简单等半径圆角链会作为高风险候选开放整链半径/弧长修改,变半径圆角链和更长复杂链会在识别层标成受限能力并在计划阶段说明暂未实现稳定重建;复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。 - 局部偏差分析:已经有编辑前后体积、面积、包围盒、距离热力图和差异报告,但还不是工程公差级的局部区域偏差报告。 - 错误恢复和模型修复:已经有编辑前快照、失败回滚、基础 B-Rep 校验、同域面合并、布尔结果自动修复,以及一键修复当前模型或选中零件/solid,但还不是完整的模型修复流水线。 - 多零件装配:已经有装配/零件树和单独导出入口,但还没有做大规模、多来源 STEP 装配测试。 @@ -752,7 +761,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 +845,7 @@ Face 阶段的当前验收口径: - `调整盲孔/盲槽深度` 行表示这个Face是较明确的盲孔/盲槽候选,可以在当前选中对象表里修改盲孔/盲槽深度。 - `给Edge添加圆角` 行表示这个对象是直线Edge,可以配合 `圆角半径` 和 `给Edge添加圆角` 使用。 - `给Edge添加倒角` 行表示这个对象是直线Edge,可以配合 `倒角距离` 和 `给Edge添加倒角` 使用。 -- `修改已有圆角半径` 行表示这个Face是已有圆角/倒圆候选,点击后会切到 `特征` 模式并预填圆角半径参考值;真正执行需要再点击编辑区里的 `修改已有圆角半径` 按钮并确认。 +- `修改已有圆角半径/弧长` / `修改已有圆角链半径/弧长` 行表示这个Face是已有圆角/倒圆候选或简单等半径圆角链候选,点击后会切到 `特征` 模式并预填圆角半径/圆角弧长参考值;真正执行需要在特征参数表里改目标值,再点击 `参数化建模` 并确认。 - `状态` 为 `ready` 表示比较适合尝试,`caution` 表示可以尝试但风险更高,`blocked` 表示当前参数或对象不适合执行。 - `风险` 越高越应该谨慎,尤其是圆角、凸柱或未明确圆柱面,不要直接当孔修改。 - 点击任意一行会自动选中并高亮对应Face或Edge。 @@ -1046,8 +1055,8 @@ Face 阶段的当前验收口径: - 在当前选中对象表里修改 `圆角半径` 或 `圆角弧长`,并把 `建模意图` 设为 `重建圆角`。 - 输入目标值后点击 `参数化建模`。 - 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。 - - 通过检查后,会显示蓝色半透明预览,表示将尝试移除并重建的已有圆角面。 - - 真实编辑会先用 OCCT defeaturing 移除已有圆角面,再从恢复出的候选锐边中寻找可重新倒圆的边并调用 OCCT 倒圆 API。 + - 通过检查后,会显示蓝色半透明预览,表示将尝试移除并重建的已有圆角面;简单等半径圆角链会把链上圆角面一起纳入重建计划。 + - 真实编辑会先用 OCCT defeaturing 移除已有圆角面或链上圆角面,再从恢复出的候选锐边中寻找可重新倒圆的边并调用 OCCT 倒圆 API。 - 这是 B-Rep 几何重建,不是 CAD 历史圆角参数编辑;复杂 blend、支撑面不明确或恢复锐边失败时会失败并回滚。 - `圆角半径` / `圆角弧长` 的 `缩放特征`: - 先选择一个已有圆角/倒圆候选 Face。 @@ -1170,7 +1179,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 区域一起拉伸/切除 @@ -1316,15 +1325,15 @@ 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 个一级侧面会跟随重建,二级底面保持不动。 @@ -1357,7 +1366,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 +1397,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、孔、槽、凸台和圆角也要按同样思路设计。当多种结果都合理时,不要让程序暗自决定;要把“改法、约束、影响对象、跟随关系”做成当前选中对象里的策略行、下拉框或清晰按钮,并用普通用户能理解的话解释。 @@ -1441,9 +1450,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 +1482,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` 会同时覆盖矩形凸台/口袋长度/宽度、中心、高度/深度、多台阶矩形凸台顶层台阶编辑和中间过渡面快速阻止。 diff --git a/scripts/verify_edge_edit_suite.py b/scripts/verify_edge_edit_suite.py index 1fd4690..3aeb04a 100644 --- a/scripts/verify_edge_edit_suite.py +++ b/scripts/verify_edge_edit_suite.py @@ -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"), diff --git a/scripts/verify_edge_first_level_topology.py b/scripts/verify_edge_first_level_topology.py index c2f69bd..641fb22 100644 --- a/scripts/verify_edge_first_level_topology.py +++ b/scripts/verify_edge_first_level_topology.py @@ -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") diff --git a/scripts/verify_edge_intent_specs.py b/scripts/verify_edge_intent_specs.py index 26e2424..62a5cbd 100644 --- a/scripts/verify_edge_intent_specs.py +++ b/scripts/verify_edge_intent_specs.py @@ -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 diff --git a/scripts/verify_edge_isolated_edit.py b/scripts/verify_edge_isolated_edit.py index 9e85217..b5c8bf4 100644 --- a/scripts/verify_edge_isolated_edit.py +++ b/scripts/verify_edge_isolated_edit.py @@ -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 diff --git a/scripts/verify_edge_length_resize.py b/scripts/verify_edge_length_resize.py index 7c0baa6..0221a6c 100644 --- a/scripts/verify_edge_length_resize.py +++ b/scripts/verify_edge_length_resize.py @@ -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 ''}") 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, diff --git a/scripts/verify_edge_round_chamfer.py b/scripts/verify_edge_round_chamfer.py index 1f44f74..5a94356 100644 --- a/scripts/verify_edge_round_chamfer.py +++ b/scripts/verify_edge_round_chamfer.py @@ -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 ''}") +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 ''}") + + 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 diff --git a/scripts/verify_face_ui_isolation_contract.py b/scripts/verify_face_ui_isolation_contract.py index 0cc2884..4d54126 100644 --- a/scripts/verify_face_ui_isolation_contract.py +++ b/scripts/verify_face_ui_isolation_contract.py @@ -54,6 +54,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", @@ -122,8 +123,6 @@ def _assert_same(label: str, actual: set[str], expected: set[str]) -> None: PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION = { "push_pull_face": "push_pull_face", "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 +138,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", } @@ -277,6 +277,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", diff --git a/scripts/verify_feature_recognition_summary.py b/scripts/verify_feature_recognition_summary.py index 3cda2f5..ac531a0 100644 --- a/scripts/verify_feature_recognition_summary.py +++ b/scripts/verify_feature_recognition_summary.py @@ -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 diff --git a/scripts/verify_first_level_fact_graph.py b/scripts/verify_first_level_fact_graph.py index 912f382..6be847d 100644 --- a/scripts/verify_first_level_fact_graph.py +++ b/scripts/verify_first_level_fact_graph.py @@ -80,6 +80,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 +105,22 @@ 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}", + ) probe = _FactProbe(model) fields = probe._face_first_level_selection_fields(face_id) diff --git a/scripts/verify_prismatic_feature.py b/scripts/verify_prismatic_feature.py index 1f5c0fe..e93fb00 100644 --- a/scripts/verify_prismatic_feature.py +++ b/scripts/verify_prismatic_feature.py @@ -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( diff --git a/scripts/verify_property_editor_specs.py b/scripts/verify_property_editor_specs.py index afd3fe8..81ef16c 100644 --- a/scripts/verify_property_editor_specs.py +++ b/scripts/verify_property_editor_specs.py @@ -788,6 +788,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 +817,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 +901,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 +1113,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"}, diff --git a/scripts/verify_round_chamfer_edit_suite.py b/scripts/verify_round_chamfer_edit_suite.py index 7d93cf5..a4e520a 100644 --- a/scripts/verify_round_chamfer_edit_suite.py +++ b/scripts/verify_round_chamfer_edit_suite.py @@ -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",), ), ( diff --git a/scripts/verify_shell_thickness_resize.py b/scripts/verify_shell_thickness_resize.py index 52f57f6..a1cc7f6 100644 --- a/scripts/verify_shell_thickness_resize.py +++ b/scripts/verify_shell_thickness_resize.py @@ -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: - _run_case(mode, args.source_thickness, args.target_thickness, args.tolerance) + 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 diff --git a/scripts/verify_slot_resize.py b/scripts/verify_slot_resize.py index 01b6bec..6d1b6fe 100644 --- a/scripts/verify_slot_resize.py +++ b/scripts/verify_slot_resize.py @@ -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) diff --git a/step_editor/features.py b/step_editor/features.py index 678ea53..6137e7d 100644 --- a/step_editor/features.py +++ b/step_editor/features.py @@ -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,13 +804,26 @@ 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", ""), } ) - shell_thickness_count += 1 + + 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 chamfer_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.") diff --git a/step_editor/isolated_edit_worker.py b/step_editor/isolated_edit_worker.py index bac49c6..57c7007 100644 --- a/step_editor/isolated_edit_worker.py +++ b/step_editor/isolated_edit_worker.py @@ -117,6 +117,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": diff --git a/step_editor/model.py b/step_editor/model.py index df2865d..c7dfa64 100644 --- a/step_editor/model.py +++ b/step_editor/model.py @@ -126,6 +126,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self._edge_first_level_topology_cache: dict[int, dict[str, object]] = {} self._edge_first_level_fact_cache: dict[int, dict[str, object]] = {} self._local_face_deform_readiness_cache: dict[int, dict[str, object]] = {} + self._open_shell_context_cache: dict[int, dict[str, object]] = {} self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None self._same_domain_internal_edge_ids_cache: set[int] | None = None self._same_domain_duplicate_edge_ids_cache: set[int] | None = None @@ -215,6 +216,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self._edge_first_level_topology_cache.clear() self._edge_first_level_fact_cache.clear() self._local_face_deform_readiness_cache.clear() + self._open_shell_context_cache.clear() self._edge_duplicate_key_ids_cache = None self._same_domain_internal_edge_ids_cache = None self._same_domain_duplicate_edge_ids_cache = None @@ -311,6 +313,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self._cylindrical_first_level_topology_cache.clear() self._face_first_level_fact_cache.clear() self._local_face_deform_readiness_cache.clear() + self._open_shell_context_cache.clear() def _restore_face_logical_ids_if_count_matches(self, logical_ids: Iterable[int]) -> bool: previous = tuple(int(item) for item in logical_ids) @@ -325,6 +328,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self._cylindrical_first_level_topology_cache.clear() self._face_first_level_fact_cache.clear() self._local_face_deform_readiness_cache.clear() + self._open_shell_context_cache.clear() return True def quick_face_info(self, face_id: int) -> dict[str, object]: @@ -868,6 +872,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd ("depth_status", "depth_risk", "depth_blockers", "盲孔/盲槽深度"), ("suppress_status", "suppress_risk", "suppress_blockers", "封堵孔/槽"), ("existing_fillet_status", "existing_fillet_risk", "existing_fillet_blockers", "已有圆角半径"), + ("existing_chamfer_status", "existing_chamfer_risk", "existing_chamfer_blockers", "已有倒角距离"), ("fillet_status", "fillet_risk", "fillet_blockers", "已有圆角半径"), ("chamfer_status", "chamfer_risk", "chamfer_blockers", "倒角"), ) @@ -905,7 +910,19 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd has_local_face_deform = bool(info.get("local_face_deform_ready")) has_slot_candidate = str(info.get("slot_status") or "").strip() == "candidate" has_shell_candidate = str(info.get("shell_region_status") or "").strip() == "candidate" + has_open_shell_context = str(info.get("open_shell_context_status") or "").strip() == "limited" + has_existing_chamfer_candidate = str(info.get("existing_chamfer_status") or "").strip() == "candidate" has_analytic_surface_candidate = surface in {"cone", "sphere", "torus"} and bool(feature_type_text) + if has_existing_chamfer_candidate: + has_planar_push_pull_candidate = False + has_local_face_deform = False + has_shell_candidate = False + is_multistep_prismatic_blocked = str(info.get("multistep_prismatic_status") or "").strip() == "blocked" + if is_multistep_prismatic_blocked: + has_planar_push_pull_candidate = False + has_local_face_deform = False + has_shell_candidate = False + has_analytic_surface_candidate = False has_available_capability = bool( available_capabilities or has_planar_push_pull_candidate @@ -925,6 +942,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd risk = value if surface in FREEFORM_FACE_SURFACES: risk = "blocked" + if is_multistep_prismatic_blocked: + risk = "blocked" + confidence = "medium" if confidence not in {"high", "medium"} else confidence for status_key, risk_key, _blocker_key, _label in relevant_capability_specs: value = str(info.get(risk_key) or "").strip() if value == "blocked" and has_available_capability: @@ -990,6 +1010,12 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd add("slot_geometry", "部分圆柱槽/半孔几何") if info.get("shell_region_status") == "candidate": add("shell_opposite_face", "找到相对平面/壳体候选") + if is_multistep_prismatic_blocked: + add("multistep_prismatic", "多台阶过渡面已识别为受限") + if has_open_shell_context: + add("open_shell_context", "识别到开口薄壁壳体上下文") + if has_existing_chamfer_candidate: + add("existing_chamfer_geometry", "识别到简单已有倒角斜面") add("user_operation_priority", f"常用操作优先级={user_priority_label}") ready_actions: list[str] = [] @@ -1021,8 +1047,12 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd add_action(limited_actions, "局部重建尺寸/中心/偏移") if has_shell_candidate: add_action(ready_actions, "壳体厚度") + if has_open_shell_context: + add_action(limited_actions, "完整抽壳/开口面编辑") if has_slot_candidate: add_action(ready_actions, "槽/半孔宽度/深度/弧长") + if has_existing_chamfer_candidate: + add_action(ready_actions, "已有倒角距离") if surface == "cone" and feature_type_text: add_action(ready_actions, "圆锥参考半径/直径/半角") elif surface == "sphere" and feature_type_text: @@ -1030,7 +1060,12 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd elif surface == "torus" and feature_type_text: add_action(ready_actions, "环面主/小半径或直径") - for key in ("local_face_deform_blocker", "first_level_topology_blockers"): + for key in ( + "multistep_prismatic_blockers", + "local_face_deform_blocker", + "first_level_topology_blockers", + "open_shell_blockers", + ): text = str(info.get(key) or "").strip() if not text: continue @@ -1465,11 +1500,16 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd boundary_edge_ids = self._region_boundary_edge_ids(coplanar_face_ids) shell_info = self._planar_shell_region_info(face_id, coplanar_face_ids, info) prismatic_info = self._planar_rectangular_profile_info(face_id, coplanar_face_ids, info, shell_info) + multistep_info = self._planar_multistep_prismatic_block_info(face_id, info, prismatic_info) + open_shell_info = self._planar_open_shell_context_info(face_id, info, shell_info, prismatic_info) + chamfer_info = self._planar_existing_chamfer_info(face_id, info) if len(coplanar_face_ids) > 1: scope_note = f"已检测到 {len(coplanar_face_ids)} 个共面且相接/重叠的 face,拉伸/切除时会作为同一片平面区域处理。" else: scope_note = "当前 face 没有检测到可一起拉伸/切除的共面相接/重叠邻居。" edit_actions = "拉伸/切除平面" + feature_type = "可拉伸/切除平面候选" + feature_mode = "这是从 B-Rep 几何推断出的平面编辑候选,不是 CAD 历史特征。" if shell_info.get("shell_region_status") == "candidate": edit_actions += ";调整壳体厚度" if prismatic_info.get("prismatic_profile_status") == "candidate": @@ -1478,16 +1518,33 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd edit_actions += ";调整棱柱高度/凹槽深度" else: edit_actions += ";沿法向拉伸/切除" + if chamfer_info.get("existing_chamfer_status") == "candidate": + feature_type = "已有倒角平面候选" + edit_actions = "修改已有倒角距离" + feature_mode = ( + "这是从小面积斜平面、两张支撑平面和一级边界推断出的已有倒角候选;" + "当前只按简单等距直线倒角处理,不恢复 CAD 历史特征。" + ) + if multistep_info.get("multistep_prismatic_status") == "blocked": + edit_actions = "只读诊断;当前不开放多台阶凸台整组联动修改" + feature_type = multistep_info.get("feature_type", "复杂多台阶凸台过渡面(暂不支持修改)") + if open_shell_info.get("open_shell_context_status") == "limited" and multistep_info.get( + "multistep_prismatic_status" + ) != "blocked": + edit_actions += ";完整抽壳/开口面编辑暂未实现" highlight_face_ids = set(coplanar_face_ids) highlight_face_ids.update(_int_values(prismatic_info.get("prismatic_highlight_face_ids"))) + highlight_face_ids.update(_int_values(open_shell_info.get("open_shell_highlight_face_ids"))) + highlight_face_ids.update(_int_values(chamfer_info.get("feature_existing_chamfer_support_face_ids"))) + highlight_face_ids.update(_int_values(chamfer_info.get("feature_existing_chamfer_end_face_ids"))) result = dict(info) result.update( { "kind": "feature", - "feature_type": "可拉伸/切除平面候选", + "feature_type": feature_type, "feature_source_face_id": face_id, "feature_face_ids": tuple(coplanar_face_ids), - "feature_highlight_face_ids": tuple(coplanar_face_ids), + "feature_highlight_face_ids": tuple(sorted(highlight_face_ids)), "feature_boundary_edge_ids": tuple(boundary_edge_ids), "feature_adjacent_face_ids": tuple( sorted(set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - set(coplanar_face_ids)) @@ -1496,13 +1553,271 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "push_pull_scope_face_count": len(coplanar_face_ids), "push_pull_scope_note": scope_note, "feature_edit_actions": edit_actions, - "feature_mode": "这是从 B-Rep 几何推断出的平面编辑候选,不是 CAD 历史特征。", + "feature_mode": feature_mode, **shell_info, **prismatic_info, + **chamfer_info, + **multistep_info, + **open_shell_info, } ) + if multistep_info.get("multistep_prismatic_status") == "blocked": + result.update( + { + "feature_type": multistep_info.get("feature_type", "复杂多台阶凸台过渡面(暂不支持修改)"), + "feature_edit_actions": edit_actions, + "feature_mode": ( + "这是多台阶凸台/基座之间的过渡平面;当前阶段只支持顶层规则矩形台阶独立编辑," + "不把整组台阶联动重建伪装成可改参数。" + ), + "recognition_confidence": "medium", + "recognition_risk": "blocked", + "recognition_blockers": multistep_info.get("multistep_prismatic_blockers", ""), + } + ) + elif chamfer_info.get("existing_chamfer_status") == "candidate": + result.update( + { + "feature_type": "已有倒角平面候选", + "feature_guess": "chamfer candidate", + "feature_edit_actions": "修改已有倒角距离", + "feature_mode": feature_mode, + "local_face_deform_ready": False, + "local_face_deform_blocker": "", + } + ) return result + def _planar_existing_chamfer_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]: + if info.get("surface") != "plane": + return {} + if bool(info.get("has_inner_boundaries")): + return {} + try: + solid_id = int(info.get("solid_id")) + except (TypeError, ValueError): + solid_id = -1 + if solid_id < 0 or solid_id >= len(self.solids): + return {} + + normal = _tuple_or_none(info.get("normal")) + if normal is None: + return {} + + boundary_edge_ids = self._face_boundary_edge_ids(face_id) + if len(boundary_edge_ids) != 4: + return {} + + edge_rows: list[dict[str, object]] = [] + for edge_id in boundary_edge_ids: + try: + edge_info = self.edge_info(edge_id) + except Exception: + continue + if edge_info.get("curve") != "line": + return {} + length = _float_or_none(edge_info.get("length")) + direction = _tuple_or_none(edge_info.get("direction")) + if length is None or length <= 1e-9 or direction is None: + return {} + edge_rows.append( + { + "edge_id": edge_id, + "length": float(length), + "direction": direction, + } + ) + if len(edge_rows) != 4: + return {} + + lengths = sorted(float(row["length"]) for row in edge_rows) + shortest = lengths[0] + second_shortest = lengths[1] + longest = lengths[-1] + diagonal = max(_shape_diagonal(self.solids[solid_id][1]), 1.0) + length_tolerance = max(shortest * 0.18, diagonal * 1e-5, 1e-5) + if abs(second_shortest - shortest) > length_tolerance: + return {} + if longest < shortest * 2.0: + return {} + + support_candidates: list[dict[str, object]] = [] + end_face_ids: list[int] = [] + adjacent_face_ids = sorted(set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - {face_id}) + for adjacent_id in adjacent_face_ids: + if self.face_solid_ids[adjacent_id] != solid_id: + continue + try: + adjacent_info = self.face_info(adjacent_id) + except Exception: + continue + if adjacent_info.get("surface") != "plane": + continue + adjacent_normal = _tuple_or_none(adjacent_info.get("normal")) + if adjacent_normal is None: + continue + chamfer_dot = abs(_tuple_dot(normal, adjacent_normal)) + if 0.52 <= chamfer_dot <= 0.86: + support_candidates.append( + { + "face_id": adjacent_id, + "normal": adjacent_normal, + "chamfer_dot": chamfer_dot, + } + ) + elif chamfer_dot <= 0.16: + end_face_ids.append(adjacent_id) + + best_pair: tuple[float, dict[str, object], dict[str, object]] | None = None + target_chamfer_dot = math.sqrt(0.5) + for left_index, left in enumerate(support_candidates): + for right in support_candidates[left_index + 1 :]: + support_dot = abs(_tuple_dot(left["normal"], right["normal"])) + if support_dot > 0.22: + continue + balance = abs(float(left["chamfer_dot"]) - target_chamfer_dot) + abs( + float(right["chamfer_dot"]) - target_chamfer_dot + ) + score = support_dot + balance + if best_pair is None or score < best_pair[0]: + best_pair = (score, left, right) + if best_pair is None: + return {} + + support_face_ids = tuple(sorted((int(best_pair[1]["face_id"]), int(best_pair[2]["face_id"])))) + distance_estimate = shortest / math.sqrt(2.0) + if distance_estimate <= 0.0 or distance_estimate > diagonal * 0.16: + return {} + + short_edge_ids = tuple( + int(row["edge_id"]) + for row in edge_rows + if abs(float(row["length"]) - shortest) <= length_tolerance + ) + long_edge_rows = sorted(edge_rows, key=lambda row: float(row["length"]), reverse=True) + long_edge_ids = tuple(int(row["edge_id"]) for row in long_edge_rows[:2]) + chamfer_angle = math.degrees(math.acos(max(-1.0, min(1.0, float(best_pair[1]["chamfer_dot"]))))) + support_angle = math.degrees( + math.acos(max(-1.0, min(1.0, abs(_tuple_dot(best_pair[1]["normal"], best_pair[2]["normal"]))))) + ) + warnings: list[str] = [] + if abs(support_angle - 90.0) > 8.0: + warnings.append("两张支撑面不是严格 90 度,倒角距离只是近似估算。") + if abs(chamfer_angle - 45.0) > 8.0: + warnings.append("倒角面不是严格 45 度,当前只按等距倒角近似处理。") + + return { + "feature_guess": "chamfer candidate", + "existing_chamfer_kind": "planar-equal-distance-straight-chamfer", + "existing_chamfer_status": "candidate", + "existing_chamfer_risk": "medium" if not warnings else "high", + "existing_chamfer_warnings": ";".join(warnings), + "existing_chamfer_blockers": "", + "existing_chamfer_distance_estimate": distance_estimate, + "existing_chamfer_cross_edge_length_estimate": shortest, + "existing_chamfer_long_edge_length_estimate": longest, + "existing_chamfer_support_angle_degrees": support_angle, + "existing_chamfer_face_angle_degrees": chamfer_angle, + "feature_existing_chamfer_face_ids": (face_id,), + "feature_existing_chamfer_support_face_ids": support_face_ids, + "feature_existing_chamfer_end_face_ids": tuple(sorted(set(end_face_ids))), + "feature_existing_chamfer_short_edge_ids": short_edge_ids, + "feature_existing_chamfer_long_edge_ids": long_edge_ids, + "existing_chamfer_note": ( + "这是由小面积斜平面和两张近似垂直支撑面推断出的简单等距倒角;" + "当前只承诺单个直线倒角面,倒角链、不等距倒角和复杂过渡面会继续保持受限。" + ), + } + + def _planar_multistep_prismatic_block_info( + self, + face_id: int, + info: dict[str, object], + prismatic_info: dict[str, object], + ) -> dict[str, object]: + if info.get("surface") != "plane": + return {} + if str(prismatic_info.get("prismatic_feature_semantics") or "") in {"additive-boss", "subtractive-pocket"}: + return {} + try: + source_surface = BRepAdaptor_Surface(self.faces[face_id]) + if source_surface.GetType() != GeomAbs_Plane: + return {} + source_plane = source_surface.Plane() + source_normal = source_plane.Axis().Direction() + except Exception: + return {} + + boundary_edge_ids = self._face_boundary_edge_ids(face_id) + adjacent_face_ids = sorted(set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - {face_id}) + if len(adjacent_face_ids) < 6: + return {} + + solid_id = self.face_solid_ids[face_id] + tolerance = max(_shape_diagonal(self.faces[face_id]) * 1e-6, 1e-6) + positive_parallel_faces: set[int] = set() + negative_parallel_faces: set[int] = set() + positive_side_faces: set[int] = set() + negative_side_faces: set[int] = set() + + for side_id in adjacent_face_ids: + if side_id < 0 or side_id >= len(self.faces): + continue + try: + side_surface = BRepAdaptor_Surface(self.faces[side_id]) + except Exception: + continue + if side_surface.GetType() != GeomAbs_Plane: + continue + side_neighbors = self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(side_id), side_id) + for candidate_id in side_neighbors: + if candidate_id == face_id or candidate_id < 0 or candidate_id >= len(self.faces): + continue + if solid_id >= 0 and self.face_solid_ids[candidate_id] != solid_id: + continue + try: + candidate_surface = BRepAdaptor_Surface(self.faces[candidate_id]) + if candidate_surface.GetType() != GeomAbs_Plane: + continue + candidate_plane = candidate_surface.Plane() + normal_dot = _direction_dot(source_normal, candidate_plane.Axis().Direction()) + if abs(normal_dot) < 0.995: + continue + signed_distance = _axis_parameter( + source_plane.Location(), + source_normal, + candidate_plane.Location(), + ) + except Exception: + continue + if abs(signed_distance) <= tolerance: + continue + if signed_distance > 0: + positive_parallel_faces.add(candidate_id) + positive_side_faces.add(side_id) + else: + negative_parallel_faces.add(candidate_id) + negative_side_faces.add(side_id) + + if not positive_parallel_faces or not negative_parallel_faces: + return {} + + blockers = ( + "当前 Face 位于多台阶凸台/基座的中间过渡位置,上下两侧都连接了其它平行台阶面;" + "当前阶段只支持顶层规则矩形台阶独立修改,暂不支持多台阶整组联动重建。" + ) + return { + "multistep_prismatic_status": "blocked", + "multistep_prismatic_kind": "intermediate-step-plane", + "multistep_prismatic_blockers": blockers, + "multistep_prismatic_note": blockers, + "multistep_prismatic_positive_face_ids": tuple(sorted(positive_parallel_faces)), + "multistep_prismatic_negative_face_ids": tuple(sorted(negative_parallel_faces)), + "multistep_prismatic_positive_side_face_ids": tuple(sorted(positive_side_faces)), + "multistep_prismatic_negative_side_face_ids": tuple(sorted(negative_side_faces)), + "feature_type": "复杂多台阶凸台过渡面(暂不支持修改)", + } + def _planar_rectangular_profile_info( self, face_id: int, @@ -1863,6 +2178,251 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd ), } + def _open_shell_context_for_solid(self, solid_id: int) -> dict[str, object]: + cached = self._open_shell_context_cache.get(solid_id) + if cached is not None: + return dict(cached) + + result: dict[str, object] = {"open_shell_context_status": "not-detected"} + if solid_id < 0 or solid_id >= len(self.solids): + self._open_shell_context_cache[solid_id] = dict(result) + return result + + face_ids = [index for index, current_solid_id in enumerate(self.face_solid_ids) if current_solid_id == solid_id] + if len(face_ids) < 10: + self._open_shell_context_cache[solid_id] = dict(result) + return result + + solid_shape = self.solids[solid_id][1] + try: + bounds = _shape_bounds_info(solid_shape) + except Exception: + self._open_shell_context_cache[solid_id] = dict(result) + return result + + bbox_min = _tuple_or_none(bounds.get("bbox_min")) + bbox_max = _tuple_or_none(bounds.get("bbox_max")) + bbox_size = _tuple_or_none(bounds.get("bbox_size")) + if bbox_min is None or bbox_max is None or bbox_size is None: + self._open_shell_context_cache[solid_id] = dict(result) + return result + + positive_sizes = [value for value in bbox_size if value > 1e-9] + if len(positive_sizes) < 3: + self._open_shell_context_cache[solid_id] = dict(result) + return result + + diagonal = max(_shape_diagonal(solid_shape), 1.0) + tolerance = min(max(diagonal * 1e-6, 1e-6), 1e-3) + smallest_solid_span = min(positive_sizes) + narrow_span_limit = max(smallest_solid_span * 0.28, diagonal * 0.015, tolerance * 10.0) + side_areas = [[0.0, 0.0] for _ in range(3)] + side_face_ids: list[list[list[int]]] = [[[], []] for _ in range(3)] + narrow_face_ids: set[int] = set() + narrow_spans: list[float] = [] + planar_face_ids: list[int] = [] + + for candidate_id in face_ids: + try: + surf = BRepAdaptor_Surface(self.faces[candidate_id]) + except Exception: + continue + if surf.GetType() != GeomAbs_Plane: + continue + planar_face_ids.append(candidate_id) + candidate_info = self.face_info(candidate_id) + area = _float_or_none(candidate_info.get("area")) or 0.0 + center = _tuple_or_none(candidate_info.get("area_center")) or _tuple_or_none(candidate_info.get("bbox_center")) + if center is not None and area > 0.0: + for axis_index in range(3): + axis_tolerance = max(tolerance, abs(bbox_size[axis_index]) * 1e-5) + if abs(center[axis_index] - bbox_min[axis_index]) <= axis_tolerance: + side_areas[axis_index][0] += area + side_face_ids[axis_index][0].append(candidate_id) + if abs(center[axis_index] - bbox_max[axis_index]) <= axis_tolerance: + side_areas[axis_index][1] += area + side_face_ids[axis_index][1].append(candidate_id) + + local_width = _float_or_none(candidate_info.get("local_face_width")) + local_height = _float_or_none(candidate_info.get("local_face_height")) + spans = [value for value in (local_width, local_height) if value is not None and value > tolerance] + if len(spans) >= 2: + small = min(spans) + large = max(spans) + if small <= narrow_span_limit and large >= small * 2.2: + narrow_face_ids.add(candidate_id) + narrow_spans.append(small) + + if len(planar_face_ids) < 10 or len(narrow_face_ids) < 4 or not narrow_spans: + self._open_shell_context_cache[solid_id] = dict(result) + return result + + sorted_spans = sorted(narrow_spans) + wall_thickness = sorted_spans[len(sorted_spans) // 2] + open_candidates: list[tuple[float, int, int, float, float]] = [] + for axis_index in range(3): + other_axes = [index for index in range(3) if index != axis_index] + full_side_area = max(bbox_size[other_axes[0]] * bbox_size[other_axes[1]], 1e-12) + minus_ratio = side_areas[axis_index][0] / full_side_area + plus_ratio = side_areas[axis_index][1] / full_side_area + for open_side_index, open_ratio, closed_ratio in ( + (0, minus_ratio, plus_ratio), + (1, plus_ratio, minus_ratio), + ): + if closed_ratio >= 0.72 and 0.02 <= open_ratio <= 0.68: + contrast = closed_ratio - open_ratio + open_candidates.append((contrast, axis_index, open_side_index, open_ratio, closed_ratio)) + + if not open_candidates: + self._open_shell_context_cache[solid_id] = dict(result) + return result + + open_candidates.sort(reverse=True) + _contrast, open_axis, open_side_index, open_ratio, closed_ratio = open_candidates[0] + rim_face_ids = set(side_face_ids[open_axis][open_side_index]) + closed_side_face_ids = set(side_face_ids[open_axis][1 - open_side_index]) + interior_face_ids: set[int] = set() + offset_tolerance = max(wall_thickness * 0.38, tolerance * 10.0) + + for candidate_id in planar_face_ids: + candidate_info = self.face_info(candidate_id) + center = _tuple_or_none(candidate_info.get("area_center")) or _tuple_or_none(candidate_info.get("bbox_center")) + normal = _tuple_normalized(_tuple_or_none(candidate_info.get("normal"))) + if center is None or normal is None: + continue + for axis_index in range(3): + if abs(normal[axis_index]) < 0.92: + continue + side_distance = min( + abs(center[axis_index] - bbox_min[axis_index]), + abs(center[axis_index] - bbox_max[axis_index]), + ) + if abs(side_distance - wall_thickness) <= offset_tolerance: + interior_face_ids.add(candidate_id) + + open_direction = [0.0, 0.0, 0.0] + open_direction[open_axis] = 1.0 if open_side_index == 1 else -1.0 + axis_names = ("X", "Y", "Z") + related_face_ids = set(narrow_face_ids) + related_face_ids.update(rim_face_ids) + related_face_ids.update(interior_face_ids) + related_face_ids.update(closed_side_face_ids) + blockers = ( + "已识别为开口薄壁壳体上下文;当前只支持局部壳体厚度、平面推拉或整体缩放," + "完整抽壳/开口面特征级重建暂未实现。" + ) + result = { + "open_shell_context_status": "limited", + "open_shell_kind": "open-thin-wall-cavity", + "open_shell_axis": axis_names[open_axis], + "open_shell_side": "max" if open_side_index == 1 else "min", + "open_shell_open_direction": tuple(open_direction), + "open_shell_wall_thickness_estimate": wall_thickness, + "open_shell_open_side_area_ratio": open_ratio, + "open_shell_closed_side_area_ratio": closed_ratio, + "open_shell_rim_face_ids": tuple(sorted(rim_face_ids)), + "open_shell_closed_side_face_ids": tuple(sorted(closed_side_face_ids)), + "open_shell_inner_face_ids": tuple(sorted(interior_face_ids)), + "open_shell_thin_wall_face_ids": tuple(sorted(narrow_face_ids)), + "open_shell_related_face_ids": tuple(sorted(related_face_ids)), + "open_shell_highlight_face_ids": tuple(sorted(related_face_ids)), + "open_shell_limited_action": "完整抽壳/开口面编辑", + "open_shell_blockers": blockers, + "open_shell_note": blockers, + } + self._open_shell_context_cache[solid_id] = dict(result) + return result + + def _planar_open_shell_context_info( + self, + face_id: int, + info: dict[str, object], + shell_info: dict[str, object], + prismatic_info: dict[str, object], + ) -> dict[str, object]: + try: + solid_id = int(info.get("solid_id", -1)) + except (TypeError, ValueError): + solid_id = -1 + context = self._open_shell_context_for_solid(solid_id) + if context.get("open_shell_context_status") != "limited": + return {} + + related_face_ids = set(_int_values(context.get("open_shell_related_face_ids"))) + rim_face_ids = set(_int_values(context.get("open_shell_rim_face_ids"))) + inner_face_ids = set(_int_values(context.get("open_shell_inner_face_ids"))) + thin_wall_face_ids = set(_int_values(context.get("open_shell_thin_wall_face_ids"))) + wall_thickness = _float_or_none(context.get("open_shell_wall_thickness_estimate")) + shell_thickness = _float_or_none(shell_info.get("shell_thickness_estimate")) + related = face_id in related_face_ids + if ( + not related + and wall_thickness is not None + and shell_thickness is not None + and shell_info.get("shell_region_status") == "candidate" + and abs(shell_thickness - wall_thickness) <= max(wall_thickness * 0.38, 1e-6) + ): + related = True + if not related and prismatic_info.get("prismatic_profile_status") == "candidate": + related = self._planar_face_is_near_open_shell_offset(face_id, info, context) + if not related: + return {} + + if face_id in rim_face_ids: + role = "opening-rim" + elif face_id in inner_face_ids: + role = "inner-shell-face" + elif face_id in thin_wall_face_ids: + role = "thin-wall-face" + elif shell_info.get("shell_region_status") == "candidate": + role = "shell-thickness-face" + else: + role = "shell-context-face" + + result = dict(context) + result["open_shell_current_face_role"] = role + result["open_shell_current_face_id"] = face_id + return result + + def _planar_face_is_near_open_shell_offset( + self, + face_id: int, + info: dict[str, object], + context: dict[str, object], + ) -> bool: + try: + solid_id = int(info.get("solid_id", -1)) + except (TypeError, ValueError): + return False + if solid_id < 0 or solid_id >= len(self.solids): + return False + wall_thickness = _float_or_none(context.get("open_shell_wall_thickness_estimate")) + if wall_thickness is None or wall_thickness <= 1e-9: + return False + center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) + normal = _tuple_normalized(_tuple_or_none(info.get("normal"))) + if center is None or normal is None: + return False + try: + bounds = _shape_bounds_info(self.solids[solid_id][1]) + except Exception: + return False + bbox_min = _tuple_or_none(bounds.get("bbox_min")) + bbox_max = _tuple_or_none(bounds.get("bbox_max")) + if bbox_min is None or bbox_max is None: + return False + tolerance = max(wall_thickness * 0.38, 1e-6) + for axis_index in range(3): + if abs(normal[axis_index]) < 0.92: + continue + side_distance = min( + abs(center[axis_index] - bbox_min[axis_index]), + abs(center[axis_index] - bbox_max[axis_index]), + ) + if abs(side_distance - wall_thickness) <= tolerance: + return True + return False + def _cylindrical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]: side_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id] surf = BRepAdaptor_Surface(self.faces[face_id]) @@ -1925,14 +2485,26 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd if angular_span >= math.tau * 0.92: edit_actions += ";封堵圆柱孔" elif guess == "round/fillet candidate": - feature_type = "圆角/倒圆候选" - edit_actions = "可尝试修改已有圆角半径;当前版本会先移除圆角面,再在恢复出的锐边上重新倒圆。" + fillet_status = str(fillet_info.get("existing_fillet_status") or "") + fillet_chain_status = str(fillet_info.get("existing_fillet_chain_status") or "") + if fillet_status == "blocked": + feature_type = "复杂圆角链候选(暂不支持修改)" + edit_actions = "只读诊断;复杂长链、变半径圆角链或角部 blend 暂不开放稳定重建。" + elif fillet_chain_status == "same-radius-chain-candidate": + feature_type = "简单等半径圆角链候选" + edit_actions = "可尝试修改整条同半径圆角链半径/弧长;当前版本会一起移除链上圆角面,再在恢复出的锐边上重新倒圆。" + else: + feature_type = "圆角/倒圆候选" + edit_actions = "可尝试修改已有圆角半径/弧长;当前版本会先移除圆角面,再在恢复出的锐边上重新倒圆。" elif guess == "boss/outer-round candidate": feature_type = "凸台/外圆候选" edit_actions = "调整圆柱凸台直径;调整圆柱凸台高度;修改圆柱凸台轴心坐标。" else: feature_type = "未明确圆柱特征" edit_actions = "可尝试调整圆柱孔径,但风险较高。" + if str(slot_info.get("slot_status") or "") == "blocked": + feature_type = "复杂槽/多槽组候选(暂不支持修改)" + edit_actions = "只读诊断;当前不开放槽宽、槽深、弧长、弧角或槽轴心修改。" highlight_face_ids = tuple( sorted( @@ -2011,8 +2583,138 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "resize_note": "当前对象已识别为圆角/倒圆;请使用已有圆角半径入口,不按孔径重切。", } ) + if str(slot_info.get("slot_status") or "") == "blocked": + blocker = str(slot_info.get("slot_blockers") or "当前槽/半孔属于复杂槽或多槽组,暂不开放稳定修改。") + result.update( + { + "resize_status": "blocked", + "resize_risk": "blocked", + "resize_blockers": blocker, + "resize_warnings": "", + "resize_note": blocker, + "depth_status": "blocked", + "depth_risk": "blocked", + "depth_blockers": blocker, + "suppress_status": "blocked", + "suppress_risk": "blocked", + "suppress_blockers": blocker, + "recognition_confidence": "low", + "recognition_risk": "blocked", + "recognition_blockers": blocker, + } + ) return result + def _partial_slot_pair_ambiguity_info( + self, + face_id: int, + *, + radius: float, + axis_direction: tuple[float, float, float] | None, + axis_mid_point: tuple[float, float, float] | None, + height: float, + boundary_face_ids: Iterable[int], + ) -> dict[str, object]: + if radius <= 1e-9 or axis_direction is None or axis_mid_point is None or height <= 1e-9: + return {} + part_id = self.face_part_ids[face_id] + solid_id = self.face_solid_ids[face_id] + solid_shape = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else self.shape + diagonal = max(_shape_diagonal(solid_shape), radius, height, 1.0) + radius_tolerance = max(radius * 0.08, diagonal * 1e-5, 1e-4) + height_tolerance = max(height * 0.25, diagonal * 1e-5, 1e-4) + min_center_distance = max(radius * 1.2, diagonal * 1e-5, 1e-4) + source_boundary = set(int(item) for item in boundary_face_ids) + compatible_face_ids: list[int] = [] + shared_boundary_face_ids: list[int] = [] + unshared_boundary_face_ids: list[int] = [] + + for other_face_id, other_face in enumerate(self.faces): + if other_face_id == face_id: + continue + if self.face_part_ids[other_face_id] != part_id: + continue + if solid_id >= 0 and self.face_solid_ids[other_face_id] != solid_id: + continue + try: + other_surf = BRepAdaptor_Surface(other_face) + except Exception: + continue + if other_surf.GetType() != GeomAbs_Cylinder: + continue + other_cyl = other_surf.Cylinder() + other_radius = float(other_cyl.Radius()) + if abs(other_radius - radius) > radius_tolerance: + continue + other_axis = _tuple_normalized(_dir_tuple(other_cyl.Axis().Direction())) + if other_axis is None or abs(_tuple_dot(axis_direction, other_axis)) < 1.0 - 1e-4: + continue + other_span = abs(float(other_surf.LastUParameter()) - float(other_surf.FirstUParameter())) + if other_span <= 1e-6 or other_span >= math.tau * 0.92: + continue + try: + other_side_face_ids = self.connected_same_domain_face_ids(other_face_id) or [other_face_id] + other_range = self._cylindrical_axis_range(other_face_id, other_surf, other_side_face_ids) + except Exception: + other_side_face_ids = [other_face_id] + other_range = { + "v_min": float(other_surf.FirstVParameter()), + "v_max": float(other_surf.LastVParameter()), + } + other_height = abs(float(other_range["v_max"]) - float(other_range["v_min"])) + if abs(other_height - height) > height_tolerance: + continue + other_mid_parameter = (float(other_range["v_min"]) + float(other_range["v_max"])) * 0.5 + other_mid = _point_tuple( + _point_on_axis(other_cyl.Axis().Location(), other_cyl.Axis().Direction(), other_mid_parameter) + ) + raw_offset = _tuple_sub(other_mid, axis_mid_point) + axis_offset = _tuple_scale(axis_direction, _tuple_dot(raw_offset, axis_direction)) + section_offset = _tuple_sub(raw_offset, axis_offset) + if _vector_length(section_offset) <= min_center_distance: + continue + try: + other_boundary_edges = self._region_boundary_edge_ids(other_side_face_ids) + other_adjacent = sorted( + set(self._adjacent_face_ids_for_edges(other_boundary_edges, other_face_id)) + - set(other_side_face_ids) + ) + other_domain_info = { + "v_range": (other_range["v_min"], other_range["v_max"]), + "height_estimate": other_height, + "angular_span": other_span, + } + other_end_faces = self._cylindrical_end_face_groups(other_face_id, other_adjacent, other_domain_info) + other_boundary = set(other_adjacent) - set(other_end_faces["end_face_ids"]) + except Exception: + other_boundary = set() + + compatible_face_ids.append(other_face_id) + if source_boundary & other_boundary: + shared_boundary_face_ids.append(other_face_id) + else: + unshared_boundary_face_ids.append(other_face_id) + + blockers = "" + if len(compatible_face_ids) >= 2 and not shared_boundary_face_ids: + blockers = ( + "检测到多个同半径、同轴向且深度相近的槽端,但没有共享侧壁能确定稳定配对;" + "这更像交叉槽、多槽组或复杂草图槽。当前一级阶段暂不开放槽宽、槽深、弧长、弧角或槽轴心修改," + "避免把局部圆柱面误当成独立槽特征。" + ) + elif len(shared_boundary_face_ids) > 1: + blockers = "检测到多个可能共享侧壁的槽端,槽端配对不唯一;当前一级阶段暂不处理多槽组联动。" + + return { + "slot_pair_candidate_face_ids": tuple(sorted(compatible_face_ids)), + "slot_pair_candidate_count": len(compatible_face_ids), + "slot_pair_shared_boundary_face_ids": tuple(sorted(shared_boundary_face_ids)), + "slot_pair_shared_boundary_count": len(shared_boundary_face_ids), + "slot_pair_unshared_boundary_face_ids": tuple(sorted(unshared_boundary_face_ids)), + "slot_pair_ambiguity_status": "blocked" if blockers else "candidate", + "slot_pair_ambiguity_blockers": blockers, + } + def _cylindrical_slot_info( self, face_id: int, @@ -2028,11 +2730,38 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd radius = max(float(info.get("radius", 0.0)), 0.0) span = min(max(angular_span, 0.0), math.tau) boundary_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids)) + axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis"))) + axis_point = _tuple_or_none(info.get("axis_point")) + v_range = info.get("v_range") + height = _float_or_none(info.get("height_estimate")) or 0.0 + axis_mid_point = None + if axis_point is not None and axis_direction is not None and isinstance(v_range, (tuple, list)) and len(v_range) >= 2: + v_min = _float_or_none(v_range[0]) + v_max = _float_or_none(v_range[1]) + if v_min is not None and v_max is not None: + v_mid = (v_min + v_max) * 0.5 + axis_mid_point = ( + axis_point[0] + axis_direction[0] * v_mid, + axis_point[1] + axis_direction[1] * v_mid, + axis_point[2] + axis_direction[2] * v_mid, + ) + ambiguity_info = self._partial_slot_pair_ambiguity_info( + face_id, + radius=radius, + axis_direction=axis_direction, + axis_mid_point=axis_mid_point, + height=height, + boundary_face_ids=boundary_face_ids, + ) + slot_blockers = str(ambiguity_info.get("slot_pair_ambiguity_blockers") or "") + slot_status = "blocked" if slot_blockers else "candidate" chord_width = 2.0 * radius * math.sin(span / 2.0) if radius > 0 else 0.0 sagitta_depth = radius * (1.0 - math.cos(min(span, math.pi) / 2.0)) if radius > 0 else 0.0 return { "slot_kind": "partial-cylindrical-groove", - "slot_status": "candidate", + "slot_status": slot_status, + "slot_risk": "blocked" if slot_status == "blocked" else "medium", + "slot_blockers": slot_blockers, "slot_angular_span": angular_span, "slot_open_angle": max(math.tau - span, 0.0), "slot_chord_width_estimate": chord_width, @@ -2041,9 +2770,11 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "feature_slot_face_ids": (face_id,), "feature_slot_boundary_face_ids": tuple(boundary_face_ids), "slot_note": ( - "这是由局部圆柱面推断出的槽/半孔候选;宽度和深度是几何估算," - "不是 CAD 历史里的参数。" + slot_blockers + if slot_blockers + else "这是由局部圆柱面推断出的槽/半孔候选;宽度和深度是几何估算,不是 CAD 历史里的参数。" ), + **ambiguity_info, } def _cylindrical_existing_fillet_info( @@ -2058,36 +2789,111 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd radius = max(float(info.get("radius", 0.0)), 0.0) angular_span = min(max(float(info.get("angular_span", 0.0)), 0.0), math.tau) - adjacent_round_face_ids: list[int] = [] + same_radius_round_face_ids: set[int] = set() + mixed_radius_round_face_ids: set[int] = set() + direct_round_face_ids: set[int] = set() + adjacent_round_radius_rows: dict[int, float] = {} radius_tolerance = max(radius * 0.12, _shape_diagonal(self.faces[face_id]) * 1e-5, 1e-4) - for adjacent_id in adjacent_face_ids: - if adjacent_id == face_id: - continue + chain_scan_truncated = False + visited = {face_id} + queue = [face_id] + max_chain_scan_faces = 24 + while queue: + current_id = queue.pop(0) try: - adjacent_info = self.face_info(adjacent_id) + current_adjacent_ids = self._adjacent_face_ids_for_edges( + self._face_boundary_edge_ids(current_id), + current_id, + ) except Exception: continue - adjacent_radius = _float_or_none(adjacent_info.get("radius")) - if ( - adjacent_info.get("surface") == "cylinder" - and adjacent_info.get("feature_guess") == "round/fillet candidate" - and adjacent_radius is not None - and abs(adjacent_radius - radius) <= radius_tolerance - ): - adjacent_round_face_ids.append(adjacent_id) - adjacent_round_face_ids = sorted(set(adjacent_round_face_ids)) - support_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids) - set(adjacent_round_face_ids)) + for adjacent_id in current_adjacent_ids: + if adjacent_id == face_id: + continue + try: + adjacent_info = self.face_info(adjacent_id) + except Exception: + continue + adjacent_radius = _float_or_none(adjacent_info.get("radius")) + if ( + adjacent_info.get("surface") != "cylinder" + or adjacent_info.get("feature_guess") != "round/fillet candidate" + or adjacent_radius is None + ): + continue + adjacent_round_radius_rows[int(adjacent_id)] = float(adjacent_radius) + if current_id == face_id: + direct_round_face_ids.add(int(adjacent_id)) + if abs(adjacent_radius - radius) <= radius_tolerance: + same_radius_round_face_ids.add(int(adjacent_id)) + if adjacent_id not in visited: + if len(visited) >= max_chain_scan_faces: + chain_scan_truncated = True + continue + visited.add(int(adjacent_id)) + queue.append(int(adjacent_id)) + else: + mixed_radius_round_face_ids.add(int(adjacent_id)) + adjacent_same_radius_round_face_ids = sorted(same_radius_round_face_ids) + adjacent_mixed_radius_round_face_ids = sorted(mixed_radius_round_face_ids) + adjacent_round_face_ids = sorted(set(adjacent_same_radius_round_face_ids) | set(adjacent_mixed_radius_round_face_ids)) + adjacent_round_radius_row_values = tuple(sorted(adjacent_round_radius_rows.items())) + support_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids) - direct_round_face_ids) chain_face_ids = tuple(sorted({face_id, *adjacent_round_face_ids})) - chain_blocker = ( - "当前圆角与其它圆角面直接相连,属于复杂圆角链或角部 blend;" - "当前版本暂未实现稳定圆角链重建。" - if adjacent_round_face_ids - else "" - ) + if adjacent_mixed_radius_round_face_ids: + chain_status = "variable-radius-chain-candidate" + chain_blocker = ( + "当前圆角与不同半径的圆角面直接相连,属于变半径圆角链或角部 blend;" + "当前版本暂未实现稳定变半径圆角链重建。" + ) + elif chain_scan_truncated or len(chain_face_ids) > 4: + chain_status = "complex-same-radius-chain-candidate" + chain_blocker = ( + f"当前圆角链包含至少 {len(chain_face_ids)} 个同半径圆角面;" + "当前版本只开放 2 到 4 个面的简单等半径圆角链,复杂长链暂未实现稳定重建。" + ) + elif adjacent_same_radius_round_face_ids: + chain_status = "same-radius-chain-candidate" + chain_blocker = "" + else: + chain_status = "single-face" + chain_blocker = "" + existing_fillet_risk = "blocked" if chain_blocker else "high" if adjacent_same_radius_round_face_ids else "medium" + if adjacent_mixed_radius_round_face_ids: + chain_note = ( + "Detected directly connected round/fillet Faces with different radii; " + "this is treated as a variable-radius fillet chain or corner blend, not a single isolated fillet." + ) + elif chain_blocker: + chain_note = ( + "Detected a same-radius round/fillet chain beyond the simple local edit limit; " + "this is treated as a complex fillet chain until a dedicated chain rebuild is implemented." + ) + elif adjacent_same_radius_round_face_ids: + chain_note = ( + "Detected connected round/fillet Faces with a matching radius; " + "this can be edited as a simple same-radius fillet chain when every chain Face can be defeatured and refilleted." + ) + else: + chain_note = "No directly connected round/fillet Face was detected." + if chain_blocker: + existing_fillet_note = chain_blocker + elif adjacent_same_radius_round_face_ids: + existing_fillet_note = ( + "这是由同半径相邻圆柱倒圆面推断出的简单圆角链候选;" + "当前版本会尝试一起移除链上圆角面,再在恢复出的锐边上按目标半径重新倒圆;" + "角部 blend 或复杂链仍可能失败并回滚。" + ) + else: + existing_fillet_note = ( + "这是由局部小半径圆柱面推断出的已有圆角/倒圆候选;" + "当前版本可尝试使用 defeature + 重新倒圆修改半径;" + "复杂 blend 或支撑面不明确时可能失败并回滚。" + ) return { "existing_fillet_kind": "cylindrical-round-face", "existing_fillet_status": "blocked" if chain_blocker else "candidate", - "existing_fillet_risk": "blocked" if chain_blocker else "medium", + "existing_fillet_risk": existing_fillet_risk, "existing_fillet_blockers": chain_blocker, "existing_fillet_radius_estimate": radius, "existing_fillet_angular_span": angular_span, @@ -2096,23 +2902,14 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "feature_existing_fillet_support_face_ids": tuple(support_face_ids), "feature_existing_fillet_chain_face_ids": chain_face_ids, "feature_existing_fillet_chain_adjacent_face_ids": tuple(adjacent_round_face_ids), + "feature_existing_fillet_direct_round_face_ids": tuple(sorted(direct_round_face_ids)), + "feature_existing_fillet_same_radius_chain_face_ids": tuple(adjacent_same_radius_round_face_ids), + "feature_existing_fillet_mixed_radius_chain_face_ids": tuple(adjacent_mixed_radius_round_face_ids), + "feature_existing_fillet_adjacent_radius_rows": adjacent_round_radius_row_values, "existing_fillet_chain_face_count": len(chain_face_ids), - "existing_fillet_chain_status": "chain-candidate" if adjacent_round_face_ids else "single-face", - "existing_fillet_chain_note": ( - "Detected directly connected round/fillet Faces with a matching radius; " - "this is treated as a fillet chain or corner blend, not a single isolated fillet." - if adjacent_round_face_ids - else "No directly connected same-radius round/fillet Face was detected." - ), - "existing_fillet_note": ( - chain_blocker - if chain_blocker - else ( - "这是由局部小半径圆柱面推断出的已有圆角/倒圆候选;" - "当前版本可尝试使用 defeature + 重新倒圆修改半径;" - "复杂 blend 或支撑面不明确时可能失败并回滚。" - ) - ), + "existing_fillet_chain_status": chain_status, + "existing_fillet_chain_note": chain_note, + "existing_fillet_note": existing_fillet_note, } def _cylindrical_end_face_groups( @@ -2318,6 +3115,175 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self._face_edge_ids_cache[face_id] = list(edge_ids) return edge_ids + def _face_plane_normal_tuple(self, face_id: int) -> tuple[float, float, float] | None: + if face_id < 0 or face_id >= len(self.faces): + return None + try: + surface = BRepAdaptor_Surface(self.faces[face_id]) + except Exception: + return None + if surface.GetType() != GeomAbs_Plane: + return None + return _tuple_normalized(_dir_tuple(surface.Plane().Axis().Direction())) + + def _edge_axis_tuple(self, edge_id: int) -> tuple[float, float, float] | None: + if edge_id < 0 or edge_id >= len(self.edges): + return None + try: + curve = BRepAdaptor_Curve(self.edges[edge_id]) + if curve.GetType() == GeomAbs_Line: + return _tuple_normalized(_dir_tuple(curve.Line().Direction())) + except Exception: + pass + points = self._edge_vertex_points(edge_id) + if len(points) < 2: + return None + return _tuple_normalized(_tuple_sub(points[-1], points[0])) + + def _planar_relation_name(self, dot: float, tolerance: float) -> str: + if dot >= 1.0 - tolerance: + return "parallel" + if dot <= tolerance: + return "perpendicular" + return "angled" + + def _first_level_planar_relation_fields( + self, + *, + subject_face_ids: Iterable[int], + adjacent_face_ids: Iterable[int], + edge_axis: tuple[float, float, float] | None = None, + relation_scope: str = "face", + ) -> dict[str, object]: + tolerance = 0.12 + rows: list[dict[str, object]] = [] + non_planar_face_ids: set[int] = set() + normals: dict[int, tuple[float, float, float]] = {} + + subject_ids = tuple(sorted({int(item) for item in subject_face_ids if 0 <= int(item) < len(self.faces)})) + adjacent_ids = tuple(sorted({int(item) for item in adjacent_face_ids if 0 <= int(item) < len(self.faces)})) + involved_ids = tuple(sorted({*subject_ids, *adjacent_ids})) + for face_id in involved_ids: + normal = self._face_plane_normal_tuple(face_id) + if normal is None: + non_planar_face_ids.add(face_id) + continue + normals[face_id] = normal + + relation_labels = { + "parallel": "平行", + "perpendicular": "垂直", + "angled": "斜交", + } + subject_pairs: set[tuple[int, int]] = set() + for subject_id in subject_ids: + subject_normal = normals.get(subject_id) + if subject_normal is None: + continue + for adjacent_id in adjacent_ids: + adjacent_normal = normals.get(adjacent_id) + if adjacent_normal is None or adjacent_id == subject_id: + continue + pair_key = tuple(sorted((subject_id, adjacent_id))) + if pair_key in subject_pairs: + continue + subject_pairs.add(pair_key) + dot = abs(_tuple_dot(subject_normal, adjacent_normal)) + relation = self._planar_relation_name(dot, tolerance) + rows.append( + { + "relation_kind": "face-face", + "source": "subject-adjacent", + "face_ids": pair_key, + "relation": relation, + "relation_label": relation_labels[relation], + "normal_dot": dot, + } + ) + + if relation_scope == "edge": + incident_ids = tuple(face_id for face_id in adjacent_ids if face_id in normals) + for left_index, left_id in enumerate(incident_ids): + left_normal = normals.get(left_id) + if left_normal is None: + continue + for right_id in incident_ids[left_index + 1 :]: + right_normal = normals.get(right_id) + if right_normal is None: + continue + dot = abs(_tuple_dot(left_normal, right_normal)) + relation = self._planar_relation_name(dot, tolerance) + rows.append( + { + "relation_kind": "face-face", + "source": "incident-face-pair", + "face_ids": (left_id, right_id), + "relation": relation, + "relation_label": relation_labels[relation], + "normal_dot": dot, + } + ) + if edge_axis is not None: + for face_id in incident_ids: + normal = normals.get(face_id) + if normal is None: + continue + dot = abs(_tuple_dot(normal, edge_axis)) + relation = "perpendicular" if dot >= 1.0 - tolerance else "parallel" if dot <= tolerance else "angled" + rows.append( + { + "relation_kind": "face-edge-axis", + "source": "incident-face-to-edge-axis", + "face_id": face_id, + "relation": relation, + "relation_label": relation_labels[relation], + "normal_edge_dot": dot, + } + ) + + parallel_count = sum(1 for row in rows if row.get("relation") == "parallel") + perpendicular_count = sum(1 for row in rows if row.get("relation") == "perpendicular") + angled_count = sum(1 for row in rows if row.get("relation") == "angled") + face_face_count = sum(1 for row in rows if row.get("relation_kind") == "face-face") + edge_axis_count = sum(1 for row in rows if row.get("relation_kind") == "face-edge-axis") + observed_modes: list[str] = [] + if parallel_count: + observed_modes.append("parallel") + if perpendicular_count: + observed_modes.append("perpendicular") + if angled_count: + observed_modes.append("angled") + + if rows: + summary = ( + f"一级平面关系:已识别 {len(rows)} 条;" + f"平行 {parallel_count},垂直 {perpendicular_count},斜交 {angled_count}。" + ) + status = "ready" + else: + summary = "一级平面关系:当前一级邻域没有足够平面可判断。" + status = "unavailable" + return { + "first_level_planar_relation_status": status, + "first_level_planar_relation_scope": relation_scope, + "first_level_planar_relation_tolerance": tolerance, + "first_level_planar_relation_rows": tuple(rows), + "first_level_planar_relation_count": len(rows), + "first_level_planar_relation_face_face_count": face_face_count, + "first_level_planar_relation_edge_axis_count": edge_axis_count, + "first_level_planar_relation_parallel_count": parallel_count, + "first_level_planar_relation_perpendicular_count": perpendicular_count, + "first_level_planar_relation_angled_count": angled_count, + "first_level_planar_relation_subject_face_ids": subject_ids, + "first_level_planar_relation_adjacent_face_ids": adjacent_ids, + "first_level_planar_relation_non_planar_face_ids": tuple(sorted(non_planar_face_ids)), + "first_level_planar_relation_observed_modes": tuple(observed_modes), + "first_level_planar_relation_summary": summary, + "first_level_planar_relation_note": ( + "这些是一级邻域内已识别到的平面方向关系;只有明确支持的建模意图会把它们作为执行约束。" + ), + } + def face_first_level_topology(self, face_id: int) -> dict[str, object]: """Return the explicit first-level B-Rep neighborhood for a Face. @@ -2381,6 +3347,11 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd } for adjacent_id, edge_ids in sorted(shared_edges_by_face.items()) ) + planar_relation_fields = self._first_level_planar_relation_fields( + subject_face_ids=same_domain_face_ids, + adjacent_face_ids=adjacent_face_ids, + relation_scope="face", + ) topology = { "topology_relation_model": "STEP/B-Rep shared-edge first-level", "topology_relation_depth": 1, @@ -2408,6 +3379,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "first_level_shared_edges_by_face": shared_edge_refs, "first_level_face_ids": first_level_face_ids, "first_level_face_count": len(first_level_face_ids), + **planar_relation_fields, "first_level_topology_note": ( f"已识别当前 Face 区域 {len(same_domain_face_ids)} 个 Face、" f"边界 Edge {len(region_boundary_edge_ids)} 条、" @@ -2655,11 +3627,25 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd topology.get("cylindrical_feature_shared_edges_by_face") or () ) ignored_depths = tuple(topology.get("topology_ignored_relation_depths") or ("second-level", "third-level")) + planar_relation_fields = { + key: value + for key, value in topology.items() + if str(key).startswith("first_level_planar_relation_") + } + if not planar_relation_fields: + planar_relation_fields = self._first_level_planar_relation_fields( + subject_face_ids=subject_face_ids, + adjacent_face_ids=adjacent_face_ids, + relation_scope=resolved_scope, + ) + planar_summary = str(planar_relation_fields.get("first_level_planar_relation_summary") or "").strip() summary = ( f"{subject_role}: Face {len(subject_face_ids)}, boundary Edge {len(boundary_edge_ids)}, " f"boundary Vertex {len(boundary_vertex_points)}, direct adjacent Face {len(adjacent_face_ids)}; " "deeper relations are recorded as future propagation targets, not edited automatically." ) + if planar_summary and planar_relation_fields.get("first_level_planar_relation_status") == "ready": + summary = f"{summary} {planar_summary}" facts = { "first_level_fact_model": "STEP/B-Rep first-level fact graph", "first_level_fact_source_model": source_model, @@ -2684,6 +3670,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "first_level_fact_ignored_relation_depths": ignored_depths, "first_level_fact_ignored_relation_note": topology.get("topology_ignored_relation_note", ""), "first_level_fact_summary": summary, + **planar_relation_fields, } for item in facts["first_level_fact_subject_face_ids"]: self._face_first_level_fact_cache[(int(item), resolved_scope)] = dict(facts) @@ -3378,6 +4365,12 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd ) first_level_edge_ids = tuple(sorted({edge_id, *adjacent_edge_ids})) + planar_relation_fields = self._first_level_planar_relation_fields( + subject_face_ids=(), + adjacent_face_ids=adjacent_face_ids, + edge_axis=self._edge_axis_tuple(edge_id), + relation_scope="edge", + ) topology = { "topology_relation_model": "STEP/B-Rep edge first-level", "topology_relation_depth": 1, @@ -3407,6 +4400,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "first_level_shared_edges_by_face": tuple(shared_edges_by_face), "first_level_adjacent_face_boundary_edge_ids": tuple(sorted(adjacent_face_boundary_edges)), "first_level_adjacent_face_boundary_edge_count": len(adjacent_face_boundary_edges), + **planar_relation_fields, "first_level_topology_note": ( f"Selected Edge 1, endpoint Vertex {len(endpoint_points)}, " f"shared-endpoint Edge {len(adjacent_edge_ids)}, direct adjacent Face {len(adjacent_face_ids)}. " @@ -3436,6 +4430,19 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd {"role": "shared-endpoint-edges", "edge_ids": adjacent_edge_ids, "count": len(adjacent_edge_ids)}, {"role": "direct-incident-faces", "face_ids": adjacent_face_ids, "count": len(adjacent_face_ids)}, ) + planar_relation_fields = { + key: value + for key, value in topology.items() + if str(key).startswith("first_level_planar_relation_") + } + planar_summary = str(planar_relation_fields.get("first_level_planar_relation_summary") or "").strip() + summary = ( + f"selected Edge 1, endpoint Vertex {len(endpoint_points)}, " + f"shared-endpoint Edge {len(adjacent_edge_ids)}, direct adjacent Face {len(adjacent_face_ids)}; " + "deeper relations are recorded as future propagation targets, not edited automatically." + ) + if planar_summary and planar_relation_fields.get("first_level_planar_relation_status") == "ready": + summary = f"{summary} {planar_summary}" facts = { "first_level_fact_model": "STEP/B-Rep first-level fact graph", "first_level_fact_source_model": "edge", @@ -3463,11 +4470,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd "first_level_fact_role_groups": role_groups, "first_level_fact_ignored_relation_depths": ignored_depths, "first_level_fact_ignored_relation_note": topology.get("topology_ignored_relation_note", ""), - "first_level_fact_summary": ( - f"selected Edge 1, endpoint Vertex {len(endpoint_points)}, " - f"shared-endpoint Edge {len(adjacent_edge_ids)}, direct adjacent Face {len(adjacent_face_ids)}; " - "deeper relations are recorded as future propagation targets, not edited automatically." - ), + "first_level_fact_summary": summary, + **planar_relation_fields, } self._edge_first_level_fact_cache[edge_id] = dict(facts) return dict(facts) diff --git a/step_editor/operations.py b/step_editor/operations.py index 27d048d..e2e2da2 100644 --- a/step_editor/operations.py +++ b/step_editor/operations.py @@ -1,5 +1,6 @@ from __future__ import annotations +import itertools import math from pathlib import Path from typing import Callable, Iterable @@ -257,6 +258,19 @@ class OperationMixin: blockers.append(f"{operation_label}缺少直接相邻 Face 角色,不能确定一级联动范围。") return blockers + def _multistep_prismatic_plan_blocker(self, face_id: int, operation_label: str) -> str: + try: + feature = self.feature_info(face_id) + except Exception: + return "" + if feature.get("multistep_prismatic_status") != "blocked": + return "" + reason = str( + feature.get("multistep_prismatic_blockers") + or "当前 Face 属于复杂多台阶凸台/基座过渡区域,整组联动重建暂未实现。" + ).strip() + return f"{operation_label}暂不支持这个多台阶过渡面。{reason}" + def repair_model(self) -> str: before_stats = self.stats() repaired_parts = 0 @@ -678,7 +692,8 @@ class OperationMixin: anchor_mode = self._edge_length_anchor_mode(anchor_mode) strategy_mode = self._edge_length_strategy_mode(strategy_mode) force_local = strategy_mode == "local-edge-only-deform" - force_end_face = strategy_mode == "move-edge-end-plane-by-push-pull" + force_planar_relation = strategy_mode == "keep-first-level-planar-relations" + force_end_face = strategy_mode in {"move-edge-end-plane-by-push-pull", "keep-first-level-planar-relations"} force_cylinder = strategy_mode == "resize-adjacent-cylinder-from-circular-edge-length" base: dict[str, object] = { "edge_id": edge_id, @@ -724,11 +739,11 @@ class OperationMixin: if not blockers and force_local and curve != "line": blockers.append("“只改当前Edge”策略当前只支持直线Edge。") if not blockers and force_end_face and curve != "line": - blockers.append("“移动端面/整体尺寸”策略当前只支持直线Edge。") + blockers.append(f"“{self._edge_length_strategy_label(strategy_mode)}”策略当前只支持直线Edge。") if not blockers and force_cylinder and curve != "circle": blockers.append("“相邻圆柱直径”策略当前只支持圆形/圆弧Edge。") if not blockers and force_end_face and anchor_mode == "center": - blockers.append("“移动端面/整体尺寸”需要固定起点、固定终点或自动基准,不能使用固定中心。") + blockers.append(f"“{self._edge_length_strategy_label(strategy_mode)}”需要固定起点、固定终点或自动基准,不能使用固定中心。") if not blockers and curve == "line" and strategy_mode in {"auto", "local-edge-only-deform"}: local_candidate, local_skip_note = self._local_edge_length_deform_candidate( @@ -749,7 +764,7 @@ class OperationMixin: else: warnings.append(local_skip_note) - if not blockers and "resize_strategy" not in base and curve == "line" and anchor_mode != "center" and strategy_mode in {"auto", "move-edge-end-plane-by-push-pull"}: + if not blockers and "resize_strategy" not in base and curve == "line" and anchor_mode != "center" and strategy_mode in {"auto", "move-edge-end-plane-by-push-pull", "keep-first-level-planar-relations"}: candidate = self._straight_edge_length_end_face_candidate(info, delta_length, anchor_mode=anchor_mode) if candidate is not None: constraint_fields = self._edge_end_face_planar_constraint_fields(info, candidate) @@ -775,6 +790,10 @@ class OperationMixin: base.update( { "resize_strategy": "move-edge-end-plane-by-push-pull", + "edge_length_planar_relation_constraint_requested": force_planar_relation, + "edge_length_planar_relation_constraint_label": ( + "保持一级平面关系" if force_planar_relation else "" + ), "push_pull_status": push_plan.get("status"), "push_pull_risk": push_plan.get("risk"), "push_pull_message": push_plan.get("message"), @@ -893,7 +912,10 @@ class OperationMixin: if strategy == "local-edge-only-deform": message = "可以通过局部边形变只调整这条直线Edge,并重建周边平面。" elif strategy == "move-edge-end-plane-by-push-pull": - message = "可以通过端面拉伸/切除调整这条直线Edge长度。" + if strategy_mode == "keep-first-level-planar-relations": + message = "可以通过端面拉伸/切除调整这条直线Edge长度,并保持已验证的一级平面平行/垂直关系。" + else: + message = "可以通过端面拉伸/切除调整这条直线Edge长度。" elif strategy == "resize-adjacent-cylinder-from-circular-edge-length": message = "可以通过相邻圆柱直径编辑调整这条圆形/圆弧Edge长度。" elif strategy == "scale-owning-shape-from-edge": @@ -905,7 +927,11 @@ class OperationMixin: { "edge_length_constraint_summary": self._edge_length_constraint_summary(anchor_mode), "edge_length_impact_summary": self._edge_length_impact_summary(base), - "edit_strategy_label": self._edge_length_strategy_label(str(base.get("resize_strategy") or strategy_mode)), + "edit_strategy_label": ( + self._edge_length_strategy_label(strategy_mode) + if strategy_mode == "keep-first-level-planar-relations" + else self._edge_length_strategy_label(str(base.get("resize_strategy") or strategy_mode)) + ), "edit_semantics": self._edge_length_impact_summary(base), "status": status, "risk": risk, @@ -1034,29 +1060,34 @@ class OperationMixin: "delta_length": 0.0, "length_change_ratio": 0.0, "edge_endpoint_role": role, - "edge_endpoint_label": "start point" if role == "start" else "end point", + "edge_endpoint_label": "起点" if role == "start" else "终点", "start_point": start, "end_point": end, "target_endpoint_point": target, "length_center": info.get("length_center"), "resize_strategy": "local-edge-endpoint-deform", + "edit_strategy_label": "Edge端点局部移动", + "edit_semantics": ( + "只移动当前直线 Edge 的一个端点,并重建一级相邻平面;" + "这不是端面整体推拉,不保证其它同向边一起移动或保持盒体尺寸关系。" + ), } base.update(self._edge_first_level_plan_fields(edge_id)) warnings = [ - "Edge endpoint coordinate edit rebuilds the current STEP/B-Rep result geometry; it is not recovered CAD history." + "Edge端点坐标修改基于当前 STEP/B-Rep 结果几何重建,不是恢复原 CAD 历史参数。" ] blockers: list[str] = [] risk = "low" status = "ready" if curve != "line": - blockers.append("Only straight line Edge endpoints can be moved directly.") + blockers.append("当前阶段只有直线 Edge 的端点可以直接移动。") if current_length <= 1e-9: - blockers.append("Current Edge length is invalid.") + blockers.append("当前 Edge 长度无效。") if start is None or end is None: - blockers.append("Current Edge does not have stable start/end coordinates.") + blockers.append("当前 Edge 缺少稳定起点/终点坐标。") if target is None: - blockers.append("Target endpoint coordinate must be a valid X/Y/Z tuple.") + blockers.append("目标端点必须是有效的 X/Y/Z 坐标。") if not blockers and start is not None and end is not None and target is not None: current_endpoint = start if role == "start" else end @@ -1082,15 +1113,15 @@ class OperationMixin: } ) if move_distance <= max(current_length * 1e-7, 1e-7): - blockers.append("Target endpoint coordinate is almost identical to the current coordinate.") + blockers.append("目标端点和当前端点几乎相同,不需要移动。") if target_length <= 1e-9: - blockers.append("Moving this endpoint would collapse the Edge length to zero.") + blockers.append("移动该端点会让 Edge 长度坍缩为 0。") if move_ratio > 0.5 or length_change_ratio > 0.5: risk = _max_risk(risk, "high") - warnings.append("Endpoint movement or resulting length change is larger than 50% of the current Edge length.") + warnings.append("端点移动距离或结果长度变化超过当前 Edge 长度的 50%,局部重建风险较高。") elif move_ratio > 0.25 or length_change_ratio > 0.25: risk = _max_risk(risk, "medium") - warnings.append("Endpoint movement or resulting length change is larger than 25% of the current Edge length.") + warnings.append("端点移动距离或结果长度变化超过当前 Edge 长度的 25%,请确认相邻面变化。") if not blockers and start is not None and end is not None and target is not None: anchor_mode = "keep-end" if role == "start" else "keep-start" @@ -1100,7 +1131,7 @@ class OperationMixin: anchor_mode=anchor_mode, ) if local_candidate is None: - blockers.append(local_skip_note or "Local endpoint deformation is not available for this Edge.") + blockers.append(local_skip_note or "当前 Edge 不满足局部端点移动的一级拓扑条件。") else: start_move = _tuple_sub(target, start) if role == "start" else (0.0, 0.0, 0.0) end_move = _tuple_sub(target, end) if role == "end" else (0.0, 0.0, 0.0) @@ -1117,7 +1148,7 @@ class OperationMixin: "local_edge_deform_end_move": end_move, "local_edge_deform_moved_endpoint_count": 1, "local_edge_deform_note": ( - "Move one endpoint of the selected straight Edge and rebuild the surrounding planar solid." + "移动所选直线 Edge 的一个端点,并重建周边平面多面体。" ), "expected_start_point": expected_start, "expected_end_point": expected_end, @@ -1125,7 +1156,7 @@ class OperationMixin: ) risk = _max_risk(risk, str(local_candidate.get("local_edge_deform_risk", "medium"))) warnings.append( - "The selected Edge endpoint will be moved and the surrounding planar faces will be rebuilt; four-point non-planar faces are rebuilt as continuous surfaces when possible." + "将移动所选 Edge 的一个端点并重建周边平面;四点非共面面会优先重建为连续曲面。" ) if blockers: @@ -1136,7 +1167,7 @@ class OperationMixin: status = "caution" message = " ".join(warnings) else: - message = "The straight Edge endpoint can be moved by local planar-solid deformation." + message = "可以通过局部平面实体变形移动该直线 Edge 端点。" base.update( { @@ -1178,23 +1209,28 @@ class OperationMixin: "length_center": current_center, "target_edge_center": target, "resize_strategy": "local-edge-center-deform", + "edit_strategy_label": "Edge整体局部移动", + "edit_semantics": ( + "同时移动当前直线 Edge 的两个端点,保持这条 Edge 长度不变,并重建一级相邻平面;" + "这不是移动整个端面或所属零件。" + ), } base.update(self._edge_first_level_plan_fields(edge_id)) warnings = [ - "Edge center coordinate edit rebuilds the current STEP/B-Rep result geometry; it is not recovered CAD history." + "Edge中心坐标修改基于当前 STEP/B-Rep 结果几何重建,不是恢复原 CAD 历史参数。" ] blockers: list[str] = [] risk = "low" status = "ready" if curve != "line": - blockers.append("Only straight line Edge centers can be moved directly.") + blockers.append("当前阶段只有直线 Edge 的中心可以直接移动。") if current_length <= 1e-9: - blockers.append("Current Edge length is invalid.") + blockers.append("当前 Edge 长度无效。") if start is None or end is None or current_center is None: - blockers.append("Current Edge does not have stable start/end/center coordinates.") + blockers.append("当前 Edge 缺少稳定起点/终点/中心坐标。") if target is None: - blockers.append("Target Edge center coordinate must be a valid X/Y/Z tuple.") + blockers.append("目标 Edge 中心必须是有效的 X/Y/Z 坐标。") if not blockers and start is not None and end is not None and current_center is not None and target is not None: move = _tuple_sub(target, current_center) @@ -1212,13 +1248,13 @@ class OperationMixin: } ) if move_distance <= max(current_length * 1e-7, 1e-7): - blockers.append("Target Edge center coordinate is almost identical to the current coordinate.") + blockers.append("目标 Edge 中心和当前中心几乎相同,不需要移动。") if move_ratio > 0.5: risk = _max_risk(risk, "high") - warnings.append("Edge center movement is larger than 50% of the current Edge length.") + warnings.append("Edge 中心移动距离超过当前 Edge 长度的 50%,局部重建风险较高。") elif move_ratio > 0.25: risk = _max_risk(risk, "medium") - warnings.append("Edge center movement is larger than 25% of the current Edge length.") + warnings.append("Edge 中心移动距离超过当前 Edge 长度的 25%,请确认相邻面变化。") if not blockers and start is not None and end is not None and current_center is not None and target is not None: local_candidate, local_skip_note = self._local_edge_length_deform_candidate( @@ -1227,7 +1263,7 @@ class OperationMixin: anchor_mode="center", ) if local_candidate is None: - blockers.append(local_skip_note or "Local Edge center deformation is not available for this Edge.") + blockers.append(local_skip_note or "当前 Edge 不满足局部中心移动的一级拓扑条件。") else: move = _tuple_sub(target, current_center) expected_start = _tuple_add(start, move) @@ -1241,7 +1277,7 @@ class OperationMixin: "local_edge_deform_end_move": move, "local_edge_deform_moved_endpoint_count": 2, "local_edge_deform_note": ( - "Move both endpoints of the selected straight Edge and rebuild the surrounding planar solid." + "同时移动所选直线 Edge 的两个端点,并重建周边平面多面体。" ), "expected_start_point": expected_start, "expected_end_point": expected_end, @@ -1249,7 +1285,7 @@ class OperationMixin: ) risk = _max_risk(risk, str(local_candidate.get("local_edge_deform_risk", "medium"))) warnings.append( - "Both endpoints of the selected Edge will be moved and the surrounding planar faces will be rebuilt; four-point non-planar faces are rebuilt as continuous surfaces when possible." + "将同时移动所选 Edge 的两个端点并重建周边平面;四点非共面面会优先重建为连续曲面。" ) if blockers: @@ -1260,7 +1296,7 @@ class OperationMixin: status = "caution" message = " ".join(warnings) else: - message = "The straight Edge center can be moved by local planar-solid deformation." + message = "可以通过局部平面实体变形移动该直线 Edge 中心。" base.update( { @@ -1331,6 +1367,16 @@ class OperationMixin: "端面拉伸/切除": "move-edge-end-plane-by-push-pull", "整体尺寸变化": "move-edge-end-plane-by-push-pull", "变成长方体": "move-edge-end-plane-by-push-pull", + "keep-planar": "keep-first-level-planar-relations", + "keep-planar-relations": "keep-first-level-planar-relations", + "keep-first-level-planar-relations": "keep-first-level-planar-relations", + "keep-first-level-plane-relations": "keep-first-level-planar-relations", + "保持关系": "keep-first-level-planar-relations", + "保持平面关系": "keep-first-level-planar-relations", + "保持一级关系": "keep-first-level-planar-relations", + "保持一级平面关系": "keep-first-level-planar-relations", + "保持平行垂直": "keep-first-level-planar-relations", + "保持平行/垂直": "keep-first-level-planar-relations", "cylinder": "resize-adjacent-cylinder-from-circular-edge-length", "adjacent-cylinder": "resize-adjacent-cylinder-from-circular-edge-length", "resize-adjacent-cylinder-from-circular-edge-length": "resize-adjacent-cylinder-from-circular-edge-length", @@ -1349,6 +1395,7 @@ class OperationMixin: "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_mode, "自动选择") @@ -1373,6 +1420,13 @@ class OperationMixin: if strategy == "move-edge-end-plane-by-push-pull": end_face = plan.get("end_face_id") face_text = f" Face {end_face}" if end_face not in {None, ""} else "" + if str(plan.get("edge_length_strategy_mode") or "") == "keep-first-level-planar-relations": + constraint_summary = str(plan.get("edge_length_planar_constraint_summary") or "").strip() + return ( + f"保持一级平面关系:把长度变化转换为端面{face_text}拉伸/切除;" + "只在直接相邻平面和移动端面能验证平行/垂直时放行。" + f"{' ' + constraint_summary if constraint_summary else ''}" + ) return ( f"移动端面:把长度变化转换为端面{face_text}拉伸/切除;端面和同一端面区域上的相关边会跟随," "相邻平面会尽量保持垂直,正方体这类模型会更像变成长方体。" @@ -1494,9 +1548,34 @@ class OperationMixin: } ) + parallel_count = sum(1 for item in pair_specs if item.get("relation") == "parallel") + perpendicular_count = sum(1 for item in pair_specs if item.get("relation") == "perpendicular") + angled_count = sum(1 for item in pair_specs if item.get("relation") == "angled") + observed_modes: list[str] = [] + if parallel_count: + observed_modes.append("parallel") + if perpendicular_count: + observed_modes.append("perpendicular") + if angled_count: + observed_modes.append("angled") + if blockers: + summary = ( + "端面推拉平面约束:当前不能稳定保持一级平行/垂直关系;" + f"已识别侧面 {len(side_specs)} 个,阻止原因 {len(blockers)} 项。" + ) + else: + summary = ( + "端面推拉平面约束:可保持移动端面与直接侧面垂直," + f"并复查侧面关系;侧面平行 {parallel_count},垂直 {perpendicular_count},斜交 {angled_count}。" + ) return { "edge_length_planar_constraint_status": "blocked" if blockers else "ready", "edge_length_planar_constraint_risk": "blocked" if blockers else ("medium" if warnings else "low"), + "edge_length_planar_constraint_summary": summary, + "edge_length_planar_constraint_parallel_count": parallel_count, + "edge_length_planar_constraint_perpendicular_count": perpendicular_count, + "edge_length_planar_constraint_angled_count": angled_count, + "edge_length_planar_constraint_observed_modes": tuple(observed_modes), "edge_length_planar_constraint_blockers": "; ".join(blockers), "edge_length_planar_constraint_warnings": "; ".join(warnings), "edge_length_planar_constraint_side_face_ids": tuple(int(item["face_id"]) for item in side_specs), @@ -2500,6 +2579,11 @@ class OperationMixin: support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ())) chain_face_ids = tuple(_int_values(feature.get("feature_existing_fillet_chain_face_ids"))) chain_adjacent_face_ids = tuple(_int_values(feature.get("feature_existing_fillet_chain_adjacent_face_ids"))) + mixed_radius_chain_face_ids = tuple(_int_values(feature.get("feature_existing_fillet_mixed_radius_chain_face_ids"))) + same_radius_chain_face_ids = tuple(_int_values(feature.get("feature_existing_fillet_same_radius_chain_face_ids"))) + chain_status = str(feature.get("existing_fillet_chain_status") or "") + feature_blockers = str(feature.get("existing_fillet_blockers") or "") + resize_face_ids = chain_face_ids if chain_status == "same-radius-chain-candidate" and len(chain_face_ids) > 1 else (face_id,) topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id) warnings: list[str] = [] blockers: list[str] = [] @@ -2550,10 +2634,48 @@ class OperationMixin: if feature_guess != "round/fillet candidate": blockers.append("当前圆柱面没有被识别为已有圆角/倒圆候选。") if len(chain_face_ids) > 1 or chain_adjacent_face_ids: - blockers.append( - "当前圆角与其它圆角面直接相连,属于复杂圆角链或角部 blend;当前版本暂未实现稳定圆角链重建," - "已在计划阶段阻止,避免进入高风险 defeature/refillet 计算。" - ) + if feature_blockers: + blockers.append(feature_blockers) + elif mixed_radius_chain_face_ids: + blockers.append( + "当前圆角与不同半径的圆角面直接相连,属于变半径圆角链或角部 blend;" + "当前版本暂未实现稳定变半径圆角链重建,已在计划阶段阻止," + "避免进入高风险 defeature/refillet 计算。" + ) + else: + if chain_status != "same-radius-chain-candidate": + blockers.append( + "当前圆角链不是稳定的等半径链候选;当前版本只开放简单等半径圆角链重建。" + ) + elif len(chain_face_ids) > 4: + blockers.append( + f"当前圆角链包含 {len(chain_face_ids)} 个圆角面;当前版本只开放 2 到 4 个面的简单等半径圆角链。" + ) + elif not same_radius_chain_face_ids: + blockers.append("当前圆角链没有记录稳定的同半径相邻圆角面。") + else: + risk = _max_risk(risk, "high") + warnings.append( + "当前选择的是简单等半径圆角链;将一起移除链上圆角面,再对恢复出的多条锐边重新倒圆。" + ) + radius_tolerance = max(current_radius * 0.12, _shape_diagonal(self.faces[face_id]) * 1e-5, 1e-4) + for chain_face_id in resize_face_ids: + if chain_face_id < 0 or chain_face_id >= len(self.faces): + blockers.append(f"圆角链 Face {chain_face_id} 不存在。") + continue + try: + chain_info = self.face_info(chain_face_id) + chain_feature = self.feature_info(chain_face_id) + except Exception as exc: + blockers.append(f"圆角链 Face {chain_face_id} 无法读取识别信息:{exc}") + continue + chain_radius = _float_or_none(chain_feature.get("existing_fillet_radius_estimate", chain_info.get("radius"))) + if chain_info.get("surface") != "cylinder" or chain_info.get("feature_guess") != "round/fillet candidate": + blockers.append(f"圆角链 Face {chain_face_id} 不是稳定圆柱倒圆面。") + elif chain_radius is None or abs(chain_radius - current_radius) > radius_tolerance: + blockers.append(f"圆角链 Face {chain_face_id} 半径与当前圆角不一致。") + elif str(chain_feature.get("existing_fillet_chain_status") or "") == "variable-radius-chain-candidate": + blockers.append(f"圆角链 Face {chain_face_id} 连接到变半径圆角,不能按等半径链重建。") if target_radius <= 0: blockers.append("目标圆角半径必须大于 0。") if current_radius <= 0: @@ -2599,7 +2721,10 @@ class OperationMixin: risk = "blocked" message = " ".join(blockers + warnings) else: - message = "将尝试先移除已有圆角面,再在恢复出的锐边上按目标半径重新倒圆。" + if len(tuple(resize_face_ids)) > 1: + message = "将尝试先一起移除同半径圆角链,再在恢复出的多条锐边上按目标半径重新倒圆。" + else: + message = "将尝试先移除已有圆角面,再在恢复出的锐边上按目标半径重新倒圆。" if warnings: message += " " + " ".join(warnings) @@ -2627,15 +2752,29 @@ class OperationMixin: "feature_existing_fillet_support_face_ids": support_face_ids, "feature_existing_fillet_chain_face_ids": chain_face_ids, "feature_existing_fillet_chain_adjacent_face_ids": chain_adjacent_face_ids, + "feature_existing_fillet_same_radius_chain_face_ids": same_radius_chain_face_ids, + "feature_existing_fillet_mixed_radius_chain_face_ids": mixed_radius_chain_face_ids, + "feature_existing_fillet_adjacent_radius_rows": feature.get("feature_existing_fillet_adjacent_radius_rows", ()), "existing_fillet_chain_face_count": feature.get("existing_fillet_chain_face_count", len(chain_face_ids)), - "existing_fillet_chain_status": feature.get("existing_fillet_chain_status", ""), + "existing_fillet_chain_status": chain_status, "existing_fillet_chain_note": feature.get("existing_fillet_chain_note", ""), + "feature_existing_fillet_resize_face_ids": tuple(resize_face_ids), + "existing_fillet_resize_face_count": len(tuple(resize_face_ids)), "feature_boundary_edge_ids": feature.get("feature_boundary_edge_ids"), - "resize_strategy": "defeature-existing-fillet-face-then-refillet-axis-edge", - "edit_strategy_label": "移除旧圆角并重新倒圆", - "edit_semantics": "先移除当前已有圆角面,再在恢复出的锐边上按目标半径重新倒圆;复杂 blend 可能失败并回滚。", + "resize_strategy": ( + "defeature-existing-fillet-chain-then-refillet-axis-edges" + if len(tuple(resize_face_ids)) > 1 + else "defeature-existing-fillet-face-then-refillet-axis-edge" + ), + "edit_strategy_label": "移除同半径圆角链并重新倒圆" if len(tuple(resize_face_ids)) > 1 else "移除旧圆角并重新倒圆", + "edit_semantics": ( + "先一起移除链上同半径已有圆角面,再在恢复出的多条锐边上按目标半径重新倒圆;" + "当前只开放简单小链,复杂 blend 会阻止或失败回滚。" + if len(tuple(resize_face_ids)) > 1 + else "先移除当前已有圆角面,再在恢复出的锐边上按目标半径重新倒圆;复杂 blend 可能失败并回滚。" + ), "resize_note": ( - "当前版本的已有圆角半径修改只支持由圆柱面表示的直线边圆角。" + "当前版本的已有圆角半径修改支持由圆柱面表示的直线边圆角,以及少量简单等半径圆角链。" "执行后 Face/Edge ID 会重建,请重新选择对象确认结果。" ), } @@ -2763,6 +2902,11 @@ class OperationMixin: status = "blocked" risk = "blocked" blockers.append("拉伸/切除距离为 0,不需要修改。") + multistep_blocker = self._multistep_prismatic_plan_blocker(face_id, "拉伸/切除平面") + if multistep_blocker: + status = "blocked" + risk = "blocked" + blockers.append(multistep_blocker) if direction_confidence != "high": risk = _max_risk(risk, "medium") warnings.append("拉伸/切除方向判断置信度较低,可能不是期望的内外方向。") @@ -3457,9 +3601,17 @@ class OperationMixin: overlap_ratio = float(info.get("shell_overlap_ratio_estimate", 0.0)) source_face_ids = tuple(_int_values(info.get("shell_source_face_ids")) or [face_id]) opposite_face_id = int(info.get("shell_opposite_face_id", -1)) + open_shell_limited = str(info.get("open_shell_context_status") or "").strip() == "limited" + if open_shell_limited: + warnings.append( + "当前 Face 属于开口薄壁壳体上下文;本次只调整局部壳体厚度,不是完整抽壳/开口面特征级编辑。" + ) if info.get("surface") != "plane": blockers.append("当前选中 Face 不是平面,不能调整壳体厚度。") + multistep_blocker = self._multistep_prismatic_plan_blocker(face_id, "壳体厚度调整") + if multistep_blocker: + blockers.append(multistep_blocker) if info.get("shell_region_status") != "candidate": blockers.append(str(info.get("shell_region_note", "当前平面没有识别到相对壳体平面。"))) if current_thickness <= 1e-9 or abs(signed_thickness) <= 1e-9: @@ -3560,6 +3712,11 @@ class OperationMixin: "shell_signed_thickness": signed_thickness, "shell_overlap_ratio_estimate": overlap_ratio, "shell_opposite_normal_dot": info.get("shell_opposite_normal_dot"), + "open_shell_context_status": info.get("open_shell_context_status"), + "open_shell_kind": info.get("open_shell_kind"), + "open_shell_current_face_role": info.get("open_shell_current_face_role"), + "open_shell_limited_action": info.get("open_shell_limited_action"), + "open_shell_blockers": info.get("open_shell_blockers"), "shell_desired_movement_vector": desired_movement, "shell_movement_alignment": movement_alignment, "push_pull_distance": push_pull_distance, @@ -3671,6 +3828,9 @@ class OperationMixin: blockers.append("找不到当前 Face 所属特征。") if solid is None: blockers.append("找不到当前 Face 所属 Solid。") + multistep_blocker = self._multistep_prismatic_plan_blocker(face_id, "中心(局部重建)") + if multistep_blocker: + blockers.append(multistep_blocker) move_vector = (0.0, 0.0, 0.0) move_distance = 0.0 @@ -3787,6 +3947,182 @@ class OperationMixin: ), } + def existing_chamfer_resize_plan(self, face_id: int, target_distance: float) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + info = self.face_info(face_id) + if info.get("surface") != "plane": + return { + "status": "blocked", + "risk": "blocked", + "message": "当前选中的 Face 不是平面倒角面,不能修改已有倒角距离。", + "blockers": "当前选中的 Face 不是平面。", + "warnings": "", + "face_id": face_id, + } + + feature = self.feature_info(face_id) + current_distance = _float_or_none(feature.get("existing_chamfer_distance_estimate")) + topology_fields = self._face_first_level_plan_fields(face_id) + warnings: list[str] = [] + blockers: list[str] = [] + risk = str(feature.get("existing_chamfer_risk") or "medium") + + blockers.extend( + self._face_first_level_plan_blockers( + topology_fields, + operation_label="已有倒角距离修改", + require_adjacent=True, + ) + ) + feature_status = str(feature.get("existing_chamfer_status") or "") + if feature_status != "candidate": + blockers.append( + str(feature.get("existing_chamfer_blockers") or "当前 Face 没有被识别为简单已有倒角候选。") + ) + if current_distance is None or current_distance <= 0: + blockers.append("当前倒角距离估算无效。") + if target_distance <= 0: + blockers.append("目标倒角距离必须大于 0。") + if current_distance is not None and current_distance > 0: + if abs(target_distance - current_distance) <= max(current_distance * 1e-5, 1e-6): + blockers.append("目标倒角距离与当前估算距离几乎相同,不需要修改。") + delta_ratio = abs(target_distance - current_distance) / max(current_distance, 1e-9) + else: + delta_ratio = math.inf + support_face_ids = tuple(_int_values(feature.get("feature_existing_chamfer_support_face_ids"))) + long_edge_ids = tuple(_int_values(feature.get("feature_existing_chamfer_long_edge_ids"))) + if len(support_face_ids) < 2: + blockers.append("当前倒角缺少两张稳定支撑 Face,不能恢复锐边后重新倒角。") + if not long_edge_ids: + blockers.append("当前倒角缺少稳定长边方向,不能定位恢复出的原始 Edge。") + + part_id = int(info.get("part_id", -1)) + part_stats = None + try: + part_stats = self.part_topology_stats(part_id) + except Exception: + part_stats = None + if part_stats is not None and part_stats.solids != 1: + blockers.append( + f"当前零件包含 {part_stats.solids} 个Solid;已有倒角距离修改当前版本只对单Solid零件开放。" + ) + + if not blockers: + if delta_ratio > 1.0: + risk = "high" + warnings.append("目标倒角距离变化超过当前距离的 100%,defeature + 重新倒角很可能失败。") + elif delta_ratio > 0.35: + risk = _max_risk(risk, "high") + warnings.append("目标倒角距离变化超过当前距离的 35%,修改后请重点检查支撑面。") + feature_warnings = str(feature.get("existing_chamfer_warnings") or "").strip() + if feature_warnings: + risk = _max_risk(risk, "high") + warnings.append(feature_warnings) + + if blockers: + status = "blocked" + risk = "blocked" + message = " ".join(blockers + warnings) + else: + status = "caution" + message = "将尝试先移除已有倒角斜面,再在恢复出的锐边上按目标距离重新倒角。" + if warnings: + message += " " + " ".join(warnings) + + return { + "status": status, + "risk": risk, + "message": message, + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": info.get("part_id"), + "solid_id": info.get("solid_id"), + **topology_fields, + "feature_type": feature.get("feature_type"), + "feature_guess": feature.get("feature_guess"), + "confidence": feature.get("confidence"), + "current_distance": current_distance, + "target_distance": target_distance, + "delta_distance": (target_distance - current_distance) if current_distance is not None else "", + "distance_delta_ratio": delta_ratio if math.isfinite(delta_ratio) else "", + "existing_chamfer_distance_estimate": current_distance, + "existing_chamfer_cross_edge_length_estimate": feature.get("existing_chamfer_cross_edge_length_estimate"), + "existing_chamfer_long_edge_length_estimate": feature.get("existing_chamfer_long_edge_length_estimate"), + "existing_chamfer_support_angle_degrees": feature.get("existing_chamfer_support_angle_degrees"), + "existing_chamfer_face_angle_degrees": feature.get("existing_chamfer_face_angle_degrees"), + "feature_existing_chamfer_support_face_ids": support_face_ids, + "feature_existing_chamfer_end_face_ids": feature.get("feature_existing_chamfer_end_face_ids", ()), + "feature_existing_chamfer_short_edge_ids": feature.get("feature_existing_chamfer_short_edge_ids", ()), + "feature_existing_chamfer_long_edge_ids": long_edge_ids, + "area_center": feature.get("area_center"), + "normal": feature.get("normal"), + "resize_strategy": "defeature-existing-chamfer-face-then-rechamfer-root-edge", + "edit_strategy_label": "移除旧倒角并重新倒角", + "edit_semantics": "先移除当前倒角斜面,再在恢复出的原始锐边上按目标距离重新倒角;复杂倒角链会阻止或回滚。", + "resize_note": ( + "当前版本只支持简单等距直线倒角。执行后 Face/Edge ID 会重建,请重新选择对象确认结果。" + ), + } + + def _existing_chamfer_root_edge_candidates( + self, + shape: TopoDS_Shape, + plan: dict[str, object], + ) -> list[TopoDS_Shape]: + center = _tuple_or_none(plan.get("area_center")) + long_edge_ids = tuple(_int_values(plan.get("feature_existing_chamfer_long_edge_ids"))) + if center is None or not long_edge_ids: + return [] + + direction: tuple[float, float, float] | None = None + expected_length = _float_or_none(plan.get("existing_chamfer_long_edge_length_estimate")) or 0.0 + for edge_id in long_edge_ids: + try: + edge_info = self.edge_info(edge_id) + except Exception: + continue + direction = _tuple_or_none(edge_info.get("direction")) + if direction is not None: + expected_length = _float_or_none(edge_info.get("length")) or expected_length + break + if direction is None: + return [] + + target_distance = _float_or_none(plan.get("target_distance")) or 0.0 + current_distance = _float_or_none(plan.get("current_distance")) or target_distance + diagonal = max(_shape_diagonal(shape), target_distance, current_distance, 1.0) + distance_limit = max(current_distance * 1.8, target_distance * 1.8, diagonal * 0.035, 0.2) + length_tolerance = max(expected_length * 0.2, diagonal * 1e-5, 1e-5) + center_point = gp_Pnt(*center) + candidates: list[tuple[float, TopoDS_Shape]] = [] + for edge in TopologyExplorer(shape, ignore_orientation=True).edges(): + try: + curve = BRepAdaptor_Curve(edge) + if curve.GetType() != GeomAbs_Line: + continue + line = curve.Line() + if abs(_direction_dot(line.Direction(), gp_Dir(*direction))) < 0.96: + continue + props = GProp_GProps() + brepgprop.LinearProperties(edge, props) + edge_length = float(props.Mass()) + if edge_length <= 1e-9: + continue + if expected_length > 0 and abs(edge_length - expected_length) > length_tolerance: + continue + line_distance = _point_axis_distance(center_point, line.Direction(), line.Location()) + if line_distance > distance_limit: + continue + length_penalty = abs(edge_length - expected_length) / max(expected_length, 1.0) if expected_length > 0 else 0.0 + score = line_distance + length_penalty * max(target_distance, current_distance, 0.05) + candidates.append((score, topods.Edge(edge))) + except Exception: + continue + candidates.sort(key=lambda item: item[0]) + return [edge for _score, edge in candidates] + def face_area_local_resize_plan(self, face_id: int, target_area: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") @@ -3980,6 +4316,9 @@ class OperationMixin: blockers.append("找不到当前 Face 所属特征。") if solid is None: blockers.append("找不到当前 Face 所属 Solid。") + multistep_blocker = self._multistep_prismatic_plan_blocker(face_id, f"{axis_label}(局部重建)") + if multistep_blocker: + blockers.append(multistep_blocker) size_info = self._local_face_plane_size_info( face_id, @@ -5050,6 +5389,11 @@ class OperationMixin: confidence = str(info.get("shell_confidence", "low")) overlap_ratio = _float_or_none(info.get("shell_overlap_ratio_estimate")) + open_shell_limited = str(info.get("open_shell_context_status") or "").strip() == "limited" + if open_shell_limited: + warnings.append( + "当前 Face 属于开口薄壁壳体上下文;本次只按厚度方向缩放所属对象,不是完整抽壳/开口面特征级编辑。" + ) if confidence == "low": risk = _max_risk(risk, "high") warnings.append("壳体相对面识别置信度较低。") @@ -5159,6 +5503,11 @@ class OperationMixin: "shell_signed_thickness": signed_thickness, "shell_overlap_ratio_estimate": overlap_ratio, "shell_opposite_normal_dot": info.get("shell_opposite_normal_dot"), + "open_shell_context_status": info.get("open_shell_context_status"), + "open_shell_kind": info.get("open_shell_kind"), + "open_shell_current_face_role": info.get("open_shell_current_face_role"), + "open_shell_limited_action": info.get("open_shell_limited_action"), + "open_shell_blockers": info.get("open_shell_blockers"), "resize_strategy": "axis-scale-owning-shape-from-shell-thickness", "edit_strategy_label": "壳体厚度(缩放特征)", "edit_semantics": ( @@ -11406,6 +11755,7 @@ class OperationMixin: radius_tolerance = max(target_radius * 0.01, diagonal * 1e-5, 1e-5) required_adjacent = max(2, min(int(plan.get("cylindrical_feature_adjacent_face_count", 0) or 0), 4)) required_boundary = max(1, min(int(plan.get("cylindrical_feature_boundary_edge_count", 0) or 0), 4)) + required_match_count = max(1, int(plan.get("existing_fillet_resize_face_count", 1) or 1)) matched: list[dict[str, object]] = [] closest: dict[str, object] | None = None @@ -11459,7 +11809,7 @@ class OperationMixin: } ) - if not matched: + if len(matched) < required_match_count: if closest is None: detail = "no cylindrical Face remained in the source part" else: @@ -11468,8 +11818,9 @@ class OperationMixin: f"target {target_radius:.6g}, error {float(closest['radius_error']):.6g}" ) raise RuntimeError( - "Existing fillet result did not contain a target-radius cylindrical round Face; " - f"the model has been restored to the previous state. {detail}." + "Existing fillet result did not contain enough target-radius cylindrical round Faces; " + f"required {required_match_count}, matched {len(matched)}. " + f"The model has been restored to the previous state. {detail}." ) face_delta = int(getattr(after_stats, "faces", 0)) - int(getattr(before_stats, "faces", 0)) @@ -11486,10 +11837,226 @@ class OperationMixin: return ( "Existing fillet result check: " f"solids={before_solids}->{after_solids}, face_delta={face_delta}, edge_delta={edge_delta}, " - f"target_radius={target_radius:g}, matched_faces={matched_face_ids}, " + f"target_radius={target_radius:g}, required_matches={required_match_count}, matched_faces={matched_face_ids}, " f"first_level_topology_matched=True, topology={topology_summaries}." ) + def _existing_chamfer_result_summary_or_raise( + self, + plan: dict[str, object], + before_stats, + ) -> str: + part_id = _int_or_none(plan.get("part_id")) + if part_id is None: + raise RuntimeError("Existing chamfer result check failed: the source part could not be resolved.") + part = self.part_by_id(part_id) + if part is None: + raise RuntimeError(f"Existing chamfer result check failed: unknown part id {part_id}.") + + _ensure_valid_shape(part.shape) + after_stats = self.part_topology_stats(part_id) + before_solids = int(getattr(before_stats, "solids", 0)) + after_solids = int(getattr(after_stats, "solids", 0)) + if before_solids > 0 and after_solids != before_solids: + raise RuntimeError( + "Existing chamfer result changed the solid count; the model has been restored to the previous state. " + f"Solids {before_solids}->{after_solids}." + ) + + target_distance = _float_or_none(plan.get("target_distance")) + if target_distance is None or target_distance <= 0.0: + raise RuntimeError("Existing chamfer result check failed: target distance is invalid.") + diagonal = max(_shape_diagonal(part.shape), target_distance, 1.0) + distance_tolerance = max(target_distance * 0.03, diagonal * 1e-5, 1e-5) + matched: list[dict[str, object]] = [] + closest: dict[str, object] | None = None + + for candidate_face_id in range(len(self.faces)): + if int(self.face_part_ids[candidate_face_id]) != part_id: + continue + try: + feature = self.feature_info(candidate_face_id) + except Exception: + continue + if feature.get("existing_chamfer_status") != "candidate": + continue + distance = _float_or_none(feature.get("existing_chamfer_distance_estimate")) + if distance is None: + continue + error = abs(distance - target_distance) + candidate = { + "face_id": candidate_face_id, + "distance": distance, + "distance_error": error, + } + if closest is None or error < float(closest["distance_error"]): + closest = candidate + if error > distance_tolerance: + continue + try: + topology = self.face_first_level_topology(candidate_face_id) + boundary_count = int(topology.get("first_level_boundary_edge_count", 0) or 0) + adjacent_count = int(topology.get("first_level_adjacent_face_count", 0) or 0) + except Exception as exc: + raise RuntimeError( + "Existing chamfer result matched the target distance but first-level topology failed; " + f"Face {candidate_face_id}, error={exc}." + ) from exc + if boundary_count < 4 or adjacent_count < 2: + raise RuntimeError( + "Existing chamfer result matched the target distance but lost first-level topology; " + f"Face {candidate_face_id}, boundary_edges={boundary_count}, adjacent_faces={adjacent_count}." + ) + matched.append( + { + **candidate, + "boundary_edge_count": boundary_count, + "adjacent_face_count": adjacent_count, + } + ) + + if not matched: + if closest is None: + detail = "no simple chamfer Face remained in the source part" + else: + detail = ( + f"closest Face {closest['face_id']} distance {float(closest['distance']):.6g}, " + f"target {target_distance:.6g}, error {float(closest['distance_error']):.6g}" + ) + raise RuntimeError( + "Existing chamfer result did not contain a target-distance planar chamfer Face; " + f"the model has been restored to the previous state. {detail}." + ) + + face_delta = int(getattr(after_stats, "faces", 0)) - int(getattr(before_stats, "faces", 0)) + edge_delta = int(getattr(after_stats, "edges", 0)) - int(getattr(before_stats, "edges", 0)) + matched_face_ids = tuple(int(item["face_id"]) for item in matched[:8]) + topology_summaries = tuple( + { + "face_id": int(item["face_id"]), + "boundary_edge_count": int(item["boundary_edge_count"]), + "adjacent_face_count": int(item["adjacent_face_count"]), + } + for item in matched[:8] + ) + return ( + "Existing chamfer result check: " + f"solids={before_solids}->{after_solids}, face_delta={face_delta}, edge_delta={edge_delta}, " + f"target_distance={target_distance:g}, matched_faces={matched_face_ids}, " + f"first_level_topology_matched=True, topology={topology_summaries}." + ) + + def resize_existing_chamfer(self, face_id: int, target_distance: float) -> str: + plan = self.existing_chamfer_resize_plan(face_id, target_distance) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + part_id = int(plan["part_id"]) + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + + old_part_shape = part.shape + before_stats = self.part_topology_stats(part_id) + try: + source_face = topods.Face(self.faces[face_id]) + defeatured = _defeature_faces(part.shape, [source_face]) + root_edges = self._existing_chamfer_root_edge_candidates(defeatured, plan) + if not root_edges: + raise RuntimeError( + "Existing chamfer face was removed, but no matching recovered sharp Edge was found; " + "the selected chamfer is probably not a simple straight equal-distance chamfer." + ) + + result = None + failures: list[str] = [] + for index, root_edge in enumerate(root_edges[:16], start=1): + try: + maker = BRepFilletAPI_MakeChamfer(defeatured) + maker.Add(float(target_distance), topods.Edge(root_edge)) + result = _finalize_builder_result(maker, f"existing chamfer resize candidate {index}") + break + except Exception as exc: + failures.append(str(exc)) + if result is None: + detail = failures[-1] if failures else "no candidate Edge was available" + raise RuntimeError( + "Existing chamfer face was removed, but every candidate sharp Edge failed to rechamfer; " + f"the selected chamfer may be a complex blend. Last error: {detail}" + ) + + part.shape = result + self.refresh_topology() + result_check = self._existing_chamfer_result_summary_or_raise(plan, before_stats) + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise + + return ( + "Existing chamfer distance resize completed: " + f"face {face_id}, current_distance={float(plan['current_distance']):g}, " + f"target_distance={target_distance:g}, " + f"delta_distance={float(plan['delta_distance']):g}, " + f"support_faces={plan.get('feature_existing_chamfer_support_face_ids')}, " + f"risk={plan['risk']}. {result_check}" + ) + + def _existing_fillet_refillet_edge_candidate_groups( + self, + defeatured_shape: TopoDS_Shape, + plan: dict[str, object], + ) -> list[list[TopoDS_Shape]]: + face_ids = tuple(_int_values(plan.get("feature_existing_fillet_resize_face_ids"))) or ( + int(plan.get("face_id", -1)), + ) + groups: list[list[TopoDS_Shape]] = [] + for chain_face_id in face_ids: + if chain_face_id < 0 or chain_face_id >= len(self.faces): + raise RuntimeError(f"Existing fillet chain Face {chain_face_id} is not available.") + info = self.face_info(chain_face_id) + axis_point_values = _tuple_or_none(info.get("axis_point")) + axis_values = _tuple_normalized(_tuple_or_none(info.get("axis"))) + current_radius = _float_or_none(info.get("radius")) + if axis_point_values is None or axis_values is None or current_radius is None or current_radius <= 0.0: + raise RuntimeError(f"Existing fillet chain Face {chain_face_id} lacks stable axis or radius.") + group = _axis_aligned_edge_candidates( + defeatured_shape, + gp_Pnt(*axis_point_values), + gp_Dir(*axis_values), + expected_length=float(info.get("height_estimate") or 0.0), + reference_radius=current_radius, + ) + if not group: + raise RuntimeError( + f"Existing fillet chain Face {chain_face_id} was removed, " + "but no matching recovered sharp Edge was found for refillet." + ) + groups.append(group[:4]) + return groups + + def _existing_fillet_refillet_edge_sets( + self, + candidate_groups: list[list[TopoDS_Shape]], + *, + max_attempts: int = 32, + ) -> list[list[TopoDS_Shape]]: + edge_sets: list[list[TopoDS_Shape]] = [] + for combo in itertools.product(*candidate_groups): + unique_edges: list[TopoDS_Shape] = [] + duplicate = False + for edge in combo: + if any(edge.IsSame(existing) for existing in unique_edges): + duplicate = True + break + unique_edges.append(edge) + if duplicate: + continue + edge_sets.append(unique_edges) + if len(edge_sets) >= max_attempts: + break + return edge_sets + def resize_existing_fillet(self, face_id: int, target_radius: float) -> str: plan = self.existing_fillet_resize_plan(face_id, target_radius) if plan["status"] == "blocked": @@ -11503,29 +12070,24 @@ class OperationMixin: old_part_shape = part.shape before_stats = self.part_topology_stats(part_id) try: - source_face = topods.Face(self.faces[face_id]) - defeatured = _defeature_faces(part.shape, [source_face]) - axis_point = gp_Pnt(*plan["axis_point"]) - axis_dir = gp_Dir(*plan["axis"]) - root_edges = _axis_aligned_edge_candidates( - defeatured, - axis_point, - axis_dir, - expected_length=float(plan.get("height_estimate") or 0.0), - reference_radius=float(plan["current_radius"]), - ) - if not root_edges: + resize_face_ids = tuple(_int_values(plan.get("feature_existing_fillet_resize_face_ids"))) or (face_id,) + source_faces = [topods.Face(self.faces[item]) for item in resize_face_ids] + defeatured = _defeature_faces(part.shape, source_faces) + candidate_groups = self._existing_fillet_refillet_edge_candidate_groups(defeatured, plan) + edge_sets = self._existing_fillet_refillet_edge_sets(candidate_groups) + if not edge_sets: raise RuntimeError( - "Existing fillet face was removed, but no axis-aligned sharp Edge was found for refillet; " - "the selected round is probably not a simple straight-edge fillet." + "Existing fillet face(s) were removed, but no non-duplicate sharp Edge set was found for refillet; " + "the selected round may be a complex blend." ) result = None failures: list[str] = [] - for index, root_edge in enumerate(root_edges[:16], start=1): + for index, root_edges in enumerate(edge_sets, start=1): try: maker = BRepFilletAPI_MakeFillet(defeatured) - maker.Add(float(target_radius), topods.Edge(root_edge)) + for root_edge in root_edges: + maker.Add(float(target_radius), topods.Edge(root_edge)) result = _finalize_builder_result(maker, f"existing fillet resize candidate {index}") break except Exception as exc: @@ -11533,7 +12095,7 @@ class OperationMixin: if result is None: detail = failures[-1] if failures else "no candidate Edge was available" raise RuntimeError( - "Existing fillet face was removed, but every candidate sharp Edge failed to refillet; " + "Existing fillet face(s) were removed, but every candidate sharp Edge set failed to refillet; " f"the selected round may be a complex blend. Last error: {detail}" ) @@ -11550,6 +12112,7 @@ class OperationMixin: f"face {face_id}, current_radius={float(plan['current_radius']):g}, " f"target_radius={target_radius:g}, " f"delta_radius={float(plan['delta_radius']):g}, " + f"resize_faces={plan.get('feature_existing_fillet_resize_face_ids')}, " f"support_faces={plan.get('feature_existing_fillet_support_face_ids')}, " f"risk={plan['risk']}. {result_check}" ) diff --git a/step_editor/recognition_priority.py b/step_editor/recognition_priority.py index 85d1d9b..a0a4677 100644 --- a/step_editor/recognition_priority.py +++ b/step_editor/recognition_priority.py @@ -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 diff --git a/step_editor/ui_helpers.py b/step_editor/ui_helpers.py index 95d1304..28e714a 100644 --- a/step_editor/ui_helpers.py +++ b/step_editor/ui_helpers.py @@ -79,6 +79,29 @@ 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", + "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 +169,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 +247,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 +340,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 +554,29 @@ 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": "一级平面关系说明", + "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 +752,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 +823,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": "锥孔小端半径", diff --git a/step_editor/window_actions.py b/step_editor/window_actions.py index 4822fce..51b3aff 100644 --- a/step_editor/window_actions.py +++ b/step_editor/window_actions.py @@ -1487,6 +1487,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 +4102,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 +4570,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 +4627,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 +4758,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 +4816,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 +5023,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 +5121,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 +5133,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 +5141,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 +5184,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 +5200,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 +5216,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 +5266,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"), diff --git a/step_editor/window_core.py b/step_editor/window_core.py index fc35955..638f333 100644 --- a/step_editor/window_core.py +++ b/step_editor/window_core.py @@ -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)) diff --git a/step_editor/window_state.py b/step_editor/window_state.py index f0764cf..6c743d3 100644 --- a/step_editor/window_state.py +++ b/step_editor/window_state.py @@ -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,7 +1551,7 @@ class WindowStateMixin: "Face:面内长度/宽度、中心、偏移、壳体厚度。\n" "孔/槽:孔径、轴心、封堵、盲孔/盲槽深度、槽宽/槽深/弧长/总长。\n" "凸台:圆柱凸台直径/高度/轴心;矩形凸台/矩形槽口袋长宽、中心、高度/深度;多台阶矩形凸台顶层规则台阶。\n" - "圆角/倒角:简单已有圆角半径/弧长,直线 Edge 新增圆角/倒角。\n" + "圆角/倒角:简单已有圆角半径/弧长、简单等半径圆角链半径/弧长、已有等距倒角距离,直线 Edge 新增圆角/倒角。\n" "Edge/解析曲面:直线 Edge 长度/端点/中心、圆/椭圆 Edge、简单圆锥/球/环面。\n" "受限:复杂链式特征、二级/三级拓扑传播和原 CAD 历史恢复。" ) @@ -2566,7 +2582,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 +2630,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 +2639,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 +3004,11 @@ class WindowStateMixin: "工程特征 / 倒圆角:移除已有圆角后按目标半径重新倒圆。", "STEP 没有原始倒圆角历史时,只能基于当前圆角面和支撑面推断;复杂 blend 会阻止或回滚。", ) + if is_existing_chamfer: + return ( + "工程特征 / 倒角:移除已有倒角斜面后按目标距离重新倒角。", + "STEP 没有原始倒角历史时,只能基于当前斜面、支撑面和恢复出的锐边推断;倒角链或不等距倒角先保持受限。", + ) if is_shell_candidate: return ( "工程特征 / 抽壳:按壳体厚度或相对面距离调整薄壁区域。", @@ -3062,6 +3093,11 @@ class WindowStateMixin: "优先改圆角半径;复杂圆角链先不要用整体缩放替代。", "已有圆角需要先恢复支撑锐边再重新倒圆,支撑面不明确时失败回滚是正常保护。", ) + if is_existing_chamfer: + return ( + "优先改倒角距离;复杂倒角链先保持只读或重新选 Edge 新增倒角。", + "已有倒角需要先移除斜面、恢复原始锐边再重新倒角;如果支撑面或长边不明确,会在计划阶段阻止。", + ) if is_shell_candidate: return ( "优先改壳体厚度;普通位置变化再用偏移。", @@ -3243,6 +3279,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", @@ -3502,7 +3548,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")) @@ -4547,6 +4593,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 +4848,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 +4912,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",