diff --git a/README.md b/README.md index 8297483..3cf0dc3 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,84 @@ - 这是一个 STEP B-Rep 结果几何查看和受限局部编辑工具,不是完整参数化 CAD 内核。 - 主入口是 `python main.py`;默认模型是 `assets/models/geom_extract.step`;立方体测试模型是 `assets/models/cube_10mm.step`。 -- 左侧操作面板按工作流排列:STEP 文件、模型结构树、鼠标选择模式、按 ID 选择、编辑、导出、显示、测量、可编辑对象、圆柱面候选、操作历史和对象信息。 +- 左侧操作面板当前优先保留最小建模链路:STEP 文件、选择模式 / 按 ID 选择、当前选中对象、编辑、参数化建模和导出当前完整 STEP;部分辅助面板暂时收起,后续需要时再放回。 - 局部编辑会先做计划、风险提示、预览和后台执行;失败时会尽量回滚,成功后进入撤销/重做历史。 - 新开 Codex 聊天框继续开发时,只需要 Codex 阅读 README 末尾的“Codex 项目记忆”;普通开发者可以忽略那一节。 +## 怎么运行 + +你第一次配置或运行这个项目时,可以按下面步骤来。命令默认在 Windows PowerShell 里执行。 + +1. 进入项目目录。如果你的仓库不在这个路径,把命令里的路径换成你的实际目录: + +```powershell +cd C:\Users\admin\Desktop\python-occt +``` + +2. 如果你已经有 `pyocc` 环境,直接激活: + +```powershell +conda activate pyocc +``` + +3. 如果你是第一次配置环境,先用仓库里的 `environment.yml` 创建环境,再激活: + +```powershell +conda env create -f environment.yml +conda activate pyocc +``` + +4. 先跑一次烟测,确认入口和依赖能正常加载。这个命令不会进入交互窗口: + +```powershell +python main.py --smoke-test +``` + +5. 启动程序并打开默认测试模型: + +```powershell +python main.py +``` + +6. 启动时直接打开指定 STEP 文件: + +```powershell +python main.py path\to\model.step +``` + +7. 也可以先启动程序,在左侧 `STEP文件` 区域点击 `导入几何模型` 选择 `.step` / `.stp` 文件,再点击 `读取模型`。 + +8. 打开仓库自带的简单立方体测试模型: + +```powershell +python main.py assets\models\cube_10mm.step +``` + +9. 重新生成简单立方体测试模型: + +```powershell +python scripts\generate_cube_step.py +``` + +10. 验证立方体上的“只改一条 Edge 长度”基线: + +```powershell +python scripts\verify_edge_length_resize.py +``` + +11. 如果当前 PowerShell 没识别 `conda`,你可以直接使用完整路径运行: + +```powershell +C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python main.py +``` + +开发时常用的本地检查: + +```powershell +python -m compileall -q main.py step_editor scripts +git diff --check +``` + ## 最终要实现什么 最终目标是做一个面向 STEP 模型的交互式工具: @@ -22,7 +96,7 @@ - 用户可以选择零件、Solid、Face、Edge 和几何特征。 - 用户可以做基础测量,例如把两个拾取点或对象中心设为 A/B 并计算距离。 - 程序可以识别孔、圆角、凸台、槽、壳体局部区域等候选特征。 -- 用户可以修改可控的特征,比如平面推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、凸台直径调整、Edge倒圆、Edge倒角和修改Edge长度。 +- 用户可以修改可控的特征,比如平面推拉和目标法向位置、孔径/半径调整、完整圆柱孔轴心坐标修改、槽/半孔宽度、深度、圆弧长度、圆弧角度、开口角度和槽孔总长度调整、盲孔/盲槽深度调整、凸台直径/半径/高度/轴心坐标调整、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度调整、圆锥半角、环面直径、Edge倒圆、Edge倒角、圆Edge半径/直径、修改Edge长度和直线Edge起点/终点坐标。 - 用户可以撤销/重做修改,避免一步编辑做坏。 - 用户可以导出修改后的完整模型。 - 如果 STEP 文件里存在多个互不关联的零件,用户可以只导出选中的某个零件。 @@ -44,6 +118,23 @@ - 能撤销/重做编辑。 - 先实现少量可控编辑操作,用于验证后续特征编辑路线。 +## 当前可修改范围 + +这张表描述的是当前版本已经接入到界面或编辑链路里的能力。它们都是基于 STEP 的 B-Rep 结果几何做局部重建,不等于原 CAD 软件里的完整建模历史参数。 + +| 对象 / 特征 | 现在可以修改 | 暂不能保证或还不能修改 | +| --- | --- | --- | +| 平面 Face | `Face目标法向位置`、`Face本次偏移量`;识别到相对平面时可以改薄壁厚度。 | `Face面积` 目前只读;自由曲面、复杂壳体局部区域和带复杂边界的面不能直接按面积修改。 | +| 圆柱孔 / 圆柱面 | 孔或圆柱直径、半径;完整圆柱孔可以改轴心坐标;完整孔可以尝试封堵。 | 复杂孔、螺纹孔、锥孔、不完整圆柱孔和拓扑不稳定的孔可能失败并回滚。 | +| 盲孔 / 盲槽 | 识别到疑似底面 Face 时,可以改 `盲孔/盲槽深度`。 | STEP 不保存真实孔深历史;找不到可靠底面或底面被复杂拓扑切碎时,深度只能只读或需要手动辅助。 | +| 槽 / 半孔 | 可以改槽/半孔宽度、深度、圆弧长度、圆弧角度和开口角度;长圆槽识别到成对半圆端时,可以改槽孔总长度和中心距,也可以手动填写配对端 Face ID。 | 复杂草图槽、非圆柱槽、槽的整体迁移/轴心移动、链式槽和跨多个不规则面的槽还不是稳定能力。 | +| 圆柱凸台 / 外圆 | 完整圆柱凸台可以改直径、半径、高度和轴心坐标;普通完整圆柱也有高度兜底修改。 | 复杂凸台、异形凸台、多台阶凸台和不完整外圆不能保证稳定重建。 | +| 已有圆角 / 倒圆面 | 部分规则圆柱倒圆面可以改半径或圆弧长度,使用先移除旧圆角再重建新圆角的路线。 | 复杂 blend、支撑面不明确、圆角链或恢复锐边失败时会阻止或回滚。 | +| Edge | 直线 Edge 可以尝试改目标长度、起点坐标、中心坐标和终点坐标;直线 Edge 可以加倒圆、对称倒角、不等距倒角和距离+角度倒角;圆形/圆弧 Edge 可以尝试改半径或直径。 | “通用任意 Edge”仍是受限实现;复杂曲线、B-spline、和无法确定局部变形区域的 Edge 可能走高风险 fallback 或失败。 | +| 圆锥 / 球 / 环面 | 圆锥可以改参考半径、直径或半角;球可以改半径或直径;环面可以改主/副半径或直径。 | 这些属于几何缩放或局部重建能力,不是原始特征历史恢复;复杂相邻拓扑可能失败。 | +| Solid / 特征整体 | 可以对选中 Solid 或当前单一模型做平移、旋转和按包围盒尺寸 / 体积 / 表面积的缩放类修改。 | 还没有装配约束、零件间关联约束或完整多零件装配编辑。 | +| 只读信息 | 面积、体积、包围盒、曲面类型、相邻关系、候选特征说明等会展示出来帮助判断。 | 标成只读的值不会被 `参数化建模` 修改;显示、测量和导出不是建模操作。 | + ## 现在已经实现了什么 当前已经实现: @@ -124,14 +215,16 @@ - `深度扫描` 会尝试列出更多候选,适合默认列表里没有找到目标对象时使用。 - 可推拉的平面Face。 - 可调整孔径的圆柱Face候选。 - - 可调整槽/半孔宽度的局部圆柱孔/槽候选。 - - 可调整盲孔深度的圆柱Face候选。 + - 可调整槽/半孔宽度、深度和圆弧长度的局部圆柱孔/槽候选。 + - 可调整盲孔/盲槽深度的圆柱Face候选。 + - 可调整圆柱凸台直径和高度的凸台候选。 - 可添加新圆角的直线Edge候选。 - 可添加倒角的直线Edge候选。 - 可尝试直接调整长度的直线Edge候选,简单平面多面体会优先局部边形变。 + - 可调整圆Edge半径/直径的圆形或圆弧Edge候选。 - 表格会显示当前值、状态、风险、置信度和说明。 - - 盲孔深度候选会在列表阶段先确认疑似底面,列表里的深度值优先使用底面Face轴向位置计算。 - - 点击表格行会直接选中对应Face或Edge,方便继续执行推拉、孔径调整、槽/半孔宽度调整、盲孔深度调整、Edge倒圆、Edge倒角或修改Edge长度。 + - 盲孔/盲槽深度候选会在列表阶段先确认疑似底面,列表里的深度值优先使用底面Face轴向位置计算。 + - 点击表格行会直接选中对应Face或Edge,方便继续执行推拉、孔径调整、槽/半孔宽度/深度/圆弧长度调整、凸台高度调整、盲孔/盲槽深度调整、Edge倒圆、Edge倒角或修改Edge长度。 - 支持列出圆柱面候选特征。 - 圆柱面候选会显示在表格中,点击候选行可以直接选中并高亮对应 Face。 - 圆柱面候选可以按类型筛选:全部、孔/槽候选、圆角候选、凸柱/外圆候选、未明确分类。 @@ -172,6 +265,22 @@ - 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。 - 输入目标槽宽后,会按当前圆弧角度把槽宽换算为目标圆柱直径,再复用圆柱孔/槽重建路线执行。 - 这不是完整 CAD 槽参数编辑;复杂槽、非圆柱槽或圆弧角度不稳定时仍可能失败并回滚。 +- 当前版本支持槽/半孔深度调整: + - 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。 + - 输入目标槽深后,会按当前圆弧角度把槽深换算为目标圆柱直径,再复用圆柱孔/槽重建路线执行。 + - 这仍是 B-Rep 几何近似,不是完整 CAD 槽深参数编辑。 +- 当前版本支持槽/半孔圆弧长度调整: + - 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。 + - 输入目标圆弧长度后,会按当前圆弧角度把圆弧长度换算为目标圆柱直径,再复用圆柱孔/槽重建路线执行。 + - 这仍是 B-Rep 几何近似,不是完整 CAD 槽参数编辑。 +- 当前版本支持槽/半孔圆弧角度和开口角度调整: + - 只对已识别为 `partial-cylindrical-groove` 的部分圆柱孔/槽候选开放。 + - 输入目标圆弧角度时,程序会保持当前圆柱半径,先补旧扇形槽,再按目标角度重切局部扇形槽。 + - 输入目标开口角度时,会先换算为 `2π - 开口角` 的圆弧角度后执行。 + - 如果检测到成对长圆槽端,当前角度修改仍只重建所选局部扇形槽。 +- 当前版本支持长圆槽/槽孔总长度和中心距调整: + - 自动识别到成对半圆槽端时,程序会保持槽宽不变,先补旧长圆槽体,再切出目标总长度。 + - 如果自动配对失败,可以在当前选中对象表中填写 `槽孔配对端Face ID`,后续槽宽、槽深、圆弧长度和总长度调整会优先尝试使用这个手动配对端。 - 当前版本支持薄壁/壳体局部厚度调整: - 对已找到投影重叠相对平面的平面候选开放。 - 输入目标厚度后,程序会把厚度变化换算成当前平面区域的推拉距离,保留相对平面不动。 @@ -183,7 +292,7 @@ - 会识别已有圆角Face和相邻支撑Face,并在特征模式下一起高亮。 - `可编辑对象` 会把已有圆角候选单独列为 `修改已有圆角半径`,点击后会选中该圆角 Face,并把圆角半径输入框预填为当前估算半径的参考目标值。 - 选中已有圆角候选时,不会再误把它填入 `孔直径`,避免把圆角当孔径修改。 - - 当前版本的已有圆角半径修改会先尝试移除圆角Face,再在恢复出的锐边上重新倒圆;只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。 + - 当前版本的已有圆角半径修改会先尝试移除圆角Face,再在恢复出的锐边上重新倒圆;也可以在当前选中对象表中输入已有圆角圆弧长度,程序会按当前圆弧角度换算成目标半径。只对较规整的圆柱圆角候选开放,复杂 blend 可能失败并回滚。 - 当前版本支持圆柱孔封堵: - 只对 `hole/groove candidate` 且角度跨度接近完整圆柱的孔开放。 - 不对半孔/槽开放,避免用完整圆柱补料时把不该补的位置也补上。 @@ -197,6 +306,11 @@ - 目标直径变小时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建,避免环形 cutter 在部分场景下把实体切空。 - 会先显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。 - 当前默认模型可能没有明确凸台候选;包含完整圆柱凸台的 STEP 文件可以使用这个能力。 +- 当前版本支持圆柱凸台高度和轴心坐标调整: + - 只对已识别为完整圆柱凸台、并找到可推拉端盖 Face 的候选开放。 + - 输入目标高度后,程序会选择凸台端盖并把高度变化换算成端盖推拉距离。 + - 输入目标轴心坐标后,程序会先移除旧凸台包络,再按同直径在目标轴心补出凸台。 + - 这不是 CAD 历史里的拉伸特征高度参数;端盖识别不稳定或推拉计划被阻止时会拒绝执行。 - 当前版本支持直线Edge添加圆角: - 只对直线Edge开放。 - 会先检查边长、相邻Face数量和目标半径相对边长的比例。 @@ -207,14 +321,18 @@ - 当前版本支持直线Edge添加倒角: - 只对直线Edge开放。 - 会先检查边长、相邻Face数量和倒角距离相对边长的比例。 + - 当前选中对象表支持 `新增倒角距离` 的对称距离倒角、`新增不等距倒角D1/D2` 的两侧不同距离倒角,以及 `新增距离+角度倒角D/角度`。 - 橙色半透明预览表示目标Edge和输入倒角距离的大致范围。 - 真实编辑使用 OCCT `BRepFilletAPI_MakeChamfer` 在后台执行。 - STEP 没有建模历史,某些边仍可能被 OCCT 拒绝;失败时会自动尝试恢复编辑前状态。 - 当前版本支持修改Edge长度: - 选中Edge后,`Edge目标长度` 会自动填入当前Edge长度。 + - 对圆形/圆弧Edge,当前选中对象表会额外提供圆Edge半径和圆Edge直径可编辑行,内部会换算为等比例Edge目标长度。 - 可以选择Edge长度基准:自动、中心、固定起点、固定终点。 + - 对直线Edge,当前选中对象表会额外提供 `Edge起点坐标`、`Edge中心坐标` 和 `Edge终点坐标`,可以直接输入目标 X/Y/Z 坐标来移动单个端点或整条边。 - 对正方体这类全平面多面体,直线Edge会优先走“局部边形变”:只移动被选Edge的一个端点或两个端点,并重建周边平面。 - 局部边形变不会把整个端面拉走,因此正方体不会默认变成长方体;相邻面会按被移动的边自然变成梯形或拆成三角面。 + - 直线Edge端点/中心坐标修改复用同一套局部边形变;当前只对简单全平面多面体开放,复杂曲面、带内孔Face或大型复杂Solid会阻止执行。 - `自动` 默认固定起点、移动终点;`固定起点` / `固定终点` 会明确控制哪一端不动;`中心` 会让两端各移动一半。 - 如果模型不是简单全平面多面体,直线Edge会继续尝试寻找端点附近、方向匹配的平面端面,并把“改边长”转换成该端面的平面推拉操作。 - 圆形/圆弧Edge会优先检查相邻圆柱面;如果能复用孔/槽或凸台直径编辑,就把Edge目标长度按比例换算成目标圆柱直径再执行局部编辑。 @@ -243,6 +361,7 @@ - 如果后台编辑失败,程序会自动尝试恢复到编辑前快照,避免出现“提示失败但模型已经被部分改动”的状态。 - 当前版本支持圆柱孔/圆柱面扩大切削。 - 当前版本支持圆柱孔/圆柱面缩小孔径:先补料,再用目标直径重切。 +- 当前版本支持完整圆柱孔轴心坐标修改:在当前选中对象表里修改 `孔轴心坐标`,程序会先补旧孔,再按同直径在目标轴心位置重切新孔;半孔/开放槽暂不放行这条路线,避免整圆柱工具误补材料。 - 圆柱孔/圆柱面孔径调整现在会先生成切削计划: - 目标直径必须大于 0;如果目标直径与当前直径几乎相同,会直接阻止。 - 会计算直径变化比例;变化超过当前直径 35% 会提高到中风险,超过 100% 会提高到高风险。 @@ -277,7 +396,7 @@ - 鼠标拾取模型时会显示三维拾取点坐标。 - 编辑历史会记录目标对象和当时的拾取点。 - 支持把本软件加载后的编辑历史导出为 JSON v2;除摘要和详情外,也会保存 `operation_name`、`target`、`parameters` 和 `result_message`,为后续安全复放做准备。 -- 点击操作历史记录时,程序会尝试重新高亮目标对象,并显示当时的拾取点标记。 +- 点击操作历史记录时,程序会尝试重新高亮目标对象,并在详情里保留当时的拾取点坐标;3D 视图里不会再额外显示拾取点小球。 - 对非 `hole/groove candidate` 的圆柱切削会先弹出确认,避免误切外圆角或凸柱。 - 提供 `--smoke-test` 启动检查,用于验证 Qt + VTK 窗口组件能正常初始化。 @@ -302,7 +421,7 @@ vertices: 3262 - 复杂特征识别:已经有平面、圆柱、边、孔/槽候选、槽/半孔候选、已有圆角候选、圆柱凸台候选等基础候选列表,但还不是完整 CAD 语义级识别。 - 盲孔/盲槽深度:已经能对部分可识别候选做深度估算和编辑,并会优先使用疑似底面Face的轴向位置计算当前深度;但还不能保证所有模型都能恢复真实 CAD 语义深度。 -- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度调整、完整圆柱凸台直径修改,以及平面薄壁/壳体相对面厚度估算和当前版本厚度调整;复杂槽、复杂凸台和完整壳体区域仍需要继续做。 +- 槽、凸台、壳体局部区域:已经有圆柱孔、槽/半孔候选结构化识别、槽/半孔宽度/深度/圆弧长度/圆弧角度/开口角度调整、长圆槽总长度/中心距调整和手动配对端 Face ID、完整圆柱凸台直径/半径/高度/轴心坐标修改、普通完整圆柱高度兜底修改,以及平面薄壁/壳体相对面厚度估算和当前版本厚度调整;复杂槽、复杂凸台和完整壳体区域仍需要继续做。 - 已有圆角/倒圆面半径:已经有当前版本的受限修改实现,使用 defeature + refillet 路线,但复杂 blend、支撑面不明确或恢复锐边失败时仍会阻止或回滚。 - 局部偏差分析:已经有编辑前后体积、面积、包围盒、距离热力图和差异报告,但还不是工程公差级的局部区域偏差报告。 - 错误恢复和模型修复:已经有编辑前快照、失败回滚、基础 B-Rep 校验、同域面合并、布尔结果自动修复,以及一键修复当前模型或选中零件/solid,但还不是完整的模型修复流水线。 @@ -318,75 +437,12 @@ vertices: 3262 建议下一步按这个顺序推进: 1. 继续补齐更多最小可用特征入口,优先覆盖复杂槽、复杂凸台、完整壳体区域和装配用例。 -2. 继续验证平面推拉、孔径调整、槽/半孔宽度调整、薄壁厚度调整、盲孔/盲槽深度调整、圆柱凸台直径调整和直接边长调整,记录哪些对象稳定、哪些对象会失败。 +2. 继续验证平面推拉、孔径/半径调整、槽/半孔宽度/深度/圆弧长度调整、薄壁厚度调整、盲孔/盲槽深度调整、圆柱凸台直径/半径/高度/轴心坐标调整和直接边长调整,记录哪些对象稳定、哪些对象会失败。 3. 改进盲孔/槽底面识别,减少“看起来像 blind 但找不到可靠底面Face”的候选。 4. 继续加固已有圆角半径修改:扩大可成功场景,记录哪些圆角能 defeature + refillet,哪些需要更复杂的重建策略。 5. 完善本软件编辑历史的导入/复放能力;当前 JSON v2 已保存机器可读的动作名和参数,但自动复放还需要按操作类型逐步加安全规则。 6. 继续做性能和稳定性优化,重点是扫描分批刷新、局部显示重建缓存,以及高风险 OCC 操作隔离。 -## 怎么运行 - -先打开 PowerShell,进入项目目录: - -```powershell -cd C:\Users\admin\Desktop\python-occt -``` - -激活环境: - -```powershell -conda activate pyocc -``` - -如果是在一台新电脑上第一次配置环境,可以使用仓库里的 `environment.yml` 创建环境: - -```powershell -conda env create -f environment.yml -conda activate pyocc -``` - -运行默认模型: - -```powershell -python main.py -``` - -打开指定 STEP 文件: - -```powershell -python main.py path\to\model.step -``` - -打开简单立方体测试模型: - -```powershell -python main.py assets\models\cube_10mm.step -``` - -重新生成简单立方体测试模型: - -```powershell -python scripts\generate_cube_step.py -``` - -如果当前 PowerShell 没识别 `conda`,可以直接使用完整路径运行: - -```powershell -C:\Users\admin\miniforge3\Scripts\conda.exe run -n pyocc python main.py -``` - -启动烟测,不进入交互窗口: - -```powershell -python main.py --smoke-test -``` - -验证立方体上的“只改一条 Edge 长度”基线: - -```powershell -python scripts\verify_edge_length_resize.py -``` - ## 怎么使用 打开程序后,右侧是 3D 模型窗口,左侧是操作面板。 @@ -459,10 +515,10 @@ python scripts\verify_edge_length_resize.py - 当前默认显示一批代表性的可编辑对象,详细几何判断会尽量延后到选中对象或执行编辑前,避免扫描过慢。 - `推拉平面` 行表示这个Face是平面,可以配合 `Face偏移` 和 `推拉平面` 使用。 - `调整圆柱孔径` 行表示这个 Face 是圆柱面候选,可以配合 `孔直径` 和 `调整圆柱孔径` 使用。 -- `调整槽/半孔宽度` 行表示这个Face是槽/半孔候选,可以配合 `槽/半孔宽度` 和 `调整槽/半孔宽度` 使用。 -- `调整圆柱凸台直径` 行表示这个Face是较明确的完整圆柱凸台候选,可以配合 `凸台直径` 和 `调整圆柱凸台直径` 使用。 +- `调整槽/半孔宽度` / `调整槽/半孔深度` / `调整槽/半孔圆弧长度` 行表示这个Face是槽/半孔候选,可以在当前选中对象表里修改槽宽、槽深或圆弧长度。 +- `调整圆柱凸台直径` / `调整圆柱凸台高度` 行表示这个Face是较明确的完整圆柱凸台候选,可以在当前选中对象表里修改凸台直径、半径、高度或轴心坐标。 - `封堵圆柱孔` 行表示这个Face是接近完整圆柱的孔候选,可以点击后直接使用 `封堵圆柱孔`。 -- `调整盲孔深度` 行表示这个Face是较明确的盲孔/盲槽候选,可以配合 `孔深度` 和 `调整盲孔深度` 使用。 +- `调整盲孔/盲槽深度` 行表示这个Face是较明确的盲孔/盲槽候选,可以在当前选中对象表里修改盲孔/盲槽深度。 - `给Edge添加圆角` 行表示这个对象是直线Edge,可以配合 `圆角半径` 和 `给Edge添加圆角` 使用。 - `给Edge添加倒角` 行表示这个对象是直线Edge,可以配合 `倒角距离` 和 `给Edge添加倒角` 使用。 - `修改已有圆角半径` 行表示这个Face是已有圆角/倒圆候选,点击后会切到 `特征` 模式并预填圆角半径参考值;真正执行需要再点击编辑区里的 `修改已有圆角半径` 按钮并确认。 @@ -546,6 +602,23 @@ python scripts\verify_edge_length_resize.py - 输入目标槽宽后点击 `调整槽/半孔宽度`。 - 程序会用当前圆弧角度把目标槽宽换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 - 这一步仍是 B-Rep 几何近似;复杂槽、非圆柱槽或圆弧角度不稳定时可能失败并回滚。 +- `槽/半孔深度`: + - 先选择一个槽/半孔候选 Face。 + - 在当前选中对象表里修改 `槽/半孔深度`,点击该行的 `可修改` 或点击 `参数化建模`。 + - 程序会按当前圆弧角度把目标槽深换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 +- `槽/半孔圆弧长度`: + - 先选择一个槽/半孔候选 Face。 + - 在当前选中对象表里修改 `槽/半孔圆弧长度`,点击该行的 `可修改` 或点击 `参数化建模`。 + - 程序会按当前圆弧角度把目标圆弧长度换算成目标圆柱直径,再复用圆柱孔/槽调整流程。 +- `槽/半孔圆弧角度` / `槽/半孔开口角度`: + - 先选择一个槽/半孔候选 Face。 + - 在当前选中对象表里修改角度值,角度输入单位是度。 + - 圆弧角度会直接换算成局部扇形槽目标角度;开口角度会换算成 `2π - 开口角`。 + - 程序会保持当前圆柱半径,先补旧扇形槽,再按目标角度重切局部扇形槽。 +- `槽孔配对端Face ID` / `槽孔总长度` / `槽孔中心距`: + - 对长圆槽或成对半圆槽,程序会自动寻找另一个槽端。 + - 如果自动配对失败,可以先在 `槽孔配对端Face ID` 行填入另一个半圆端的 Face ID 并点击 `设置`。 + - 修改 `槽孔总长度` 或 `槽孔中心距` 时,会保持槽宽不变并重建完整长圆槽体。 - `薄壁厚度` + `调整薄壁厚度`: - 先选择一个平面 Face,建议从 `可编辑对象` 中点击 `调整薄壁厚度` 行进入。 - 选中符合条件的薄壁/壳体平面后,`薄壁厚度` 会自动填入一个比当前估算厚度略大的建议值。 @@ -560,6 +633,14 @@ python scripts\verify_edge_length_resize.py - 目标直径小于当前直径时,会先用旧外形包络体移除原凸台范围,再 Fuse 目标直径圆柱重建。 - 点击后会显示半透明预览:绿色表示扩大/重建补料范围,红色表示缩小移除范围。 - 这不是 CAD 历史里的“修改凸台特征参数”,而是基于当前 B-Rep 的受限几何布尔编辑。 +- `凸台高度`: + - 先选择一个完整圆柱凸台 Face。 + - 在当前选中对象表里修改 `凸台高度`,点击该行的 `可修改` 或点击 `参数化建模`。 + - 程序会尝试找到凸台外端盖 Face,并通过推拉端盖改变高度。 +- `凸台轴心坐标`: + - 先选择一个完整圆柱凸台 Face。 + - 在当前选中对象表里修改 `凸台轴心坐标`,点击该行的 `应用` 或点击 `参数化建模`。 + - 程序会切掉旧凸台包络,再按原直径和原高度范围在目标轴心位置补出新凸台;目标位置离基体太远时风险会升高。 - `封堵圆柱孔`: - 先选择一个完整圆柱孔 Face,建议从 `可编辑对象` 中点击 `封堵圆柱孔` 行进入。 - 点击 `封堵圆柱孔` 后,程序会先生成补料计划。 @@ -591,6 +672,7 @@ python scripts\verify_edge_length_resize.py - 先切换到 `特征` 选择模式并选择一个已有圆角/倒圆候选 Face,或从 `可编辑对象` 中点击 `修改已有圆角半径` 行进入。 - 选中已有圆角候选后,`圆角半径` 会自动填入一个基于当前估算半径的参考目标值。 - 输入目标半径后点击 `修改已有圆角半径`。 + - 也可以在当前选中对象表里修改 `已有圆角圆弧长度`,程序会按当前圆弧角度换算成目标半径后执行同一条修改路线。 - 程序会先检查该Face是否为 `round/fillet candidate`、是否识别到至少两个支撑Face、目标半径是否有效,以及当前零件是否为单Solid。 - 通过检查后,会显示蓝色半透明预览,表示将尝试移除并重建的已有圆角面。 - 真实编辑会先用 OCCT defeaturing 移除已有圆角面,再从恢复出的候选锐边中寻找可重新倒圆的边并调用 OCCT 倒圆 API。 @@ -599,19 +681,26 @@ python scripts\verify_edge_length_resize.py - 先切换到 `Edge` 选择模式并选择一条直线Edge,或从 `可编辑对象` 中点击 `给Edge添加倒角` 行进入。 - 选中直线Edge后,`倒角距离` 会自动填入一个较小的建议距离。 - 输入目标倒角距离后点击 `给Edge添加倒角`。 + - 如果需要两侧不同距离,在当前选中对象表里填写 `新增不等距倒角D1/D2`,格式为 `D1, D2`。 + - 如果需要距离+角度倒角,在当前选中对象表里填写 `新增距离+角度倒角D/角度`,格式为 `距离, 角度`,角度单位是度。 + - `倒角参考Face ID` 决定 D1/D2 的方向;默认使用当前Edge的第一个相邻Face,也可以改成另一个相邻Face后点击 `设置`。 - 程序会先检查倒角距离是否大于 0、是否相对边长过大,以及该Edge是否检测到至少两个相邻Face。 - 点击后会显示橙色半透明圆管预览,用于提示目标边和倒角距离范围。 - 真实编辑会在后台调用 OCCT 倒角 API,完成后刷新模型。 - - 当前只支持对称距离倒角,不支持两侧不同距离或距离+角度倒角。 + - 当前支持对称距离倒角、两侧不同距离倒角和距离+角度倒角。 - 由于 STEP 不带建模历史,某些边会被 OCCT 判断为不适合倒角;失败时程序会提示并尝试恢复编辑前状态。 - `Edge目标长度` + `修改Edge长度`: - 先切换到 `Edge` 选择模式并选择一条Edge,或从 `可编辑对象` 中点击Edge长度相关行进入。 - 选中Edge后,`Edge目标长度` 会自动填入当前Edge长度。 - 右侧Edge长度基准下拉框可以选择 `自动`、`中心`、`固定起点` 或 `固定终点`。 - 把它改成目标长度后点击 `修改Edge长度`。 + - 如果选中的是直线Edge,也可以在当前选中对象表中直接修改 `Edge起点坐标`、`Edge中心坐标` 或 `Edge终点坐标`,格式是 `X, Y, Z`,然后点击该行的 `可修改` 按钮。 + - 修改 `Edge中心坐标` 会保持这条直线Edge长度不变,两个端点一起移动;修改起点或终点则只移动对应端点。 - 对 `assets/models/cube_10mm.step` 这类全平面多面体,直线Edge会优先只移动被选Edge的端点并重建周边平面。 - 这时正方体不会默认变成长方体;相邻面会跟着被移动的边自然变成梯形,必要时非共面的面会拆成三角面。 - 如果确认窗口里的策略是 `local-edge-only-deform`,说明走的就是“只变这条边”的局部形变路径。 + - 如果确认窗口里的策略是 `local-edge-endpoint-deform`,说明走的是“移动起点/终点坐标”的局部形变路径。 + - 如果确认窗口里的策略是 `local-edge-center-deform`,说明走的是“移动整条Edge中心坐标”的局部形变路径。 - 如果模型不是简单全平面多面体,直线Edge会继续尝试端面推拉后备路径,并计算需要移动的端面Face和推拉距离。 - 圆形/圆弧Edge会优先尝试复用相邻圆柱面的孔/槽或凸台直径编辑,并在确认窗口显示换算出来的目标直径。 - 如果圆边没有可复用圆柱面,但能读取圆心和轴线,会走 `围绕圆边轴线径向缩放` 后备策略,尽量不改变轴向厚度/高度;执行后部分解析几何可能变成 B-spline。 @@ -653,7 +742,7 @@ python scripts\verify_edge_length_resize.py - `导出差异报告` 会把当前选中的历史记录导出为 `.txt`,里面包含操作详情、拓扑变化、几何变化和热力图统计。 - `导出编辑历史` 会把当前会话中的本软件编辑记录导出为 `.json`,用于留档和后续复盘;当前格式是 `step-editor-operation-history-v2`,包含机器可读的动作名、目标和参数。 - 当前热力图是“编辑后模型顶点到编辑前模型表面”的距离估算,不是完整 CAD 公差报告。 - - 点击某条历史记录时,程序也会尝试重新高亮当时编辑的目标Face,并用青色小点标出当时的拾取位置。 + - 点击某条历史记录时,程序也会尝试重新高亮当时编辑的目标Face,并在详情里显示当时的拾取位置;3D 视图里不会额外画拾取点小球。 - 由于布尔编辑后拓扑 ID 可能重新分配,历史里的Face ID只能作为定位参考,不能视为稳定的 CAD 建模历史 ID。 - 撤销后对应记录会从列表里退回。 - 重做后对应记录会重新显示。 @@ -799,7 +888,7 @@ git diff --check - STEP 后台加载:首次先显示粗略网格,再异步切到精细显示,并可隐藏同域内部边。 - 选择:支持零件、Solid、Face、Edge、特征。鼠标悬停红色预高亮,选中黄色高亮。 - 同域面:Face/特征会尽量把共面或同轴同半径的连续碎面合成逻辑区域。 -- 编辑:平面推拉、孔径、槽/半孔宽度、盲孔/盲槽深度、凸台直径、已有圆角半径当前版本修改、Edge圆角、Edge倒角、薄壁厚度、修改Edge长度、零件/Solid 平移和旋转。 +- 编辑:平面推拉和目标法向位置、孔径/半径、槽/半孔宽度、深度、圆弧长度、圆弧角度、开口角度、槽孔总长度和中心距、盲孔/盲槽深度、凸台直径/半径/高度/轴心坐标、普通完整圆柱高度兜底修改、已有圆角半径/圆弧长度当前版本修改、圆锥半角、环面直径、Edge圆角、Edge倒角、圆Edge半径/直径、薄壁厚度、修改Edge长度、直线Edge起点/中心/终点坐标、零件/Solid 平移和旋转。Face 面积当前作为只读属性展示,不再伪装成直接可修改参数。 - 恢复:编辑前快照、后台执行、失败回滚、撤销/重做、差异预览、热力图和差异报告。 - 导出:完整模型、零件、Solid、Face、特征区域和 Edge,导出前做基础质量检查。 @@ -810,9 +899,10 @@ git diff --check 当前策略顺序: 1. `local-edge-only-deform`:直线Edge位于简单全平面多面体时,优先只移动被选Edge的端点并重建所属Solid/零件。对立方体测试模型,默认固定起点移动终点;相邻面会自然变成梯形,必要时非共面四边面拆三角面,而不是把整个正方体缩放成长方体。 -2. `move-edge-end-plane-by-push-pull`:如果不适合局部形变,尝试找端点附近、方向匹配的平面端面,把改边长转成端面推拉。 -3. `resize-adjacent-cylinder-from-circular-edge-length`:圆形/圆弧Edge尝试复用相邻圆柱孔/槽或凸台直径编辑。 -4. `scale-owning-shape-from-edge`:圆边、椭圆边和可采样为平面的非直线Edge会优先用 `radial-affine` 围绕曲线平面法向做径向缩放;其他无明确局部路径的Edge才按Edge轴向或整体做几何缩放 fallback,风险高,会影响其它尺寸。 +2. `local-edge-endpoint-deform`:直线Edge起点/终点坐标直接修改,同样只在简单全平面多面体上移动单个端点并重建周边平面。 +3. `move-edge-end-plane-by-push-pull`:如果不适合局部形变,尝试找端点附近、方向匹配的平面端面,把改边长转成端面推拉。 +4. `resize-adjacent-cylinder-from-circular-edge-length`:圆形/圆弧Edge尝试复用相邻圆柱孔/槽或凸台直径编辑。 +5. `scale-owning-shape-from-edge`:圆边、椭圆边和可采样为平面的非直线Edge会优先用 `radial-affine` 围绕曲线平面法向做径向缩放;其他无明确局部路径的Edge才按Edge轴向或整体做几何缩放 fallback,风险高,会影响其它尺寸。 确认窗口和操作历史里要尽量记录策略、目标范围、目标Edge长度、基准、移动端点、重建面数和风险,方便用户判断结果是否符合预期。 diff --git a/step_editor/__init__.py b/step_editor/__init__.py index 351d2bc..e9950f4 100644 --- a/step_editor/__init__.py +++ b/step_editor/__init__.py @@ -1,6 +1,13 @@ from __future__ import annotations -from .app import StepEditorWindow, main from .model import StepModel __all__ = ["StepEditorWindow", "StepModel", "main"] + + +def __getattr__(name: str): + if name in {"StepEditorWindow", "main"}: + from .app import StepEditorWindow, main + + return {"StepEditorWindow": StepEditorWindow, "main": main}[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/step_editor/app.py b/step_editor/app.py index a67b8b4..46ea1b9 100644 --- a/step_editor/app.py +++ b/step_editor/app.py @@ -103,11 +103,19 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.hover_face_actor = None self.hover_edge_actor = None self.hover_signature: tuple[str, int] | None = None - self.hover_interval_ms = 85 - self.hover_move_threshold_px = 5 + self.hover_interval_ms = 140 + self.hover_move_threshold_px = 8 self.pending_hover_position: tuple[int, int] | None = None self.last_hover_pick_position: tuple[int, int] | None = None + self.camera_interaction_active = False + self.edge_actor_visible_before_interaction = True + self.model_interpolation_before_interaction: int | None = None self.pointer_button_down = False + self.left_button_press_position: tuple[int, int] | None = None + self.left_button_dragged = False + self.left_click_drag_threshold_px = 6 + self.skip_next_vtk_left_press = False + self.skip_next_vtk_left_release = False self.pick_marker_actor = None self.edit_preview_actor = None self.edit_preview_actors: list[object] = [] @@ -143,6 +151,11 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.cylinder_candidate_cache: list[dict[str, object]] = [] self.cylinder_candidates_loaded = False self.operation_in_progress = False + self.edit_busy_timer = QTimer(self) + self.edit_busy_timer.timeout.connect(self._pulse_edit_busy_status) + self.edit_busy_base_text = "" + self.edit_busy_started_at: datetime | None = None + self.edit_busy_phase = 0 self.edit_thread: QThread | None = None self.edit_worker: EditWorker | None = None self.pending_edit_context: dict[str, object] | None = None @@ -158,6 +171,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.load_refine_worker: LoadWorker | None = None self.pending_load_path: Path | None = None self.initial_load_deflection = 2.0 + self.edit_result_deflection = 1.6 self.last_id_kind = "Face" self.property_editor_updating = False self.property_editor_specs: list[dict[str, object]] = [] @@ -394,23 +408,76 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf font-weight: 500; } QPushButton#propertyRowEditButton { - background: #fff3d9; - border: 1px solid #d97706; + background: #ea580c; + border: 1px solid #c2410c; border-radius: 5px; - color: #92400e; + color: #ffffff; font-weight: 700; min-height: 18px; padding: 1px 6px; } QPushButton#propertyRowEditButton:hover { - background: #ffe6b8; - border-color: #b45309; + background: #f97316; + border-color: #9a3412; + } + QPushButton#propertyRowEditButton:pressed { + background: #9a3412; + border-color: #7c2d12; + color: #ffffff; + padding-top: 2px; + padding-left: 7px; + } + QPushButton#propertyRowEditButton[changed="true"] { + background: #dcfce7; + border: 1px solid #16a34a; + color: #14532d; + } + QPushButton#propertyRowEditButton[changed="true"]:hover { + background: #bbf7d0; + border-color: #15803d; } QPushButton#propertyRowEditButton:disabled { background: #eef2f6; border: 1px dashed #bcc7d4; color: #8f99a8; } + QLineEdit#propertyTargetEditor { + background: #fff7ed; + border: 2px solid #f97316; + border-radius: 5px; + color: #111827; + min-height: 18px; + padding: 0px 5px; + selection-background-color: #bfdbfe; + } + QLineEdit#propertyTargetEditor:hover { + background: #ffedd5; + border-color: #ea580c; + } + QLineEdit#propertyTargetEditor:focus { + background: #ffffff; + border: 2px solid #2563eb; + padding: 0px 4px; + } + QPushButton#propertyExpandBar { + background: #f8fafc; + border: 1px solid #d7dee8; + border-radius: 4px; + color: #475569; + font-weight: 600; + min-height: 14px; + max-height: 20px; + padding: 1px 6px; + } + QPushButton#propertyExpandBar:hover { + background: #eef6ff; + border-color: #9dbbe0; + color: #1e3a8a; + } + QPushButton#propertyExpandBar:pressed { + background: #dbeafe; + padding-top: 2px; + } QLineEdit:disabled, QComboBox:disabled, QPlainTextEdit:disabled { @@ -743,14 +810,24 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.property_table.itemChanged.connect(self._on_property_table_item_changed) object_edit_layout.addWidget(self.property_table) self.property_expand_button = QPushButton("展开全部属性") + self.property_expand_button.setObjectName("propertyExpandBar") help_tip(self.property_expand_button, "当前选中对象属性较多时,默认只显示前 6 行;点击这里展开或收起属性表。") self.property_expand_button.clicked.connect(self.toggle_property_table_expanded) self.property_expand_button.setVisible(False) + self.property_expand_button.setMaximumHeight(22) + self.property_expand_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) object_edit_layout.addWidget(self.property_expand_button) + property_action_row = QHBoxLayout() + property_action_row.setContentsMargins(0, 0, 0, 0) self.apply_property_button = QPushButton("参数化建模") help_tip(self.apply_property_button, "按当前属性表里修改过的目标值执行参数化建模;一次只执行一个几何修改,成功后可撤销。") self.apply_property_button.clicked.connect(self.apply_current_property_edit) - object_edit_layout.addWidget(self.apply_property_button) + self.quick_export_all_button = QPushButton("导出当前完整STEP") + help_tip(self.quick_export_all_button, "把当前编辑后的完整模型导出为 STEP 文件。导出前会做基础质量检查。") + self.quick_export_all_button.clicked.connect(self.export_all) + property_action_row.addWidget(self.apply_property_button) + property_action_row.addWidget(self.quick_export_all_button) + object_edit_layout.addLayout(property_action_row) edit_box = QGroupBox(panel) edit_box.setObjectName("editSection") @@ -770,6 +847,18 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.hole_diameter_input = QLineEdit("") help_tip(self.hole_diameter_input, "圆柱孔/槽的目标直径。选中候选后会自动填一个参考值,可以手动改。") edit_layout.addWidget(self.hole_diameter_input, 2, 1) + self.hole_center_x_input = QLineEdit("", edit_box) + self.hole_center_y_input = QLineEdit("", edit_box) + self.hole_center_z_input = QLineEdit("", edit_box) + for hole_center_input in ( + self.hole_center_x_input, + self.hole_center_y_input, + self.hole_center_z_input, + ): + hole_center_input.setVisible(False) + help_tip(self.hole_center_x_input, "圆柱孔轴心目标 X 坐标。当前选中对象表会使用这个隐藏输入执行孔位置修改。") + help_tip(self.hole_center_y_input, "圆柱孔轴心目标 Y 坐标。当前选中对象表会使用这个隐藏输入执行孔位置修改。") + help_tip(self.hole_center_z_input, "圆柱孔轴心目标 Z 坐标。当前选中对象表会使用这个隐藏输入执行孔位置修改。") self.resize_button = QPushButton("调整圆柱孔径") help_tip(self.resize_button, "修改孔或圆柱槽的直径。扩大时切削,缩小时会先补料再重切。") self.resize_button.clicked.connect(self.resize_hole) @@ -784,21 +873,75 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.resize_slot_button.clicked.connect(self.resize_slot_width) edit_layout.addWidget(self.resize_slot_button, 5, 0, 1, 2) - edit_layout.addWidget(QLabel("凸台直径"), 6, 0) + edit_layout.addWidget(QLabel("槽/半孔深度"), 6, 0) + self.slot_depth_input = QLineEdit("") + help_tip(self.slot_depth_input, "槽或半孔的目标凹入深度。程序会按当前圆弧角度换算成对应圆柱直径来执行。") + edit_layout.addWidget(self.slot_depth_input, 6, 1) + self.resize_slot_depth_button = QPushButton("调整槽/半孔深度") + help_tip(self.resize_slot_depth_button, "修改已识别槽/半孔候选的凹入深度。当前版本是几何近似,失败会回滚。") + self.resize_slot_depth_button.clicked.connect(self.resize_slot_depth) + edit_layout.addWidget(self.resize_slot_depth_button, 7, 0, 1, 2) + self.slot_arc_length_input = QLineEdit("", edit_box) + self.slot_arc_length_input.setVisible(False) + help_tip(self.slot_arc_length_input, "槽或半孔的目标圆弧长度。当前选中对象表会使用这个隐藏输入执行圆弧长度修改。") + self.slot_angular_span_input = QLineEdit("", edit_box) + self.slot_angular_span_input.setVisible(False) + help_tip(self.slot_angular_span_input, "槽或半孔的目标圆弧角度。当前选中对象表会使用这个隐藏输入执行角度修改。") + self.slot_pair_face_input = QLineEdit("", edit_box) + self.slot_pair_face_input.setVisible(False) + help_tip(self.slot_pair_face_input, "长圆槽/槽孔的另一个半圆端 Face ID。自动配对不准时可在当前选中对象表里手动设置。") + self.slot_total_length_input = QLineEdit("", edit_box) + self.slot_total_length_input.setVisible(False) + help_tip(self.slot_total_length_input, "长圆槽/槽孔的目标总长度。只有识别到成对半圆槽端时才会启用。") + self.slot_center_x_input = QLineEdit("", edit_box) + self.slot_center_x_input.setVisible(False) + self.slot_center_y_input = QLineEdit("", edit_box) + self.slot_center_y_input.setVisible(False) + self.slot_center_z_input = QLineEdit("", edit_box) + self.slot_center_z_input.setVisible(False) + help_tip(self.slot_center_x_input, "槽/半孔目标轴心坐标 X。当前选中对象表会使用这个隐藏输入执行轴心移动。") + help_tip(self.slot_center_y_input, "槽/半孔目标轴心坐标 Y。当前选中对象表会使用这个隐藏输入执行轴心移动。") + help_tip(self.slot_center_z_input, "槽/半孔目标轴心坐标 Z。当前选中对象表会使用这个隐藏输入执行轴心移动。") + + edit_layout.addWidget(QLabel("凸台直径"), 8, 0) self.boss_diameter_input = QLineEdit("") help_tip(self.boss_diameter_input, "圆柱凸台的目标直径。选中凸台候选后会自动填一个参考值。") - edit_layout.addWidget(self.boss_diameter_input, 6, 1) + edit_layout.addWidget(self.boss_diameter_input, 8, 1) + self.boss_height_input = QLineEdit("", edit_box) + self.boss_height_input.setVisible(False) + help_tip(self.boss_height_input, "圆柱凸台的目标高度。当前选中对象表会使用这个隐藏输入执行高度修改。") + self.boss_center_x_input = QLineEdit("", edit_box) + self.boss_center_x_input.setVisible(False) + self.boss_center_y_input = QLineEdit("", edit_box) + self.boss_center_y_input.setVisible(False) + self.boss_center_z_input = QLineEdit("", edit_box) + self.boss_center_z_input.setVisible(False) + help_tip(self.boss_center_x_input, "圆柱凸台目标轴心坐标 X。当前选中对象表会使用这个隐藏输入执行轴心移动。") + help_tip(self.boss_center_y_input, "圆柱凸台目标轴心坐标 Y。当前选中对象表会使用这个隐藏输入执行轴心移动。") + help_tip(self.boss_center_z_input, "圆柱凸台目标轴心坐标 Z。当前选中对象表会使用这个隐藏输入执行轴心移动。") + self.cone_reference_radius_input = QLineEdit("", edit_box) + self.cone_reference_radius_input.setVisible(False) + help_tip(self.cone_reference_radius_input, "圆锥面的目标参考半径。当前选中对象表会使用这个隐藏输入执行圆锥面修改。") + self.sphere_radius_input = QLineEdit("", edit_box) + self.sphere_radius_input.setVisible(False) + help_tip(self.sphere_radius_input, "球面的目标半径。当前选中对象表会使用这个隐藏输入执行球面修改。") + self.torus_radius_input = QLineEdit("", edit_box) + self.torus_radius_input.setVisible(False) + help_tip(self.torus_radius_input, "环面的目标半径。当前选中对象表会使用这个隐藏输入执行环面修改。") + self.face_area_input = QLineEdit("", edit_box) + self.face_area_input.setVisible(False) + help_tip(self.face_area_input, "复杂曲面的目标Face面积。当前选中对象表会使用这个隐藏输入执行兜底面积缩放。") self.resize_boss_button = QPushButton("调整圆柱凸台直径") help_tip(self.resize_boss_button, "修改完整圆柱凸台直径。变大会加料,变小会重建凸台区域。") self.resize_boss_button.clicked.connect(self.resize_boss) - edit_layout.addWidget(self.resize_boss_button, 7, 0, 1, 2) + edit_layout.addWidget(self.resize_boss_button, 9, 0, 1, 2) self.suppress_button = QPushButton("封堵圆柱孔") help_tip(self.suppress_button, "用补料体填住完整圆柱孔。适合通孔/盲孔,不适合半孔或槽。") self.suppress_button.clicked.connect(self.suppress_hole) - edit_layout.addWidget(self.suppress_button, 8, 0, 1, 2) + edit_layout.addWidget(self.suppress_button, 10, 0, 1, 2) - edit_layout.addWidget(QLabel("孔深度"), 9, 0) + edit_layout.addWidget(QLabel("孔深度"), 11, 0) depth_inputs = QWidget() depth_layout = QHBoxLayout(depth_inputs) depth_layout.setContentsMargins(0, 0, 0, 0) @@ -810,41 +953,72 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.hole_bottom_face_input.textChanged.connect(lambda _text: self._update_action_states()) depth_layout.addWidget(self.hole_depth_input, stretch=2) depth_layout.addWidget(self.hole_bottom_face_input, stretch=1) - edit_layout.addWidget(depth_inputs, 9, 1) - self.resize_depth_button = QPushButton("调整盲孔深度") + edit_layout.addWidget(depth_inputs, 11, 1) + self.resize_depth_button = QPushButton("调整盲孔/盲槽深度") help_tip(self.resize_depth_button, "加深或变浅盲孔/盲槽。需要识别到底面,或手动填写底面 Face ID。") self.resize_depth_button.clicked.connect(self.resize_hole_depth) - edit_layout.addWidget(self.resize_depth_button, 10, 0, 1, 2) + edit_layout.addWidget(self.resize_depth_button, 12, 0, 1, 2) - edit_layout.addWidget(QLabel("圆角半径"), 11, 0) + edit_layout.addWidget(QLabel("圆角半径"), 13, 0) self.edge_fillet_radius_input = QLineEdit("") help_tip(self.edge_fillet_radius_input, "新圆角或已有圆角的目标半径。选中Edge/圆角候选后会自动填参考值。") - edit_layout.addWidget(self.edge_fillet_radius_input, 11, 1) + edit_layout.addWidget(self.edge_fillet_radius_input, 13, 1) self.fillet_edge_button = QPushButton("给Edge添加圆角") help_tip(self.fillet_edge_button, "给当前直线Edge新增圆角。不是修改已有圆角;已有圆角请用下面那个按钮。") self.fillet_edge_button.clicked.connect(self.fillet_edge) - edit_layout.addWidget(self.fillet_edge_button, 12, 0, 1, 2) + edit_layout.addWidget(self.fillet_edge_button, 14, 0, 1, 2) self.resize_existing_fillet_button = QPushButton("修改已有圆角半径") help_tip(self.resize_existing_fillet_button, "尝试修改已识别圆角面的半径。会先移除原圆角再重建,复杂圆角可能失败并回滚。") self.resize_existing_fillet_button.clicked.connect(self.resize_existing_fillet) - edit_layout.addWidget(self.resize_existing_fillet_button, 13, 0, 1, 2) + edit_layout.addWidget(self.resize_existing_fillet_button, 15, 0, 1, 2) - edit_layout.addWidget(QLabel("倒角距离"), 14, 0) + edit_layout.addWidget(QLabel("倒角距离"), 16, 0) self.edge_chamfer_distance_input = QLineEdit("") help_tip(self.edge_chamfer_distance_input, "给Edge添加倒角时使用的距离。选中直线Edge后会填一个较小参考值。") - edit_layout.addWidget(self.edge_chamfer_distance_input, 14, 1) + self.edge_chamfer_distance1_input = QLineEdit("", edit_box) + self.edge_chamfer_distance2_input = QLineEdit("", edit_box) + self.edge_chamfer_angle_distance_input = QLineEdit("", edit_box) + self.edge_chamfer_angle_degrees_input = QLineEdit("", edit_box) + self.edge_chamfer_reference_face_input = QLineEdit("", edit_box) + self.edge_chamfer_distance1_input.setVisible(False) + self.edge_chamfer_distance2_input.setVisible(False) + self.edge_chamfer_angle_distance_input.setVisible(False) + self.edge_chamfer_angle_degrees_input.setVisible(False) + self.edge_chamfer_reference_face_input.setVisible(False) + edit_layout.addWidget(self.edge_chamfer_distance_input, 16, 1) self.chamfer_edge_button = QPushButton("给Edge添加倒角") help_tip(self.chamfer_edge_button, "给当前直线Edge新增对称倒角。会先预览,再后台执行,失败会回滚。") self.chamfer_edge_button.clicked.connect(self.chamfer_edge) - edit_layout.addWidget(self.chamfer_edge_button, 15, 0, 1, 2) + edit_layout.addWidget(self.chamfer_edge_button, 17, 0, 1, 2) - edit_layout.addWidget(QLabel("Edge目标长度"), 16, 0) + edit_layout.addWidget(QLabel("Edge目标长度"), 18, 0) edge_length_inputs = QWidget() edge_length_layout = QHBoxLayout(edge_length_inputs) edge_length_layout.setContentsMargins(0, 0, 0, 0) self.edge_target_length_input = QLineEdit("") help_tip(self.edge_target_length_input, "当前Edge的目标长度。选中Edge后会自动填当前长度,改成新长度再执行。") + self.edge_start_x_input = QLineEdit("", edit_box) + self.edge_start_y_input = QLineEdit("", edit_box) + self.edge_start_z_input = QLineEdit("", edit_box) + self.edge_center_x_input = QLineEdit("", edit_box) + self.edge_center_y_input = QLineEdit("", edit_box) + self.edge_center_z_input = QLineEdit("", edit_box) + self.edge_end_x_input = QLineEdit("", edit_box) + self.edge_end_y_input = QLineEdit("", edit_box) + self.edge_end_z_input = QLineEdit("", edit_box) + for endpoint_input in ( + self.edge_start_x_input, + self.edge_start_y_input, + self.edge_start_z_input, + self.edge_center_x_input, + self.edge_center_y_input, + self.edge_center_z_input, + self.edge_end_x_input, + self.edge_end_y_input, + self.edge_end_z_input, + ): + endpoint_input.setVisible(False) self.edge_length_anchor_combo = NoWheelComboBox() self.edge_length_anchor_combo.addItem("自动", "auto") self.edge_length_anchor_combo.addItem("中心", "center") @@ -858,31 +1032,31 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf ) edge_length_layout.addWidget(self.edge_target_length_input, stretch=2) edge_length_layout.addWidget(self.edge_length_anchor_combo, stretch=1) - edit_layout.addWidget(edge_length_inputs, 16, 1) + edit_layout.addWidget(edge_length_inputs, 18, 1) self.resize_edge_length_button = QPushButton("修改Edge长度") help_tip( self.resize_edge_length_button, "修改当前Edge长度。直线Edge优先局部形变;圆、椭圆和平面曲线会优先用局部/径向策略,必要时再用后备缩放。", ) self.resize_edge_length_button.clicked.connect(self.resize_any_edge_length) - edit_layout.addWidget(self.resize_edge_length_button, 17, 0, 1, 2) + edit_layout.addWidget(self.resize_edge_length_button, 19, 0, 1, 2) - edit_layout.addWidget(QLabel("薄壁厚度"), 18, 0) + edit_layout.addWidget(QLabel("薄壁厚度"), 20, 0) self.shell_thickness_input = QLineEdit("") help_tip( self.shell_thickness_input, "薄壁/壳体局部区域的目标厚度。选中有相对平面的平面候选后会自动填一个参考值。", ) - edit_layout.addWidget(self.shell_thickness_input, 18, 1) + edit_layout.addWidget(self.shell_thickness_input, 20, 1) self.resize_shell_thickness_button = QPushButton("调整薄壁厚度") help_tip( self.resize_shell_thickness_button, "移动当前平面区域来达到目标厚度。当前版本基于相对平面估算,执行前会预览并可回滚。", ) self.resize_shell_thickness_button.clicked.connect(self.resize_shell_thickness) - edit_layout.addWidget(self.resize_shell_thickness_button, 19, 0, 1, 2) + edit_layout.addWidget(self.resize_shell_thickness_button, 21, 0, 1, 2) - edit_layout.addWidget(QLabel("平移 X/Y/Z"), 20, 0) + edit_layout.addWidget(QLabel("平移 X/Y/Z"), 22, 0) translate_inputs = QWidget() translate_layout = QHBoxLayout(translate_inputs) translate_layout.setContentsMargins(0, 0, 0, 0) @@ -898,17 +1072,17 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf translate_layout.addWidget(self.translate_x_input) translate_layout.addWidget(self.translate_y_input) translate_layout.addWidget(self.translate_z_input) - edit_layout.addWidget(translate_inputs, 20, 1) + edit_layout.addWidget(translate_inputs, 22, 1) self.translate_part_button = QPushButton("平移选中零件") help_tip(self.translate_part_button, "按上面的 X/Y/Z 距离移动当前零件。会写入历史,可撤销。") self.translate_part_button.clicked.connect(self.translate_selected_part) self.translate_solid_button = QPushButton("平移选中Solid") help_tip(self.translate_solid_button, "按上面的 X/Y/Z 距离移动当前Solid。单Solid零件中相当于移动整个零件。") self.translate_solid_button.clicked.connect(self.translate_selected_solid) - edit_layout.addWidget(self.translate_part_button, 21, 0, 1, 2) - edit_layout.addWidget(self.translate_solid_button, 22, 0, 1, 2) + edit_layout.addWidget(self.translate_part_button, 23, 0, 1, 2) + edit_layout.addWidget(self.translate_solid_button, 24, 0, 1, 2) - edit_layout.addWidget(QLabel("旋转轴/角度"), 23, 0) + edit_layout.addWidget(QLabel("旋转轴/角度"), 25, 0) rotate_inputs = QWidget() rotate_layout = QHBoxLayout(rotate_inputs) rotate_layout.setContentsMargins(0, 0, 0, 0) @@ -921,15 +1095,27 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf help_tip(self.rotate_angle_input, "旋转角度,单位是度。正负号决定旋转方向。") rotate_layout.addWidget(self.rotate_axis_combo) rotate_layout.addWidget(self.rotate_angle_input) - edit_layout.addWidget(rotate_inputs, 23, 1) + edit_layout.addWidget(rotate_inputs, 25, 1) + self.scale_target_diagonal_input = QLineEdit("", edit_box) + self.scale_target_diagonal_input.setVisible(False) + help_tip(self.scale_target_diagonal_input, "零件或Solid的目标包围盒对角线。当前选中对象表会使用这个隐藏输入执行整体等比缩放。") + self.scale_x_size_input = QLineEdit("", edit_box) + self.scale_x_size_input.setVisible(False) + help_tip(self.scale_x_size_input, "零件或Solid的目标 X 向包围盒尺寸。") + self.scale_y_size_input = QLineEdit("", edit_box) + self.scale_y_size_input.setVisible(False) + help_tip(self.scale_y_size_input, "零件或Solid的目标 Y 向包围盒尺寸。") + self.scale_z_size_input = QLineEdit("", edit_box) + self.scale_z_size_input.setVisible(False) + help_tip(self.scale_z_size_input, "零件或Solid的目标 Z 向包围盒尺寸。") self.rotate_part_button = QPushButton("旋转选中零件") help_tip(self.rotate_part_button, "绕所选轴旋转当前零件。会写入历史,可撤销。") self.rotate_part_button.clicked.connect(self.rotate_selected_part) self.rotate_solid_button = QPushButton("旋转选中Solid") help_tip(self.rotate_solid_button, "绕所选轴旋转当前Solid。单Solid零件中相当于旋转整个零件。") self.rotate_solid_button.clicked.connect(self.rotate_selected_solid) - edit_layout.addWidget(self.rotate_part_button, 24, 0, 1, 2) - edit_layout.addWidget(self.rotate_solid_button, 25, 0, 1, 2) + edit_layout.addWidget(self.rotate_part_button, 26, 0, 1, 2) + edit_layout.addWidget(self.rotate_solid_button, 27, 0, 1, 2) panel_layout.addWidget(self.object_edit_box) if ENABLE_EXPORT_PANEL: diff --git a/step_editor/features.py b/step_editor/features.py index 4c908c7..6aeb716 100644 --- a/step_editor/features.py +++ b/step_editor/features.py @@ -77,7 +77,12 @@ class FeatureMixin: diameter_count = 0 slot_width_count = 0 + slot_depth_count = 0 + slot_arc_length_count = 0 + slot_angular_span_count = 0 boss_diameter_count = 0 + boss_height_count = 0 + boss_axis_count = 0 depth_count = 0 suppress_count = 0 existing_fillet_count = 0 @@ -85,6 +90,9 @@ class FeatureMixin: suppress_limit = max(2, min(per_type_limit, limit // 12)) existing_fillet_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)) + slot_angular_span_limit = max(2, min(per_type_limit, limit // 10)) cylinder_scan_limit = max(per_type_limit * 4, 24) for item in self.cylindrical_feature_candidates( limit=cylinder_scan_limit, @@ -148,13 +156,18 @@ class FeatureMixin: ) diameter_count += 1 if ( - slot_width_count < slot_width_limit + ( + slot_width_count < slot_width_limit + or slot_depth_count < slot_depth_limit + or slot_arc_length_count < slot_arc_length_limit + or slot_angular_span_count < slot_angular_span_limit + ) and feature_guess == "hole/groove candidate" and float(item.get("angular_span", 0.0)) < math.tau * 0.92 ): feature = self.feature_info(int(item["face_id"])) slot_width = feature.get("slot_chord_width_estimate") - if isinstance(slot_width, (int, float)) and float(slot_width) > 0: + if slot_width_count < slot_width_limit and isinstance(slot_width, (int, float)) and float(slot_width) > 0: candidates.append( { "operation_key": "resize_slot_width", @@ -178,6 +191,89 @@ class FeatureMixin: } ) slot_width_count += 1 + slot_depth = feature.get("slot_sagitta_depth_estimate") + if slot_depth_count < slot_depth_limit and isinstance(slot_depth, (int, float)) and float(slot_depth) > 0: + candidates.append( + { + "operation_key": "resize_slot_depth", + "operation": "调整槽/半孔深度", + "target_kind": "face", + "target_id": item["face_id"], + "face_id": item["face_id"], + "part_id": item["part_id"], + "solid_id": item["solid_id"], + "surface": "cylinder", + "feature_guess": feature_guess, + "current_value": float(slot_depth), + "current_value_label": "slot_depth", + "status": item["resize_status"], + "risk": item["resize_risk"], + "confidence": item["confidence"], + "note": ( + "这是槽/半孔候选;点击后会选中该 face,并把槽/半孔深度输入框预填为参考目标值。" + "执行时会把槽深换算为圆柱直径后重建。" + ), + } + ) + slot_depth_count += 1 + slot_arc_length = feature.get("slot_arc_length_estimate") + if ( + slot_arc_length_count < slot_arc_length_limit + and isinstance(slot_arc_length, (int, float)) + and float(slot_arc_length) > 0 + ): + candidates.append( + { + "operation_key": "resize_slot_arc_length", + "operation": "调整槽/半孔圆弧长度", + "target_kind": "face", + "target_id": item["face_id"], + "face_id": item["face_id"], + "part_id": item["part_id"], + "solid_id": item["solid_id"], + "surface": "cylinder", + "feature_guess": feature_guess, + "current_value": float(slot_arc_length), + "current_value_label": "slot_arc_length", + "status": item["resize_status"], + "risk": item["resize_risk"], + "confidence": item["confidence"], + "note": ( + "这是槽/半孔候选;点击后会选中该 face,可在当前选中对象里修改槽/半孔圆弧长度。" + "执行时会把圆弧长度换算为圆柱直径后重建。" + ), + } + ) + slot_arc_length_count += 1 + slot_angular_span = feature.get("slot_angular_span", item.get("angular_span")) + if ( + slot_angular_span_count < slot_angular_span_limit + and isinstance(slot_angular_span, (int, float)) + and 1e-6 < float(slot_angular_span) < math.tau * 0.92 + ): + candidates.append( + { + "operation_key": "resize_slot_angular_span", + "operation": "调整槽/半孔圆弧角度", + "target_kind": "face", + "target_id": item["face_id"], + "face_id": item["face_id"], + "part_id": item["part_id"], + "solid_id": item["solid_id"], + "surface": "cylinder", + "feature_guess": feature_guess, + "current_value": math.degrees(float(slot_angular_span)), + "current_value_label": "slot_angle_degrees", + "status": item["resize_status"], + "risk": item["resize_risk"], + "confidence": item["confidence"], + "note": ( + "这是槽/半孔候选;点击后会选中该 face,可在当前选中对象里修改槽/半孔圆弧角度。" + "执行时会保持当前半径并重建局部扇形槽。" + ), + } + ) + slot_angular_span_count += 1 boss_info = _cylinder_boss_resize_readiness(item) if boss_diameter_count < per_type_limit and boss_info["boss_resize_status"] != "blocked": candidates.append( @@ -200,6 +296,81 @@ class FeatureMixin: } ) boss_diameter_count += 1 + if boss_height_count < per_type_limit and boss_info["boss_resize_status"] != "blocked": + feature = self.feature_info(int(item["face_id"])) + height = feature.get("same_domain_height_estimate", item.get("height_estimate")) + cap_face_ids = _int_values(feature.get("feature_start_end_face_ids")) + _int_values( + feature.get("feature_end_end_face_ids") + ) + if isinstance(height, (int, float)) and float(height) > 0 and cap_face_ids: + candidates.append( + { + "operation_key": "resize_boss_height", + "operation": "调整圆柱凸台高度", + "target_kind": "face", + "target_id": item["face_id"], + "face_id": item["face_id"], + "part_id": item["part_id"], + "solid_id": item["solid_id"], + "surface": "cylinder", + "feature_guess": item["feature_guess"], + "current_value": float(height), + "current_value_label": "height", + "status": "caution", + "risk": "medium" if item["confidence"] == "high" else "high", + "confidence": item["confidence"], + "note": ( + "这是完整圆柱凸台候选;点击后会选中该 face,可在当前选中对象里修改凸台高度。" + "执行时会推拉识别到的端盖 Face。" + ), + } + ) + boss_height_count += 1 + if boss_axis_count < per_type_limit and boss_info["boss_resize_status"] != "blocked": + feature = self.feature_info(int(item["face_id"])) + axis_point = _tuple_or_none(item.get("axis_point")) + axis_direction = _tuple_or_none(item.get("axis")) + axis_range = feature.get("same_domain_v_range") or item.get("v_range") + current_axis_center = None + if ( + axis_point is not None + and axis_direction is not None + and isinstance(axis_range, (list, tuple)) + and len(axis_range) >= 2 + ): + v_min = _float_or_none(axis_range[0]) + v_max = _float_or_none(axis_range[1]) + if v_min is not None and v_max is not None: + v_mid = (v_min + v_max) * 0.5 + current_axis_center = ( + axis_point[0] + axis_direction[0] * v_mid, + axis_point[1] + axis_direction[1] * v_mid, + axis_point[2] + axis_direction[2] * v_mid, + ) + if current_axis_center is not None: + candidates.append( + { + "operation_key": "move_boss_axis", + "operation": "修改圆柱凸台轴心坐标", + "target_kind": "face", + "target_id": item["face_id"], + "face_id": item["face_id"], + "part_id": item["part_id"], + "solid_id": item["solid_id"], + "surface": "cylinder", + "feature_guess": item["feature_guess"], + "current_value": current_axis_center, + "current_value_label": "axis_center", + "status": "caution", + "risk": "medium" if item["confidence"] == "high" else "high", + "confidence": item["confidence"], + "note": ( + "这是完整圆柱凸台候选;点击后会选中该 face,可在当前选中对象里修改凸台轴心坐标。" + "执行时会移除旧凸台包络,再按同直径在目标轴心补出凸台。" + ), + } + ) + boss_axis_count += 1 suppress_info = _cylinder_suppress_readiness(item) if suppress_count < suppress_limit and suppress_info["suppress_status"] != "blocked": candidates.append( @@ -238,7 +409,7 @@ class FeatureMixin: candidates.append( { "operation_key": "resize_depth", - "operation": "调整盲孔深度", + "operation": "调整盲孔/盲槽深度", "target_kind": "face", "target_id": item["face_id"], "face_id": item["face_id"], @@ -348,6 +519,7 @@ class FeatureMixin: fillet_edge_count = 0 chamfer_edge_count = 0 edge_length_count = 0 + circle_edge_radius_count = 0 edge_type_limit = max(1, per_type_limit // 3) edge_scan_limit = len(self.edges) if max_scan_edges is None else min(len(self.edges), max(0, int(max_scan_edges))) for edge_id, edge in enumerate(self.edges[:edge_scan_limit]): @@ -357,6 +529,7 @@ class FeatureMixin: fillet_edge_count >= edge_type_limit and chamfer_edge_count >= edge_type_limit and edge_length_count >= edge_type_limit + and circle_edge_radius_count >= edge_type_limit ): break curve = BRepAdaptor_Curve(edge) @@ -432,21 +605,51 @@ class FeatureMixin: } ) edge_length_count += 1 + if is_circle_edge and circle_edge_radius_count < edge_type_limit: + radius = float(curve.Circle().Radius()) + if radius > 1e-9: + candidates.append( + { + "operation_key": "resize_edge_length", + "operation": "修改圆Edge半径", + "target_kind": "edge", + "target_id": edge_id, + "edge_id": edge_id, + "part_id": self.edge_part_ids[edge_id], + "solid_id": solid_id, + "surface": "edge", + "feature_guess": "circular edge radius candidate", + "current_value": radius, + "current_value_label": "radius", + "status": "caution", + "risk": "medium", + "confidence": "pending", + "note": ( + "快速扫描:点击后会选中圆Edge;可在当前选中对象里修改圆Edge半径或直径," + "执行时优先换算相邻圆柱直径。" + ), + } + ) + circle_edge_radius_count += 1 status_order = {"ready": 0, "caution": 1, "blocked": 2} risk_order = {"low": 0, "medium": 1, "high": 2, "blocked": 3} operation_order = { "resize_cylinder": 0, "resize_slot_width": 1, - "resize_boss": 2, - "suppress_cylinder": 3, - "resize_depth": 4, - "inspect_existing_fillet": 5, - "resize_shell_thickness": 6, - "push_pull_plane": 7, - "fillet_edge": 8, - "chamfer_edge": 9, - "resize_edge_length": 10, + "resize_slot_depth": 2, + "resize_slot_arc_length": 3, + "resize_slot_angular_span": 4, + "resize_boss": 5, + "resize_boss_height": 6, + "suppress_cylinder": 7, + "resize_depth": 8, + "inspect_existing_fillet": 9, + "resize_shell_thickness": 10, + "push_pull_plane": 11, + "fillet_edge": 12, + "chamfer_edge": 13, + "resize_edge_length": 14, } candidates.sort( key=lambda item: ( @@ -574,6 +777,1007 @@ class FeatureMixin: **fill_plan, } + def cylindrical_axis_move_plan( + self, + face_id: int, + target_center: tuple[float, float, 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) + feature: dict[str, object] = {} + try: + feature = self.feature_info(face_id) + except Exception: + feature = {} + + blockers: list[str] = [] + warnings: list[str] = [ + "Cylinder axis move fills the current cylindrical hole, then cuts a same-diameter hole on the target axis." + ] + risk = "medium" + + try: + target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2])) + except (TypeError, ValueError, IndexError): + target_center = (0.0, 0.0, 0.0) + blockers.append("Target cylinder axis center must be three numeric coordinates.") + + current_diameter = _float_or_none(info.get("diameter")) + angular_span = _float_or_none(info.get("angular_span")) + feature_guess = str(info.get("feature_guess", "")) + confidence = str(info.get("confidence", "low")) + surf = BRepAdaptor_Surface(self.faces[face_id]) + current_center: tuple[float, float, float] | None = None + axis_direction: tuple[float, float, float] | None = None + axis_range: dict[str, object] = {} + if info.get("surface") != "cylinder" or surf.GetType() != GeomAbs_Cylinder: + blockers.append("Selected Face is not a cylindrical hole/groove face.") + else: + cyl = surf.Cylinder() + axis = cyl.Axis() + axis_direction = _dir_tuple(axis.Direction()) + axis_range = self._cylindrical_axis_range( + face_id, + surf, + _int_values(feature.get("feature_side_face_ids")), + ) + mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5 + current_center = _point_tuple(_point_on_axis(axis.Location(), axis.Direction(), mid_parameter)) + + if current_diameter is None or current_diameter <= 1e-9: + blockers.append("Selected cylindrical Face has no stable diameter.") + if feature_guess != "hole/groove candidate": + blockers.append("Cylinder axis move currently supports recognized hole/groove candidates only.") + if angular_span is None or angular_span < math.tau * 0.92: + blockers.append("Cylinder axis move currently supports near-full cylindrical holes only; partial slots need a sector-aware tool.") + if current_center is None or axis_direction is None: + blockers.append("Could not derive a stable current cylinder axis center.") + + movement = (0.0, 0.0, 0.0) + move_distance = 0.0 + axial_delta = 0.0 + radial_distance = 0.0 + if current_center is not None and axis_direction is not None: + movement = _tuple_sub(target_center, current_center) + move_distance = _vector_length(movement) + axial_delta = _tuple_dot(movement, axis_direction) + radial_movement = _tuple_sub(movement, _tuple_scale(axis_direction, axial_delta)) + radial_distance = _vector_length(radial_movement) + diagonal = max(_shape_diagonal(self.faces[face_id]), current_diameter or 0.0, 1.0) + if move_distance <= max(diagonal * 1e-7, 1e-6): + blockers.append("Target cylinder axis center is almost the same as the current center.") + if current_diameter is not None and current_diameter > 0: + ratio = move_distance / current_diameter + if ratio > 4.0: + risk = _max_risk(risk, "high") + warnings.append("Target axis move is more than four hole diameters; Boolean cut may affect unrelated geometry.") + elif ratio > 1.0: + risk = _max_risk(risk, "high") + warnings.append("Target axis move is larger than one hole diameter; verify nearby walls after editing.") + elif ratio > 0.35: + risk = _max_risk(risk, "medium") + warnings.append("Target axis move is a moderate local relocation.") + if abs(axial_delta) > max(radial_distance * 0.5, (current_diameter or 1.0) * 0.25): + risk = _max_risk(risk, "high") + warnings.append("The target center includes a large movement along the cylinder axis; this may change the opening/bottom relationship.") + if confidence == "low": + risk = _max_risk(risk, "medium") + warnings.append("Hole/groove recognition confidence is low.") + + cutter_plan: dict[str, object] = {} + fill_plan: dict[str, object] = {} + if not blockers and current_diameter is not None: + cutter_plan = self._bounded_cylinder_cutter_plan(face_id, current_diameter, feature) + fill_plan = self._bounded_cylinder_fill_plan(face_id) + start = _tuple_or_none(cutter_plan.get("cutter_start_point")) + axis_point = _tuple_or_none(cutter_plan.get("cutter_axis_point")) + if start is None or axis_point is None: + blockers.append("Could not build the bounded cutter for the moved cylinder axis.") + else: + cutter_plan["target_cutter_start_point"] = _tuple_add(start, movement) + cutter_plan["target_cutter_axis_point"] = _tuple_add(axis_point, movement) + + if blockers: + status = "blocked" + risk = "blocked" + else: + status = "caution" if risk in {"medium", "high"} else "ready" + + message = "; ".join(blockers + warnings) if blockers or warnings else "Cylinder axis move can be attempted." + return { + **cutter_plan, + **fill_plan, + "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"), + "surface": info.get("surface"), + "feature_type": feature.get("feature_type"), + "feature_guess": feature_guess, + "confidence": confidence, + "current_diameter": current_diameter, + "target_diameter": current_diameter, + "current_radius": None if current_diameter is None else current_diameter * 0.5, + "target_radius": None if current_diameter is None else current_diameter * 0.5, + "current_axis_center": current_center, + "target_axis_center": target_center, + "axis_move_vector": movement, + "axis_move_distance": move_distance, + "axis_move_axial_delta": axial_delta, + "axis_move_radial_distance": radial_distance, + "axis": axis_direction, + "angular_span": angular_span, + "same_domain_face_ids": axis_range.get("same_domain_face_ids", ()), + "same_domain_face_count": axis_range.get("same_domain_face_count", 0), + "same_domain_v_range": (axis_range.get("v_min"), axis_range.get("v_max")), + "same_domain_range_source": axis_range.get("range_source", ""), + "resize_strategy": "fill-old-cylinder-and-cut-moved-cylinder", + } + + def cylindrical_slot_resize_plan( + self, + face_id: int, + target_value: float, + mode: str = "width", + pair_face_id: int | None = None, + ) -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + + mode_key = str(mode or "width").strip().lower().replace("-", "_") + mode_aliases = { + "width": "width", + "slot_width": "width", + "chord_width": "width", + "depth": "depth", + "slot_depth": "depth", + "sagitta_depth": "depth", + "arc": "arc_length", + "arc_length": "arc_length", + "slot_arc": "arc_length", + "slot_arc_length": "arc_length", + } + mode_key = mode_aliases.get(mode_key, mode_key) + mode_labels = { + "width": "slot width", + "depth": "slot depth", + "arc_length": "slot arc length", + } + info = self.face_info(face_id) + feature: dict[str, object] = {} + try: + feature = self.feature_info(face_id) + except Exception: + feature = {} + + current_diameter = _float_or_none(info.get("diameter")) + if current_diameter is None: + current_diameter = _float_or_none(feature.get("diameter")) + current_radius = current_diameter / 2.0 if current_diameter is not None else None + angular_span = ( + _float_or_none(feature.get("slot_angular_span")) + or _float_or_none(info.get("slot_angular_span")) + or _float_or_none(info.get("angular_span")) + ) + slot_kind = str(feature.get("slot_kind") or info.get("slot_kind") or "") + slot_status = str(feature.get("slot_status") or info.get("slot_status") or "") + blockers: list[str] = [] + warnings: list[str] = [ + "Slot parameter edit keeps the current partial-cylinder angular span, converts the target value to a cylinder diameter, and rebuilds only the local sector volume." + ] + risk = "medium" + + try: + target_value = float(target_value) + except (TypeError, ValueError): + target_value = 0.0 + blockers.append("Target slot value must be a number.") + + if mode_key not in mode_labels: + blockers.append(f"Unsupported slot resize mode: {mode}.") + if target_value <= 0: + blockers.append("Target slot value must be greater than 0.") + if info.get("surface") != "cylinder" or current_diameter is None or current_diameter <= 1e-9: + blockers.append("Selected face is not a measurable cylindrical slot face.") + if slot_kind != "partial-cylindrical-groove": + blockers.append("Selected cylindrical face is not recognized as a slot/half-hole candidate.") + 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}.") + + span = min(max(float(angular_span or 0.0), 1e-6), math.tau - 1e-6) + sin_half_span = math.sin(span / 2.0) + sagitta_factor = 1.0 - math.cos(min(span, math.pi) / 2.0) + if mode_key == "width" and abs(sin_half_span) <= 1e-6: + blockers.append("Slot angular span is too small to derive width reliably.") + if mode_key == "depth" and sagitta_factor <= 1e-6: + blockers.append("Slot angular span is too small to derive depth reliably.") + + current_width = _float_or_none(feature.get("slot_chord_width_estimate")) + if current_width is None and current_diameter is not None: + current_width = current_diameter * sin_half_span + current_depth = _float_or_none(feature.get("slot_sagitta_depth_estimate")) + if current_depth is None and current_radius is not None: + current_depth = current_radius * sagitta_factor + current_arc_length = _float_or_none(feature.get("slot_arc_length_estimate")) + if current_arc_length is None and current_radius is not None: + current_arc_length = current_radius * span + + target_diameter = 0.0 + if not blockers: + if mode_key == "width": + target_diameter = target_value / sin_half_span + elif mode_key == "depth": + target_diameter = 2.0 * target_value / sagitta_factor + elif mode_key == "arc_length": + target_diameter = 2.0 * target_value / span + if target_diameter <= 1e-9: + blockers.append("Target slot value produced an invalid cylinder diameter.") + + target_width = target_diameter * sin_half_span if target_diameter > 0 else None + target_depth = target_diameter * 0.5 * sagitta_factor if target_diameter > 0 else None + target_arc_length = target_diameter * 0.5 * span if target_diameter > 0 else None + + slot_plan = { + "face_id": face_id, + "part_id": info.get("part_id"), + "solid_id": info.get("solid_id"), + "surface": info.get("surface"), + "slot_resize_mode": mode_key, + "slot_resize_label": mode_labels.get(mode_key, "slot value"), + "slot_resize_strategy": "local-sector-fixed-angular-span", + "slot_kind": slot_kind, + "slot_status": slot_status, + "slot_target_value": target_value, + "slot_target_diameter": target_diameter if target_diameter > 0 else None, + "slot_angular_span": angular_span, + "slot_chord_factor": sin_half_span, + "slot_sagitta_factor": sagitta_factor, + "slot_current_width": current_width, + "slot_target_width": target_width, + "slot_width_delta": None if current_width is None or target_width is None else target_width - current_width, + "slot_current_depth": current_depth, + "slot_target_depth": target_depth, + "slot_depth_delta": None if current_depth is None or target_depth is None else target_depth - current_depth, + "slot_current_arc_length": current_arc_length, + "slot_target_arc_length": target_arc_length, + "slot_arc_length_delta": None + if current_arc_length is None or target_arc_length is None + else target_arc_length - current_arc_length, + "feature_slot_face_ids": feature.get("feature_slot_face_ids"), + "feature_slot_boundary_face_ids": feature.get("feature_slot_boundary_face_ids"), + "slot_note": feature.get("slot_note"), + } + if blockers: + return { + **slot_plan, + "status": "blocked", + "risk": "blocked", + "message": "; ".join(blockers + warnings), + "warnings": "; ".join(warnings), + "blockers": "; ".join(blockers), + } + + resize_plan = self.cylindrical_resize_plan(face_id, target_diameter) + resize_status = str(resize_plan.get("status", "ready")) + resize_risk = str(resize_plan.get("risk", "low")) + if resize_status == "blocked": + status = "blocked" + risk = "blocked" + blockers.append(str(resize_plan.get("message", ""))) + else: + risk = _max_risk(risk, resize_risk) + status = "caution" if risk in {"medium", "high"} else "ready" + + paired_slot_plan = self._paired_obround_slot_plan( + face_id, + target_diameter, + feature, + resize_plan, + pair_face_id=pair_face_id, + ) + if paired_slot_plan: + risk = _max_risk(risk, str(paired_slot_plan.get("slot_pair_risk", "medium"))) + pair_warning = str(paired_slot_plan.get("slot_pair_warning", "")) + if pair_warning: + warnings.append(pair_warning) + elif pair_face_id is not None: + risk = _max_risk(risk, "high") + warnings.append( + "Manual paired Face ID could not be used as a compatible obround slot end; " + "this edit will fall back to rebuilding only the selected local slot sector." + ) + + resize_warnings = str(resize_plan.get("warnings", "") or "") + if resize_warnings: + warnings.append(resize_warnings) + message = "; ".join(blockers + warnings) if blockers or warnings else "Slot resize can be attempted." + return { + **resize_plan, + **slot_plan, + **paired_slot_plan, + "status": status, + "risk": risk, + "message": message, + "warnings": "; ".join(warnings), + "blockers": "; ".join(blockers), + "target_diameter": target_diameter, + "derived_new_diameter": target_diameter, + } + + def cylindrical_slot_angular_span_plan(self, face_id: int, target_angular_span: 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) + feature: dict[str, object] = {} + try: + feature = self.feature_info(face_id) + except Exception: + feature = {} + + current_diameter = _float_or_none(info.get("diameter")) + if current_diameter is None: + current_diameter = _float_or_none(feature.get("diameter")) + current_radius = current_diameter * 0.5 if current_diameter is not None else None + slot_kind = str(feature.get("slot_kind") or info.get("slot_kind") or "") + slot_status = str(feature.get("slot_status") or info.get("slot_status") or "") + blockers: list[str] = [] + warnings: list[str] = [ + "Slot angular-span edit keeps the current cylinder radius, fills the old local sector, then cuts a new local sector around the same angular center." + ] + risk = "medium" + + try: + target_angular_span = float(target_angular_span) + except (TypeError, ValueError): + target_angular_span = 0.0 + blockers.append("Target slot angular span must be a number.") + + surf = BRepAdaptor_Surface(self.faces[face_id]) + if surf.GetType() != GeomAbs_Cylinder: + blockers.append("Selected face is not a cylindrical slot face.") + current_u_first = 0.0 + current_u_last = 0.0 + current_span = 0.0 + signed_span = 0.0 + else: + current_u_first = float(surf.FirstUParameter()) + current_u_last = float(surf.LastUParameter()) + signed_span = current_u_last - current_u_first + current_span = abs(signed_span) + + if info.get("surface") != "cylinder" or current_diameter is None or current_diameter <= 1e-9: + blockers.append("Selected face is not a measurable cylindrical slot face.") + if slot_kind != "partial-cylindrical-groove": + blockers.append("Selected cylindrical face is not recognized as a slot/half-hole candidate.") + if current_span <= 1e-6 or current_span >= math.tau * 0.92: + blockers.append("Selected slot does not have a stable partial-cylinder angular span.") + if target_angular_span <= 1e-6: + blockers.append("Target slot angular span must be greater than 0.") + 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}.") + + delta_span = None if current_span <= 0 else target_angular_span - current_span + delta_ratio = ( + None + if current_span <= 1e-9 or delta_span is None + else abs(delta_span) / current_span + ) + if delta_ratio is not None: + if abs(delta_span or 0.0) <= max(current_span * 1e-5, 1e-6): + blockers.append("Target slot angular span is almost the same as the current span.") + elif delta_ratio > 0.75: + risk = _max_risk(risk, "high") + warnings.append("Target slot angular span changes by more than 75%.") + elif delta_ratio > 0.35: + risk = _max_risk(risk, "medium") + warnings.append("Target slot angular span changes by more than 35%.") + + cutter_plan: dict[str, object] = {} + fill_plan: dict[str, object] = {} + pair_plan: dict[str, object] = {} + if current_diameter is not None and current_diameter > 0: + try: + cutter_plan = self._bounded_cylinder_cutter_plan(face_id, current_diameter, feature) + fill_plan = self._bounded_cylinder_fill_plan(face_id) + pair_plan = self._paired_obround_slot_plan(face_id, current_diameter, feature, cutter_plan) + except Exception as exc: + blockers.append(f"Could not build slot angular-span edit tool plan: {exc}") + + if pair_plan: + risk = _max_risk(risk, "high") + warnings.append( + "A paired obround slot end was detected; angular-span edit currently rebuilds the selected local sector only." + ) + + sign = 1.0 if signed_span >= 0 else -1.0 + center_u = (current_u_first + current_u_last) * 0.5 + target_signed_span = target_angular_span * sign + target_u_first = center_u - target_signed_span * 0.5 + target_u_last = center_u + target_signed_span * 0.5 + + radius = current_radius if current_radius is not None else 0.0 + current_width = 2.0 * radius * math.sin(min(max(current_span, 0.0), math.tau) * 0.5) if radius > 0 else None + target_width = 2.0 * radius * math.sin(min(max(target_angular_span, 0.0), math.tau) * 0.5) if radius > 0 else None + current_depth = radius * (1.0 - math.cos(min(max(current_span, 0.0), math.pi) * 0.5)) if radius > 0 else None + target_depth = radius * (1.0 - math.cos(min(max(target_angular_span, 0.0), math.pi) * 0.5)) if radius > 0 else None + current_arc_length = radius * current_span if radius > 0 else None + target_arc_length = radius * target_angular_span if radius > 0 else None + + status = "blocked" if blockers else "caution" if risk in {"medium", "high"} else "ready" + if not blockers: + warnings.append("This is a B-Rep local-sector rebuild, not a recovered CAD sketch angle parameter.") + message = "; ".join(blockers + warnings) if blockers or warnings else "Slot angular-span resize can be attempted." + return { + **cutter_plan, + **fill_plan, + "status": status, + "risk": "blocked" if blockers else 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"), + "surface": info.get("surface"), + "slot_resize_mode": "angular_span", + "slot_resize_label": "slot angular span", + "slot_resize_strategy": "local-sector-angular-span", + "resize_mode": "widen" if (delta_span or 0.0) > 0 else "narrow", + "feature_type": feature.get("feature_type"), + "feature_guess": info.get("feature_guess"), + "confidence": info.get("confidence"), + "material_vote_summary": info.get("material_vote_summary"), + "cylinder_end_type": info.get("cylinder_end_type"), + "slot_kind": slot_kind, + "slot_status": slot_status, + "current_diameter": current_diameter, + "target_diameter": current_diameter, + "slot_target_diameter": current_diameter, + "slot_current_angular_span": current_span, + "slot_target_angular_span": target_angular_span, + "slot_angular_span_delta": delta_span, + "slot_angular_span_delta_ratio": delta_ratio, + "slot_current_u_first": current_u_first, + "slot_current_u_last": current_u_last, + "slot_target_u_first": target_u_first, + "slot_target_u_last": target_u_last, + "slot_angular_center_parameter": center_u, + "slot_current_width": current_width, + "slot_target_width": target_width, + "slot_current_depth": current_depth, + "slot_target_depth": target_depth, + "slot_current_arc_length": current_arc_length, + "slot_target_arc_length": target_arc_length, + "slot_pair_face_id": pair_plan.get("slot_pair_face_id", ""), + "slot_pair_axis_distance": pair_plan.get("slot_pair_axis_distance", ""), + "slot_pair_boundary_overlap_count": pair_plan.get("slot_pair_boundary_overlap_count", ""), + "feature_slot_face_ids": feature.get("feature_slot_face_ids"), + "feature_slot_boundary_face_ids": feature.get("feature_slot_boundary_face_ids"), + "slot_note": feature.get("slot_note"), + } + + def cylindrical_slot_axis_move_plan( + self, + face_id: int, + target_center: tuple[float, float, 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) + feature: dict[str, object] = {} + try: + feature = self.feature_info(face_id) + except Exception: + feature = {} + + blockers: list[str] = [] + warnings: list[str] = [ + "Slot/half-hole axis move fills the old local sector, then cuts the same sector tool on the target axis." + ] + risk = "medium" + + try: + target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2])) + except (TypeError, ValueError, IndexError): + target_center = (0.0, 0.0, 0.0) + blockers.append("Target slot axis center must be three numeric coordinates.") + + current_diameter = _float_or_none(info.get("diameter")) + if current_diameter is None: + current_diameter = _float_or_none(feature.get("diameter")) + slot_kind = str(feature.get("slot_kind") or info.get("slot_kind") or "") + slot_status = str(feature.get("slot_status") or info.get("slot_status") or "") + feature_guess = str(info.get("feature_guess", "")) + confidence = str(info.get("confidence", "low")) + surf = BRepAdaptor_Surface(self.faces[face_id]) + current_center: tuple[float, float, float] | None = None + axis_direction: tuple[float, float, float] | None = None + axis_range: dict[str, object] = {} + current_u_first = 0.0 + current_u_last = 0.0 + current_span = 0.0 + if info.get("surface") != "cylinder" or surf.GetType() != GeomAbs_Cylinder: + blockers.append("Selected Face is not a cylindrical slot/half-hole face.") + else: + cyl = surf.Cylinder() + axis = cyl.Axis() + axis_direction = _dir_tuple(axis.Direction()) + current_u_first = float(surf.FirstUParameter()) + current_u_last = float(surf.LastUParameter()) + current_span = abs(current_u_last - current_u_first) + axis_range = self._cylindrical_axis_range( + face_id, + surf, + _int_values(feature.get("feature_side_face_ids")), + ) + mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5 + current_center = _point_tuple(_point_on_axis(axis.Location(), axis.Direction(), mid_parameter)) + + if current_diameter is None or current_diameter <= 1e-9: + blockers.append("Selected slot/half-hole has no stable diameter.") + if feature_guess != "hole/groove candidate" or slot_kind != "partial-cylindrical-groove": + blockers.append("Slot axis move currently supports recognized partial cylindrical slot/half-hole candidates only.") + if current_span <= 1e-6 or current_span >= math.tau * 0.92: + blockers.append("Selected slot/half-hole does not have a stable partial-cylinder angular span.") + if current_center is None or axis_direction is None: + blockers.append("Could not derive a stable current slot axis center.") + + movement = (0.0, 0.0, 0.0) + move_distance = 0.0 + axial_delta = 0.0 + radial_distance = 0.0 + if current_center is not None and axis_direction is not None: + movement = _tuple_sub(target_center, current_center) + move_distance = _vector_length(movement) + axial_delta = _tuple_dot(movement, axis_direction) + radial_movement = _tuple_sub(movement, _tuple_scale(axis_direction, axial_delta)) + radial_distance = _vector_length(radial_movement) + diagonal = max(_shape_diagonal(self.faces[face_id]), current_diameter or 0.0, 1.0) + if move_distance <= max(diagonal * 1e-7, 1e-6): + blockers.append("Target slot axis center is almost the same as the current center.") + if current_diameter is not None and current_diameter > 0: + ratio = move_distance / current_diameter + if ratio > 4.0: + risk = _max_risk(risk, "high") + warnings.append("Target slot axis move is more than four slot diameters; Boolean cut may affect unrelated geometry.") + elif ratio > 1.0: + risk = _max_risk(risk, "high") + warnings.append("Target slot axis move is larger than one slot diameter; verify nearby walls after editing.") + elif ratio > 0.35: + risk = _max_risk(risk, "medium") + warnings.append("Target slot axis move is a moderate local relocation.") + if abs(axial_delta) > max(radial_distance * 0.5, (current_diameter or 1.0) * 0.25): + risk = _max_risk(risk, "high") + warnings.append("The target center includes a large movement along the slot axis; this may change end overlap.") + if confidence == "low": + 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}.") + + cutter_plan: dict[str, object] = {} + fill_plan: dict[str, object] = {} + pair_plan: dict[str, object] = {} + if not blockers and current_diameter is not None: + try: + cutter_plan = self._bounded_cylinder_cutter_plan(face_id, current_diameter, feature) + fill_plan = self._bounded_cylinder_fill_plan(face_id) + pair_plan = self._paired_obround_slot_plan(face_id, current_diameter, feature, cutter_plan) + axis_point = _tuple_or_none(cutter_plan.get("cutter_axis_point")) + start = _tuple_or_none(cutter_plan.get("cutter_start_point")) + if axis_point is None or start is None: + blockers.append("Could not build the bounded sector cutter for the moved slot axis.") + else: + cutter_plan["target_cutter_axis_point"] = _tuple_add(axis_point, movement) + cutter_plan["target_cutter_start_point"] = _tuple_add(start, movement) + except Exception as exc: + blockers.append(f"Could not build slot axis move tool plan: {exc}") + + if pair_plan: + risk = _max_risk(risk, "high") + warnings.append( + "A paired obround slot end was detected; this axis move edits the selected local sector, not the whole CAD slot sketch." + ) + + current_radius = current_diameter * 0.5 if current_diameter is not None else None + current_width = ( + 2.0 * current_radius * math.sin(min(max(current_span, 0.0), math.tau) * 0.5) + if current_radius is not None and current_radius > 0 + else None + ) + current_depth = ( + current_radius * (1.0 - math.cos(min(max(current_span, 0.0), math.pi) * 0.5)) + if current_radius is not None and current_radius > 0 + else None + ) + status = "blocked" if blockers else "caution" if risk in {"medium", "high"} else "ready" + if not blockers: + warnings.append("This is a B-Rep local-sector relocation, not a recovered CAD sketch constraint.") + message = "; ".join(blockers + warnings) if blockers or warnings else "Slot axis move can be attempted." + return { + **cutter_plan, + **fill_plan, + "status": status, + "risk": "blocked" if blockers else 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"), + "surface": info.get("surface"), + "feature_type": feature.get("feature_type"), + "feature_guess": feature_guess, + "confidence": confidence, + "material_vote_summary": info.get("material_vote_summary"), + "slot_kind": slot_kind, + "slot_status": slot_status, + "current_diameter": current_diameter, + "target_diameter": current_diameter, + "slot_target_diameter": current_diameter, + "current_radius": current_radius, + "target_radius": current_radius, + "current_axis_center": current_center, + "target_axis_center": target_center, + "axis_move_vector": movement, + "axis_move_distance": move_distance, + "axis_move_axial_delta": axial_delta, + "axis_move_radial_distance": radial_distance, + "axis": axis_direction, + "angular_span": current_span, + "slot_current_angular_span": current_span, + "slot_target_angular_span": current_span, + "slot_current_u_first": current_u_first, + "slot_current_u_last": current_u_last, + "slot_target_u_first": current_u_first, + "slot_target_u_last": current_u_last, + "slot_current_width": current_width, + "slot_current_depth": current_depth, + "slot_pair_face_id": pair_plan.get("slot_pair_face_id", ""), + "slot_pair_axis_distance": pair_plan.get("slot_pair_axis_distance", ""), + "slot_pair_boundary_overlap_count": pair_plan.get("slot_pair_boundary_overlap_count", ""), + "same_domain_face_ids": axis_range.get("same_domain_face_ids", ()), + "same_domain_face_count": axis_range.get("same_domain_face_count", 0), + "same_domain_v_range": (axis_range.get("v_min"), axis_range.get("v_max")), + "same_domain_range_source": axis_range.get("range_source", ""), + "resize_strategy": "fill-old-slot-sector-and-cut-moved-sector", + } + + def _paired_obround_slot_plan( + self, + face_id: int, + target_diameter: float, + feature: dict[str, object], + resize_plan: dict[str, object], + pair_face_id: int | None = None, + ) -> dict[str, object]: + if target_diameter <= 1e-9: + return {} + face = self.faces[face_id] + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + return {} + cyl = surf.Cylinder() + radius = float(cyl.Radius()) + if radius <= 1e-9: + return {} + axis_dir = _tuple_normalized(_dir_tuple(cyl.Axis().Direction())) + if axis_dir is None: + return {} + part_id = self.face_part_ids[face_id] + solid_id = self.face_solid_ids[face_id] + axis_range = self._cylindrical_axis_range( + face_id, + surf, + _int_values(feature.get("feature_side_face_ids")), + ) + selected_span = _float_or_none(feature.get("slot_angular_span")) + if selected_span is None: + selected_span = _float_or_none(feature.get("angular_span")) + selected_span = float(selected_span or 0.0) + selected_mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5 + selected_mid = _point_tuple(_point_on_axis(cyl.Axis().Location(), cyl.Axis().Direction(), selected_mid_parameter)) + selected_boundary = set(_int_values(feature.get("feature_slot_boundary_face_ids"))) + if not selected_boundary: + selected_boundary = set(_int_values(feature.get("feature_adjacent_face_ids"))) - set( + _int_values(feature.get("feature_end_face_ids")) + ) + + candidates: list[tuple[tuple[float, float, float, float, int], dict[str, object]]] = [] + diagonal = max(_shape_diagonal(self.part_by_id(part_id).shape) if self.part_by_id(part_id) is not None else 0.0, radius, 1.0) + radius_tolerance = max(radius * 0.08, diagonal * 1e-5, 1e-4) + height = max(float(axis_range["v_max"]) - float(axis_range["v_min"]), 1e-9) + manual_pair_face_id = None + if pair_face_id is not None: + try: + manual_pair_face_id = int(pair_face_id) + except (TypeError, ValueError): + return {} + if manual_pair_face_id == face_id or manual_pair_face_id < 0 or manual_pair_face_id >= len(self.faces): + return {} + + for other_face_id, other_face in enumerate(self.faces): + if other_face_id == face_id: + continue + if manual_pair_face_id is not None and other_face_id != manual_pair_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_info = self.face_info(other_face_id) + if other_info.get("surface") != "cylinder": + continue + if str(other_info.get("feature_guess", "")) != "hole/groove candidate": + continue + other_surf = BRepAdaptor_Surface(other_face) + 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 + axis_alignment = abs(_tuple_dot(axis_dir, _dir_tuple(other_cyl.Axis().Direction()))) + if axis_alignment < 1.0 - 1e-4: + continue + other_feature = self.feature_info(other_face_id) + other_span = _float_or_none(other_feature.get("slot_angular_span")) + if other_span is None: + other_span = _float_or_none(other_info.get("angular_span")) + other_span = float(other_span or 0.0) + if other_span <= 1e-6 or other_span >= math.tau * 0.92: + continue + other_range = self._cylindrical_axis_range( + other_face_id, + other_surf, + _int_values(other_feature.get("feature_side_face_ids")), + ) + other_height = max(float(other_range["v_max"]) - float(other_range["v_min"]), 1e-9) + if abs(other_height - height) > max(height, other_height) * 0.25: + 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, selected_mid) + axis_offset = _tuple_scale(axis_dir, _tuple_dot(raw_offset, axis_dir)) + section_offset = _tuple_sub(raw_offset, axis_offset) + center_distance = _vector_length(section_offset) + if center_distance <= max(radius * 1.2, diagonal * 1e-5, 1e-4): + continue + other_boundary = set(_int_values(other_feature.get("feature_slot_boundary_face_ids"))) + if not other_boundary: + other_boundary = set(_int_values(other_feature.get("feature_adjacent_face_ids"))) - set( + _int_values(other_feature.get("feature_end_face_ids")) + ) + boundary_overlap = len(selected_boundary & other_boundary) + both_half_like = ( + math.pi * 0.35 <= selected_span <= math.pi * 1.65 + and math.pi * 0.35 <= other_span <= math.pi * 1.65 + ) + if boundary_overlap <= 0 and not both_half_like and manual_pair_face_id is None: + continue + length_dir = _tuple_normalized(section_offset) + if length_dir is None: + continue + side_dir = _tuple_normalized(_tuple_cross(axis_dir, length_dir)) + if side_dir is None: + continue + overlap_score = 0.0 if boundary_overlap >= 2 else 1.0 if boundary_overlap == 1 else 2.0 + score = ( + overlap_score, + abs(other_radius - radius), + abs(other_span - selected_span), + abs(other_height - height) / max(height, other_height, 1e-9), + other_face_id, + ) + candidates.append( + ( + score, + { + "slot_pair_face_id": other_face_id, + "slot_pair_boundary_overlap_count": boundary_overlap, + "slot_pair_axis_distance": center_distance, + "slot_pair_selected_span": selected_span, + "slot_pair_other_span": other_span, + "slot_pair_selected_radius": radius, + "slot_pair_other_radius": other_radius, + "slot_pair_manual": manual_pair_face_id is not None, + "slot_capsule_axis_direction": axis_dir, + "slot_capsule_length_direction": length_dir, + "slot_capsule_side_direction": side_dir, + "slot_capsule_start_center_1": tuple(resize_plan.get("cutter_start_point", ())), + "slot_capsule_start_center_2": ( + float(resize_plan["cutter_start_point"][0]) + section_offset[0], + float(resize_plan["cutter_start_point"][1]) + section_offset[1], + float(resize_plan["cutter_start_point"][2]) + section_offset[2], + ) + if isinstance(resize_plan.get("cutter_start_point"), tuple) + and len(resize_plan.get("cutter_start_point", ())) == 3 + else "", + }, + ) + ) + + except Exception: + continue + + if not candidates: + return {} + candidates.sort(key=lambda item: item[0]) + candidate = candidates[0][1] + if not candidate.get("slot_capsule_start_center_1") or not candidate.get("slot_capsule_start_center_2"): + return {} + warning = "" + pair_risk = "medium" + if candidate.get("slot_pair_manual"): + pair_risk = "high" if int(candidate.get("slot_pair_boundary_overlap_count", 0)) <= 0 else "medium" + warning = ( + "Using manually specified paired partial-cylinder slot end; " + "please verify the selected pair before applying the obround slot rebuild." + ) + elif int(candidate.get("slot_pair_boundary_overlap_count", 0)) <= 0: + pair_risk = "high" + warning = ( + "Detected another parallel partial-cylinder slot end, but no shared boundary face was confirmed; " + "the obround slot rebuild is higher risk." + ) + else: + warning = "Detected paired partial-cylinder slot ends; the edit will rebuild the full obround slot prism." + return { + **candidate, + "slot_resize_strategy": "paired-obround-slot-prism", + "slot_pair_risk": pair_risk, + "slot_pair_warning": warning, + "feature_slot_face_ids": tuple( + sorted({face_id, int(candidate["slot_pair_face_id"]), *_int_values(feature.get("feature_slot_face_ids"))}) + ), + } + + def cylindrical_slot_total_length_plan( + self, + face_id: int, + target_total_length: float, + pair_face_id: int | None = None, + ) -> 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) + feature = self.feature_info(face_id) + current_diameter = _float_or_none(info.get("diameter")) + if current_diameter is None or current_diameter <= 1e-9: + return { + "status": "blocked", + "risk": "blocked", + "message": "Selected face is not a measurable cylindrical slot face.", + "blockers": "Selected face is not a measurable cylindrical slot face.", + "warnings": "", + } + try: + target_total_length = float(target_total_length) + except (TypeError, ValueError): + target_total_length = 0.0 + + cutter_plan = self._bounded_cylinder_cutter_plan(face_id, current_diameter, feature) + fill_plan = self._bounded_cylinder_fill_plan(face_id) + paired_plan = self._paired_obround_slot_plan( + face_id, + current_diameter, + feature, + cutter_plan, + pair_face_id=pair_face_id, + ) + blockers: list[str] = [] + warnings: list[str] = [] + risk = "medium" + + if not paired_plan: + if pair_face_id is None: + 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 target_total_length <= 0: + blockers.append("Target slot total length must be greater than 0.") + + current_center_distance = _float_or_none(paired_plan.get("slot_pair_axis_distance")) + current_total_length = ( + current_center_distance + current_diameter + if current_center_distance is not None and current_center_distance > 0 + else None + ) + target_center_distance = target_total_length - current_diameter + if target_center_distance <= max(current_diameter * 0.08, 1e-6): + blockers.append("Target slot total length must be meaningfully greater than the current slot width/diameter.") + + delta_length = None if current_total_length is None else target_total_length - current_total_length + delta_ratio = ( + None + if current_total_length is None or current_total_length <= 1e-9 or delta_length is None + else abs(delta_length) / current_total_length + ) + if delta_ratio is not None: + if abs(delta_length or 0.0) <= max(current_total_length * 1e-5, 1e-6): + blockers.append("Target slot total length is almost the same as the current length.") + elif delta_ratio > 0.75: + risk = _max_risk(risk, "high") + warnings.append("Target slot total length changes by more than 75%.") + elif delta_ratio > 0.35: + risk = _max_risk(risk, "medium") + warnings.append("Target slot total length changes by more than 35%.") + + center_1 = _tuple_or_none(paired_plan.get("slot_capsule_start_center_1")) + center_2 = _tuple_or_none(paired_plan.get("slot_capsule_start_center_2")) + length_dir = _tuple_normalized(_tuple_or_none(paired_plan.get("slot_capsule_length_direction"))) + target_center_1 = None + target_center_2 = None + if center_1 is None or center_2 is None or length_dir is None: + blockers.append("Could not derive the obround slot centerline.") + elif target_center_distance > 0: + midpoint = ( + (center_1[0] + center_2[0]) * 0.5, + (center_1[1] + center_2[1]) * 0.5, + (center_1[2] + center_2[2]) * 0.5, + ) + half_vector = _tuple_scale(length_dir, target_center_distance * 0.5) + target_center_1 = _tuple_sub(midpoint, half_vector) + target_center_2 = _tuple_add(midpoint, half_vector) + + if blockers: + status = "blocked" + risk = "blocked" + else: + status = "caution" if risk in {"medium", "high"} else "ready" + warnings.append( + "Slot total length edit fills the old obround slot volume, then cuts a new obround slot with the same width." + ) + + message = "; ".join(blockers + warnings) if blockers or warnings else "Slot total length resize can be attempted." + return { + **cutter_plan, + **fill_plan, + **paired_plan, + "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"), + "surface": info.get("surface"), + "current_diameter": current_diameter, + "target_diameter": current_diameter, + "slot_target_diameter": current_diameter, + "slot_resize_mode": "total_length", + "slot_resize_label": "slot total length", + "slot_resize_strategy": "paired-obround-slot-length-prism", + "resize_mode": "lengthen" if (delta_length or 0.0) > 0 else "shorten", + "slot_current_total_length": current_total_length, + "slot_target_total_length": target_total_length, + "slot_total_length_delta": delta_length, + "slot_total_length_delta_ratio": delta_ratio, + "slot_current_center_distance": current_center_distance, + "slot_target_center_distance": target_center_distance, + "slot_capsule_target_start_center_1": target_center_1, + "slot_capsule_target_start_center_2": target_center_2, + } + def cylindrical_boss_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") @@ -633,6 +1837,151 @@ class FeatureMixin: **tool_plan, } + def cylindrical_boss_axis_move_plan( + self, + face_id: int, + target_center: tuple[float, float, 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) + feature: dict[str, object] = {} + try: + feature = self.feature_info(face_id) + except Exception: + feature = {} + + blockers: list[str] = [] + warnings: list[str] = [ + "Cylindrical boss axis move removes the old boss envelope, then fuses a same-diameter boss on the target axis." + ] + risk = "medium" + + try: + target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2])) + except (TypeError, ValueError, IndexError): + target_center = (0.0, 0.0, 0.0) + blockers.append("Target boss axis center must be three numeric coordinates.") + + current_diameter = _float_or_none(info.get("diameter")) + angular_span = _float_or_none(info.get("angular_span")) + feature_guess = str(info.get("feature_guess", "")) + confidence = str(info.get("confidence", "low")) + surf = BRepAdaptor_Surface(self.faces[face_id]) + current_center: tuple[float, float, float] | None = None + axis_direction: tuple[float, float, float] | None = None + axis_range: dict[str, object] = {} + if info.get("surface") != "cylinder" or surf.GetType() != GeomAbs_Cylinder: + blockers.append("Selected Face is not a cylindrical boss face.") + else: + cyl = surf.Cylinder() + axis = cyl.Axis() + axis_direction = _dir_tuple(axis.Direction()) + axis_range = self._cylindrical_axis_range( + face_id, + surf, + _int_values(feature.get("feature_side_face_ids")), + ) + mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5 + current_center = _point_tuple(_point_on_axis(axis.Location(), axis.Direction(), mid_parameter)) + + if current_diameter is None or current_diameter <= 1e-9: + blockers.append("Selected cylindrical boss has no stable diameter.") + if feature_guess != "boss/outer-round candidate": + blockers.append("Boss axis move currently supports recognized boss/outer-round candidates only.") + if angular_span is None or angular_span < math.tau * 0.92: + blockers.append("Boss axis move currently supports near-full cylindrical bosses only.") + if current_center is None or axis_direction is None: + blockers.append("Could not derive a stable current boss axis center.") + + movement = (0.0, 0.0, 0.0) + move_distance = 0.0 + axial_delta = 0.0 + radial_distance = 0.0 + if current_center is not None and axis_direction is not None: + movement = _tuple_sub(target_center, current_center) + move_distance = _vector_length(movement) + axial_delta = _tuple_dot(movement, axis_direction) + radial_movement = _tuple_sub(movement, _tuple_scale(axis_direction, axial_delta)) + radial_distance = _vector_length(radial_movement) + diagonal = max(_shape_diagonal(self.faces[face_id]), current_diameter or 0.0, 1.0) + if move_distance <= max(diagonal * 1e-7, 1e-6): + blockers.append("Target boss axis center is almost the same as the current center.") + if current_diameter is not None and current_diameter > 0: + ratio = move_distance / current_diameter + if ratio > 4.0: + risk = _max_risk(risk, "high") + warnings.append("Target boss axis move is more than four diameters; Boolean operations may affect unrelated geometry.") + elif ratio > 1.0: + risk = _max_risk(risk, "high") + warnings.append("Target boss axis move is larger than one diameter; verify nearby walls after editing.") + elif ratio > 0.35: + risk = _max_risk(risk, "medium") + warnings.append("Target boss axis move is a moderate local relocation.") + if abs(axial_delta) > max(radial_distance * 0.5, (current_diameter or 1.0) * 0.25): + risk = _max_risk(risk, "high") + warnings.append("The target center includes a large movement along the boss axis; this may change the boss/base overlap.") + if confidence == "low": + risk = _max_risk(risk, "medium") + warnings.append("Boss recognition confidence is low.") + + tool_plan: dict[str, object] = {} + if not blockers and current_diameter is not None: + tool_plan = self._bounded_boss_resize_tool_plan(face_id, current_diameter) + start = _tuple_or_none(tool_plan.get("boss_tool_start_point")) + axis_point = _tuple_or_none(tool_plan.get("boss_tool_axis_point")) + exact_start = _tuple_or_none(tool_plan.get("boss_tool_exact_start_point")) + if start is None or axis_point is None: + blockers.append("Could not build the bounded boss tool for the moved axis.") + else: + tool_plan["target_boss_tool_start_point"] = _tuple_add(start, movement) + tool_plan["target_boss_tool_axis_point"] = _tuple_add(axis_point, movement) + tool_plan["target_boss_tool_exact_start_point"] = ( + _tuple_add(exact_start, movement) if exact_start is not None else "" + ) + tool_plan["target_boss_tool_radius"] = current_diameter * 0.5 + + if blockers: + status = "blocked" + risk = "blocked" + else: + status = "caution" if risk in {"medium", "high"} else "ready" + + message = "; ".join(blockers + warnings) if blockers or warnings else "Cylindrical boss axis move can be attempted." + return { + **tool_plan, + "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"), + "surface": info.get("surface"), + "feature_type": feature.get("feature_type"), + "feature_guess": feature_guess, + "confidence": confidence, + "current_diameter": current_diameter, + "target_diameter": current_diameter, + "current_radius": None if current_diameter is None else current_diameter * 0.5, + "target_radius": None if current_diameter is None else current_diameter * 0.5, + "current_axis_center": current_center, + "target_axis_center": target_center, + "axis_move_vector": movement, + "axis_move_distance": move_distance, + "axis_move_axial_delta": axial_delta, + "axis_move_radial_distance": radial_distance, + "axis": axis_direction, + "angular_span": angular_span, + "same_domain_face_ids": axis_range.get("same_domain_face_ids", ()), + "same_domain_face_count": axis_range.get("same_domain_face_count", 0), + "same_domain_v_range": (axis_range.get("v_min"), axis_range.get("v_max")), + "same_domain_range_source": axis_range.get("range_source", ""), + "resize_strategy": "remove-old-boss-and-fuse-moved-cylinder", + } + def cylindrical_suppress_plan(self, face_id: int) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") @@ -696,7 +2045,7 @@ class FeatureMixin: return { "status": "blocked", "risk": "blocked", - "message": "当前选中的 Face 不是圆柱面,不能调整盲孔深度。", + "message": "当前选中的 Face 不是圆柱面,不能调整盲孔/盲槽深度。", } feature = self.feature_info(face_id) diff --git a/step_editor/geometry_utils.py b/step_editor/geometry_utils.py index 6b0a67a..7badb1f 100644 --- a/step_editor/geometry_utils.py +++ b/step_editor/geometry_utils.py @@ -8,7 +8,7 @@ from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Defeaturing from OCC.Core.BRepBndLib import brepbndlib from OCC.Core.BOPAlgo import BOPAlgo_GlueFull -from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform +from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_GTransform, BRepBuilderAPI_Transform from OCC.Core.BRepCheck import BRepCheck_Analyzer from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier from OCC.Core.BRepGProp import brepgprop @@ -34,7 +34,7 @@ from OCC.Core.TopExp import TopExp_Explorer from OCC.Core.TopLoc import TopLoc_Location from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape -from OCC.Core.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec +from OCC.Core.gp import gp_Ax1, gp_Dir, gp_GTrsf, gp_Pnt, gp_Trsf, gp_Vec, gp_XYZ from OCC.Extend.TopologyUtils import TopologyExplorer from .constants import CURVE_TYPES, ORIENTATION_TYPES @@ -341,6 +341,58 @@ def _rotated_shape( return BRepBuilderAPI_Transform(shape, trsf, True).Shape() +def _scaled_shape(shape: TopoDS_Shape, scale: float, center: tuple[float, float, float]) -> TopoDS_Shape: + if abs(scale - 1.0) <= 1e-12: + return shape + if scale <= 0: + raise ValueError("Scale factor must be greater than 0.") + trsf = gp_Trsf() + trsf.SetScale(gp_Pnt(*center), float(scale)) + return BRepBuilderAPI_Transform(shape, trsf, True).Shape() + + +def _axis_scaled_shape( + shape: TopoDS_Shape, + axis_name: str, + scale: float, + center: tuple[float, float, float], +) -> TopoDS_Shape: + if abs(scale - 1.0) <= 1e-12: + return shape + if scale <= 0: + raise ValueError("Scale factor must be greater than 0.") + axis = axis_name.upper() + axis_index = {"X": 0, "Y": 1, "Z": 2}.get(axis) + if axis_index is None: + raise ValueError("Scale axis must be X, Y or Z.") + matrix = [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + matrix[axis_index][axis_index] = float(scale) + cx, cy, cz = center + moved_center = ( + matrix[0][0] * cx + matrix[0][1] * cy + matrix[0][2] * cz, + matrix[1][0] * cx + matrix[1][1] * cy + matrix[1][2] * cz, + matrix[2][0] * cx + matrix[2][1] * cy + matrix[2][2] * cz, + ) + translation = (cx - moved_center[0], cy - moved_center[1], cz - moved_center[2]) + transform = gp_GTrsf() + for row in range(3): + for column in range(3): + transform.SetValue(row + 1, column + 1, matrix[row][column]) + transform.SetTranslationPart(gp_XYZ(*translation)) + builder = BRepBuilderAPI_GTransform(shape, transform, True) + builder.Build() + if not builder.IsDone(): + raise RuntimeError("Axis scale transform failed.") + result = builder.Shape() + if result.IsNull(): + raise RuntimeError("Axis scale transform produced an empty shape.") + return result + + def _axis_dir_from_name(axis_name: str) -> gp_Dir: axis = axis_name.upper() if axis == "X": @@ -365,10 +417,23 @@ def _tuple_or_none(value: object) -> tuple[float, float, float] | None: return None +def _float_or_none(value: object) -> float | None: + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + def _tuple_sub(left: tuple[float, float, float], right: tuple[float, float, float]) -> tuple[float, float, float]: return (left[0] - right[0], left[1] - right[1], left[2] - right[2]) +def _tuple_add(left: tuple[float, float, float], right: tuple[float, float, float]) -> tuple[float, float, float]: + return (left[0] + right[0], left[1] + right[1], left[2] + right[2]) + + def _tuple_scale(values: tuple[float, float, float], scale: float) -> tuple[float, float, float]: return (values[0] * scale, values[1] * scale, values[2] * scale) @@ -937,7 +1002,7 @@ def _cylinder_depth_readiness( status = "caution" if not warnings and not blockers: - note = "可以尝试调整盲孔深度。" + note = "可以尝试调整盲孔/盲槽深度。" else: note = " ".join(blockers + warnings) return { diff --git a/step_editor/info_panel.py b/step_editor/info_panel.py index a5792c8..ccb0466 100644 --- a/step_editor/info_panel.py +++ b/step_editor/info_panel.py @@ -29,6 +29,8 @@ class InfoPanelMixin: self.info_tabs.setCurrentWidget(self.info_tree) if hasattr(self, "_refresh_property_editor"): self._refresh_property_editor() + if hasattr(self, "_update_selected_object_title"): + self._update_selected_object_title() if hasattr(self, "mode_combo"): self._update_action_states() diff --git a/step_editor/model.py b/step_editor/model.py index b66783d..0f7fb98 100644 --- a/step_editor/model.py +++ b/step_editor/model.py @@ -98,6 +98,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self.edge_solid_ids: list[int] = [] self.solids: list[tuple[int, TopoDS_Shape]] = [] self._face_info_cache: dict[int, dict[str, object]] = {} + self._feature_info_cache: dict[int, dict[str, object]] = {} self._edge_info_cache: dict[int, dict[str, object]] = {} self._face_edge_ids_cache: dict[int, list[int]] = {} self._edge_face_ids_cache: dict[int, list[int]] = {} @@ -176,6 +177,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self.edge_solid_ids.clear() self.solids.clear() self._face_info_cache.clear() + self._feature_info_cache.clear() self._edge_info_cache.clear() self._face_edge_ids_cache.clear() self._edge_face_ids_cache.clear() @@ -268,6 +270,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd if isinstance(logical_ids, (list, tuple)) and len(logical_ids) == len(self.faces): self.face_logical_ids = [int(item) for item in logical_ids] self._face_info_cache.clear() + self._feature_info_cache.clear() self._same_domain_face_ids_cache.clear() def face_info(self, face_id: int) -> dict[str, object]: @@ -356,27 +359,140 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self._face_info_cache[face_id] = dict(info) return dict(info) + def face_surface_kind(self, face_id: int) -> str: + if face_id < 0 or face_id >= len(self.faces): + return "" + cached = self._face_info_cache.get(face_id) + if cached is not None and cached.get("surface") is not None: + return str(cached.get("surface")) + try: + surf = BRepAdaptor_Surface(self.faces[face_id]) + surface_type = surf.GetType() + except Exception: + return "" + return SURFACE_TYPES.get(surface_type, f"type {surface_type}") + + def face_cylinder_diameter(self, face_id: int) -> float | None: + if face_id < 0 or face_id >= len(self.faces): + return None + cached = self._face_info_cache.get(face_id) + if cached is not None and cached.get("diameter") is not None and cached.get("surface") == "cylinder": + try: + return float(cached["diameter"]) + except (TypeError, ValueError): + pass + try: + surf = BRepAdaptor_Surface(self.faces[face_id]) + if surf.GetType() != GeomAbs_Cylinder: + return None + return float(surf.Cylinder().Radius()) * 2.0 + except Exception: + return None + def feature_info(self, face_id: int) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") + if face_id in self._feature_info_cache: + return dict(self._feature_info_cache[face_id]) info = self.face_info(face_id) surface = str(info.get("surface", "")) if surface == "cylinder": - return self._cylindrical_feature_info(face_id, info) - if surface == "plane": - return self._planar_feature_info(face_id, info) + result = self._cylindrical_feature_info(face_id, info) + elif surface == "plane": + result = self._planar_feature_info(face_id, info) + elif surface == "cone": + result = self._conical_feature_info(face_id, info) + elif surface == "sphere": + result = self._spherical_feature_info(face_id, info) + elif surface == "torus": + result = self._toroidal_feature_info(face_id, info) + else: + boundary_edge_ids = self._face_boundary_edge_ids(face_id) + result = dict(info) + result.update( + { + "kind": "feature", + "feature_type": "暂不支持的局部曲面候选", + "feature_source_face_id": face_id, + "feature_face_ids": (face_id,), + "feature_highlight_face_ids": (face_id,), + "feature_boundary_edge_ids": tuple(boundary_edge_ids), + "feature_edit_actions": "当前只读;复杂曲面局部面积修改暂未开放", + "feature_mode": ( + "Feature 模式会把选中的Face解释为局部几何特征候选;" + "复杂曲面局部面积修改需要更明确的边界/约束重建,当前不会把面积伪装成直接可改参数。" + ), + } + ) + self._feature_info_cache[face_id] = dict(result) + return dict(result) + + def _toroidal_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]: boundary_edge_ids = self._face_boundary_edge_ids(face_id) + major_radius = float(info.get("major_radius", 0.0) or 0.0) + minor_radius = float(info.get("minor_radius", 0.0) or 0.0) result = dict(info) result.update( { "kind": "feature", - "feature_type": "暂不支持的局部曲面候选", + "feature_type": "环面候选", "feature_source_face_id": face_id, "feature_face_ids": (face_id,), "feature_highlight_face_ids": (face_id,), "feature_boundary_edge_ids": tuple(boundary_edge_ids), - "feature_edit_actions": "当前版本只能查看该局部曲面,暂不支持直接编辑。", - "feature_mode": "Feature 模式会把选中的Face解释为局部几何特征候选。", + "feature_edit_actions": "修改环面主半径/小半径", + "feature_mode": ( + "这是从 STEP/B-Rep 环面直接识别出的几何候选;当前修改会围绕环面中心缩放所属零件/Solid," + "主半径和小半径会等比例变化,不是 CAD 历史里的管径或圆角参数。" + ), + "feature_torus_major_radius": major_radius, + "feature_torus_minor_radius": minor_radius, + } + ) + return result + + def _spherical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]: + boundary_edge_ids = self._face_boundary_edge_ids(face_id) + radius = float(info.get("radius", 0.0) or 0.0) + result = dict(info) + result.update( + { + "kind": "feature", + "feature_type": "球面候选", + "feature_source_face_id": face_id, + "feature_face_ids": (face_id,), + "feature_highlight_face_ids": (face_id,), + "feature_boundary_edge_ids": tuple(boundary_edge_ids), + "feature_edit_actions": "修改球面半径/直径", + "feature_mode": ( + "这是从 STEP/B-Rep 球面直接识别出的几何候选;修改会围绕球心缩放所属零件/Solid," + "不是 CAD 历史里的球面或圆角参数。" + ), + "feature_sphere_radius": radius, + "feature_sphere_diameter": radius * 2.0 if radius > 0 else "", + } + ) + return result + + def _conical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]: + boundary_edge_ids = self._face_boundary_edge_ids(face_id) + reference_radius = float(info.get("reference_radius", 0.0) or 0.0) + result = dict(info) + result.update( + { + "kind": "feature", + "feature_type": "圆锥面候选", + "feature_source_face_id": face_id, + "feature_face_ids": (face_id,), + "feature_highlight_face_ids": (face_id,), + "feature_boundary_edge_ids": tuple(boundary_edge_ids), + "feature_reference_radius": reference_radius, + "feature_reference_diameter": reference_radius * 2.0 if reference_radius > 0 else "", + "feature_edit_actions": "修改圆锥参考半径/直径", + "feature_mode": ( + "这是从 STEP/B-Rep 圆锥面直接识别出的几何候选;修改会围绕圆锥轴做径向缩放," + "不是 CAD 历史里的锥孔或倒角参数。" + ), } ) return result @@ -549,12 +665,14 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd if guess == "hole/groove candidate": if angular_span < math.tau * 0.92: feature_type = "槽/半孔候选" + edit_actions = "调整圆柱孔径;调整槽/半孔宽度;调整槽/半孔深度;调整槽/半孔圆弧长度;调整槽/半孔圆弧角度;调整槽孔总长度" else: feature_type = "圆柱孔候选" + edit_actions = "调整圆柱孔径" if info.get("cylinder_end_type") == "blind" and bottom_face_ids: - edit_actions = "调整圆柱孔径;调整盲孔深度" + edit_actions += ";调整盲孔/盲槽深度" else: - edit_actions = "调整圆柱孔径;孔深调整需要明确盲孔底面" + edit_actions += ";孔深调整需要明确盲孔底面" if angular_span >= math.tau * 0.92: edit_actions += ";封堵圆柱孔" elif guess == "round/fillet candidate": @@ -562,7 +680,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd edit_actions = "可尝试修改已有圆角半径;当前版本会先移除圆角面,再在恢复出的锐边上重新倒圆。" elif guess == "boss/outer-round candidate": feature_type = "凸台/外圆候选" - edit_actions = "调整圆柱凸台直径。" + edit_actions = "调整圆柱凸台直径;调整圆柱凸台高度;修改圆柱凸台轴心坐标。" else: feature_type = "未明确圆柱特征" edit_actions = "可尝试调整圆柱孔径,但风险较高。" @@ -704,6 +822,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd start_end_face_ids: list[int] = [] end_end_face_ids: list[int] = [] + cap_scan_skipped = False for adjacent_id in adjacent_face_ids: match = self._axis_end_match_for_planar_face( adjacent_id, @@ -720,20 +839,23 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd end_end_face_ids.append(adjacent_id) if info.get("cylinder_end_type") == "blind" and (not start_end_face_ids or not end_end_face_ids): - scanned = self._axis_cap_face_candidates( - face_id, - axis_point, - axis_dir, - v_min, - v_max, - radius, - span, - set(adjacent_face_ids), - ) - if not start_end_face_ids: - start_end_face_ids.extend(scanned["start"]) - if not end_end_face_ids: - end_end_face_ids.extend(scanned["end"]) + if len(self.faces) <= 1000: + scanned = self._axis_cap_face_candidates( + face_id, + axis_point, + axis_dir, + v_min, + v_max, + radius, + span, + set(adjacent_face_ids), + ) + if not start_end_face_ids: + start_end_face_ids.extend(scanned["start"]) + if not end_end_face_ids: + end_end_face_ids.extend(scanned["end"]) + else: + cap_scan_skipped = True bottom_face_ids: list[int] = [] opening_face_ids: list[int] = [] @@ -755,6 +877,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd bottom_confidence = "medium" if bottom_detection == "axis-cap-scan" else "high" if not end_face_ids: note = "没有在圆柱边界附近找到平面端面。" + if cap_scan_skipped: + note += " 当前模型 Face 数较多,已跳过全模型底面扫描以避免选择时卡顿;如需改盲孔/盲槽深度,可以手动填写底面 Face ID。" elif bottom_face_ids: if bottom_detection == "axis-cap-scan": note = "已通过轴线端部采样和轴线附近圆盘面扫描标记疑似底面;这是几何推断,不是 CAD 历史孔深。" @@ -762,6 +886,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd note = "已根据圆柱轴线端部 inside/outside 采样标记疑似底面;这是几何推断,不是 CAD 历史孔深。" else: note = "已找到端面候选,但端部采样显示这些端面更像开口附近的相邻面。" + if cap_scan_skipped: + note += " 当前模型 Face 数较多,已跳过全模型底面扫描以避免选择时卡顿。" return { "end_face_ids": end_face_ids, "start_end_face_ids": sorted(set(start_end_face_ids)), @@ -905,6 +1031,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd for face_id in valid_face_ids: self.face_logical_ids[face_id] = int(logical_id) self._face_info_cache.pop(face_id, None) + self._feature_info_cache.pop(face_id, None) def nearest_edge_id_to_point( self, @@ -961,9 +1088,17 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd return valid_face_ids[0] point_vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(float(point[0]), float(point[1]), float(point[2]))).Vertex() + exact_face_ids = valid_face_ids + if len(valid_face_ids) > 48: + scored_face_ids: list[tuple[float, int]] = [] + for face_id in valid_face_ids: + scored_face_ids.append((self._face_bounds_distance_sq(face_id, point), face_id)) + scored_face_ids.sort(key=lambda item: item[0]) + exact_face_ids = [face_id for _distance, face_id in scored_face_ids[:48]] + best_face_id: int | None = None best_distance = math.inf - for face_id in valid_face_ids: + for face_id in exact_face_ids: try: extrema = BRepExtrema_DistShapeShape(point_vertex, self.faces[face_id]) extrema.Perform() @@ -981,7 +1116,22 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd if distance < best_distance: best_distance = distance best_face_id = face_id - return best_face_id if best_face_id is not None else valid_face_ids[0] + return best_face_id if best_face_id is not None else exact_face_ids[0] + + def _face_bounds_distance_sq(self, face_id: int, point: tuple[float, float, float]) -> float: + px, py, pz = (float(point[0]), float(point[1]), float(point[2])) + try: + xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(self.faces[face_id]) + dx = max(xmin - px, 0.0, px - xmax) + dy = max(ymin - py, 0.0, py - ymax) + dz = max(zmin - pz, 0.0, pz - zmax) + return dx * dx + dy * dy + dz * dz + except Exception: + try: + center = _surface_center(self.faces[face_id]) + return _point_distance_sq((px, py, pz), _point_tuple(center)) + except Exception: + return math.inf def _adjacent_face_ids_for_edges(self, edge_ids: list[int], face_id: int) -> list[int]: if not edge_ids: diff --git a/step_editor/operations.py b/step_editor/operations.py index 73323fe..164d8e8 100644 --- a/step_editor/operations.py +++ b/step_editor/operations.py @@ -250,6 +250,188 @@ class OperationMixin: "direction": info.get("direction"), } + def edge_asymmetric_chamfer_plan( + self, + edge_id: int, + distance1: float, + distance2: float, + reference_face_id: int | None = None, + ) -> dict[str, object]: + distance1 = float(distance1) + distance2 = float(distance2) + max_distance = max(distance1, distance2) + plan = self.edge_chamfer_plan(edge_id, max_distance) + info = self.edge_info(edge_id) + length = float(info.get("length", 0.0)) + adjacent_face_ids = _int_values(info.get("adjacent_face_ids")) + blockers = [str(plan.get("blockers", ""))] if str(plan.get("blockers", "")).strip() else [] + warnings = [str(plan.get("warnings", ""))] if str(plan.get("warnings", "")).strip() else [] + risk = str(plan.get("risk", "medium")) + + if distance1 <= 0 or distance2 <= 0: + blockers.append("Asymmetric chamfer distances D1 and D2 must both be greater than 0.") + if length <= 1e-9: + blockers.append("Current Edge length is invalid.") + if len(adjacent_face_ids) < 2: + blockers.append("Asymmetric chamfer needs at least two adjacent Faces on the selected Edge.") + + resolved_reference_face_id: int | None + if reference_face_id is None: + resolved_reference_face_id = adjacent_face_ids[0] if adjacent_face_ids else None + else: + try: + resolved_reference_face_id = int(reference_face_id) + except (TypeError, ValueError): + resolved_reference_face_id = None + blockers.append("Reference Face ID must be an integer.") + + if resolved_reference_face_id is None: + blockers.append("Could not resolve an adjacent reference Face for asymmetric chamfer.") + elif resolved_reference_face_id not in adjacent_face_ids: + blockers.append( + f"Reference Face {resolved_reference_face_id} is not adjacent to Edge {edge_id}; " + f"available adjacent Faces: {tuple(adjacent_face_ids)}." + ) + elif resolved_reference_face_id < 0 or resolved_reference_face_id >= len(self.faces): + blockers.append(f"Reference Face {resolved_reference_face_id} does not exist.") + + ratio1 = distance1 / max(length, 1e-9) + ratio2 = distance2 / max(length, 1e-9) + if length > 1e-9: + if max(ratio1, ratio2) >= 0.45: + blockers.append("D1 or D2 is close to half of the Edge length; asymmetric chamfer is blocked.") + elif max(ratio1, ratio2) > 0.25: + risk = _max_risk(risk, "high") + warnings.append("D1 or D2 is larger than 25% of the Edge length; OCCT chamfer failure is more likely.") + elif max(ratio1, ratio2) > 0.12: + risk = _max_risk(risk, "medium") + warnings.append("D1 or D2 is relatively large compared with the Edge length.") + if abs(distance1 - distance2) <= max(max_distance * 1e-6, 1e-7): + warnings.append("D1 and D2 are almost equal; the result will be close to a symmetric chamfer.") + + if blockers: + status = "blocked" + risk = "blocked" + message = " ".join(blockers) + elif risk != "low": + status = "caution" + message = " ".join(warnings) if warnings else "Asymmetric chamfer can be attempted, but it depends on OCCT." + else: + status = "ready" + message = "Asymmetric chamfer can be attempted on this straight Edge." + + plan.update( + { + "status": status, + "risk": risk, + "message": message, + "warnings": "; ".join(warnings), + "blockers": "; ".join(blockers), + "chamfer_mode": "asymmetric-distances", + "target_distance": max_distance, + "target_distance1": distance1, + "target_distance2": distance2, + "distance1_to_length_ratio": ratio1, + "distance2_to_length_ratio": ratio2, + "reference_face_id": resolved_reference_face_id, + "reference_face_candidates": tuple(adjacent_face_ids), + } + ) + return plan + + def edge_distance_angle_chamfer_plan( + self, + edge_id: int, + distance: float, + angle_degrees: float, + reference_face_id: int | None = None, + ) -> dict[str, object]: + distance = float(distance) + angle_degrees = float(angle_degrees) + angle_radians = math.radians(angle_degrees) + plan = self.edge_chamfer_plan(edge_id, distance) + info = self.edge_info(edge_id) + length = float(info.get("length", 0.0)) + adjacent_face_ids = _int_values(info.get("adjacent_face_ids")) + blockers = [str(plan.get("blockers", ""))] if str(plan.get("blockers", "")).strip() else [] + warnings = [str(plan.get("warnings", ""))] if str(plan.get("warnings", "")).strip() else [] + risk = str(plan.get("risk", "medium")) + + if distance <= 0: + blockers.append("Distance-angle chamfer distance must be greater than 0.") + if angle_degrees <= 0 or angle_degrees >= 89.0: + blockers.append("Distance-angle chamfer angle must be greater than 0 and less than 89 degrees.") + elif angle_degrees < 10.0 or angle_degrees > 80.0: + risk = _max_risk(risk, "high") + warnings.append("Chamfer angle is near an extreme value; OCCT failure is more likely.") + elif angle_degrees < 20.0 or angle_degrees > 70.0: + risk = _max_risk(risk, "medium") + warnings.append("Chamfer angle is relatively steep; please check the result carefully.") + if length <= 1e-9: + blockers.append("Current Edge length is invalid.") + if len(adjacent_face_ids) < 2: + blockers.append("Distance-angle chamfer needs at least two adjacent Faces on the selected Edge.") + + resolved_reference_face_id: int | None + if reference_face_id is None: + resolved_reference_face_id = adjacent_face_ids[0] if adjacent_face_ids else None + else: + try: + resolved_reference_face_id = int(reference_face_id) + except (TypeError, ValueError): + resolved_reference_face_id = None + blockers.append("Reference Face ID must be an integer.") + + if resolved_reference_face_id is None: + blockers.append("Could not resolve an adjacent reference Face for distance-angle chamfer.") + elif resolved_reference_face_id not in adjacent_face_ids: + blockers.append( + f"Reference Face {resolved_reference_face_id} is not adjacent to Edge {edge_id}; " + f"available adjacent Faces: {tuple(adjacent_face_ids)}." + ) + elif resolved_reference_face_id < 0 or resolved_reference_face_id >= len(self.faces): + blockers.append(f"Reference Face {resolved_reference_face_id} does not exist.") + + distance_ratio = distance / max(length, 1e-9) + if length > 1e-9: + if distance_ratio >= 0.45: + blockers.append("Chamfer distance is close to half of the Edge length; distance-angle chamfer is blocked.") + elif distance_ratio > 0.25: + risk = _max_risk(risk, "high") + warnings.append("Chamfer distance is larger than 25% of the Edge length.") + elif distance_ratio > 0.12: + risk = _max_risk(risk, "medium") + warnings.append("Chamfer distance is relatively large compared with the Edge length.") + + if blockers: + status = "blocked" + risk = "blocked" + message = " ".join(blockers) + elif risk != "low": + status = "caution" + message = " ".join(warnings) if warnings else "Distance-angle chamfer can be attempted, but it depends on OCCT." + else: + status = "ready" + message = "Distance-angle chamfer can be attempted on this straight Edge." + + plan.update( + { + "status": status, + "risk": risk, + "message": message, + "warnings": "; ".join(warnings), + "blockers": "; ".join(blockers), + "chamfer_mode": "distance-angle", + "target_distance": distance, + "target_angle_degrees": angle_degrees, + "target_angle_radians": angle_radians, + "distance_to_length_ratio": distance_ratio, + "reference_face_id": resolved_reference_face_id, + "reference_face_candidates": tuple(adjacent_face_ids), + } + ) + return plan + def general_edge_length_plan( self, edge_id: int, @@ -533,6 +715,283 @@ class OperationMixin: "", ) + def edge_endpoint_move_plan( + self, + edge_id: int, + endpoint_role: str, + target_point: tuple[float, float, float], + ) -> dict[str, object]: + if edge_id < 0 or edge_id >= len(self.edges): + raise ValueError(f"Unknown edge id {edge_id}") + + role = str(endpoint_role or "").strip().lower() + role_aliases = { + "start": "start", + "edge-start": "start", + "begin": "start", + "起点": "start", + "end": "end", + "edge-end": "end", + "finish": "end", + "终点": "end", + } + role = role_aliases.get(role, role) + if role not in {"start", "end"}: + raise ValueError("endpoint_role must be 'start' or 'end'.") + + info = self.edge_info(edge_id) + current_length = float(info.get("length", 0.0)) + curve = str(info.get("curve", "")) + start = _tuple_or_none(info.get("start_point")) + end = _tuple_or_none(info.get("end_point")) + target = _tuple_or_none(target_point) + base: dict[str, object] = { + "edge_id": edge_id, + "part_id": info.get("part_id"), + "solid_id": info.get("solid_id", -1), + "curve": curve, + "current_length": current_length, + "target_length": current_length, + "delta_length": 0.0, + "length_change_ratio": 0.0, + "edge_endpoint_role": role, + "edge_endpoint_label": "start point" if role == "start" else "end point", + "start_point": start, + "end_point": end, + "target_endpoint_point": target, + "length_center": info.get("length_center"), + "resize_strategy": "local-edge-endpoint-deform", + } + warnings = [ + "Edge endpoint coordinate edit rebuilds the current STEP/B-Rep result geometry; it is not recovered CAD history." + ] + blockers: list[str] = [] + risk = "low" + status = "ready" + + if curve != "line": + blockers.append("Only straight line Edge endpoints can be moved directly.") + if current_length <= 1e-9: + blockers.append("Current Edge length is invalid.") + if start is None or end is None: + blockers.append("Current Edge does not have stable start/end coordinates.") + if target is None: + blockers.append("Target endpoint coordinate must be a valid X/Y/Z tuple.") + + 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 + fixed_endpoint = end if role == "start" else start + move = _tuple_sub(target, current_endpoint) + move_distance = _vector_length(move) + target_length = _vector_length(_tuple_sub(target, fixed_endpoint)) + delta_length = target_length - current_length + length_change_ratio = abs(delta_length) / max(current_length, 1e-9) + move_ratio = move_distance / max(current_length, 1e-9) + base.update( + { + "current_endpoint_point": current_endpoint, + "fixed_endpoint_point": fixed_endpoint, + "target_length": target_length, + "delta_length": delta_length, + "length_change_ratio": length_change_ratio, + "moved_endpoint_delta": move, + "moved_endpoint_distance": move_distance, + "moved_endpoint_ratio": move_ratio, + "edge_length_anchor_mode": "keep-end" if role == "start" else "keep-start", + "edge_length_anchor_label": "fixed end point" if role == "start" else "fixed start point", + } + ) + if move_distance <= max(current_length * 1e-7, 1e-7): + blockers.append("Target endpoint coordinate is almost identical to the current coordinate.") + if target_length <= 1e-9: + blockers.append("Moving this endpoint would collapse the Edge length to zero.") + 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.") + 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.") + + 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" + local_candidate, local_skip_note = self._local_edge_length_deform_candidate( + info, + float(base["target_length"]), + anchor_mode=anchor_mode, + ) + if local_candidate is None: + blockers.append(local_skip_note or "Local endpoint deformation is not available for this 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) + expected_start = _tuple_add(start, start_move) + expected_end = _tuple_add(end, end_move) + base.update(local_candidate) + base.update( + { + "resize_strategy": "local-edge-endpoint-deform", + "local_edge_deform_anchor": "move start point, keep end point" + if role == "start" + else "move end point, keep start point", + "local_edge_deform_start_move": start_move, + "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." + ), + "expected_start_point": expected_start, + "expected_end_point": expected_end, + } + ) + 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; non-planar faces may be split into triangles." + ) + + if blockers: + status = "blocked" + risk = "blocked" + message = " ".join(blockers) + elif risk != "low": + status = "caution" + message = " ".join(warnings) + else: + message = "The straight Edge endpoint can be moved by local planar-solid deformation." + + base.update( + { + "status": status, + "risk": risk, + "message": message, + "warnings": "; ".join(warnings), + "blockers": "; ".join(blockers), + } + ) + return base + + def edge_center_move_plan( + self, + edge_id: int, + target_center: tuple[float, float, float], + ) -> dict[str, object]: + if edge_id < 0 or edge_id >= len(self.edges): + raise ValueError(f"Unknown edge id {edge_id}") + + info = self.edge_info(edge_id) + current_length = float(info.get("length", 0.0)) + curve = str(info.get("curve", "")) + start = _tuple_or_none(info.get("start_point")) + end = _tuple_or_none(info.get("end_point")) + current_center = _tuple_or_none(info.get("length_center")) + target = _tuple_or_none(target_center) + base: dict[str, object] = { + "edge_id": edge_id, + "part_id": info.get("part_id"), + "solid_id": info.get("solid_id", -1), + "curve": curve, + "current_length": current_length, + "target_length": current_length, + "delta_length": 0.0, + "length_change_ratio": 0.0, + "start_point": start, + "end_point": end, + "length_center": current_center, + "target_edge_center": target, + "resize_strategy": "local-edge-center-deform", + } + warnings = [ + "Edge center coordinate edit rebuilds the current STEP/B-Rep result geometry; it is not recovered CAD history." + ] + blockers: list[str] = [] + risk = "low" + status = "ready" + + if curve != "line": + blockers.append("Only straight line Edge centers can be moved directly.") + if current_length <= 1e-9: + blockers.append("Current Edge length is invalid.") + 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.") + if target is None: + blockers.append("Target Edge center coordinate must be a valid X/Y/Z tuple.") + + 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) + move_distance = _vector_length(move) + move_ratio = move_distance / max(current_length, 1e-9) + base.update( + { + "current_edge_center": current_center, + "target_edge_center": target, + "moved_edge_center_delta": move, + "moved_edge_center_distance": move_distance, + "moved_edge_center_ratio": move_ratio, + "edge_length_anchor_mode": "center", + "edge_length_anchor_label": "move whole edge center", + } + ) + if move_distance <= max(current_length * 1e-7, 1e-7): + blockers.append("Target Edge center coordinate is almost identical to the current coordinate.") + if move_ratio > 0.5: + risk = _max_risk(risk, "high") + warnings.append("Edge center movement is larger than 50% of the current Edge length.") + elif move_ratio > 0.25: + risk = _max_risk(risk, "medium") + warnings.append("Edge center movement is larger than 25% of the current Edge length.") + + 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( + info, + current_length, + anchor_mode="center", + ) + if local_candidate is None: + blockers.append(local_skip_note or "Local Edge center deformation is not available for this Edge.") + else: + move = _tuple_sub(target, current_center) + expected_start = _tuple_add(start, move) + expected_end = _tuple_add(end, move) + base.update(local_candidate) + base.update( + { + "resize_strategy": "local-edge-center-deform", + "local_edge_deform_anchor": "move both endpoints equally", + "local_edge_deform_start_move": move, + "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." + ), + "expected_start_point": expected_start, + "expected_end_point": expected_end, + } + ) + 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; non-planar faces may be split into triangles." + ) + + if blockers: + status = "blocked" + risk = "blocked" + message = " ".join(blockers) + elif risk != "low": + status = "caution" + message = " ".join(warnings) + else: + message = "The straight Edge center can be moved by local planar-solid deformation." + + base.update( + { + "status": status, + "risk": risk, + "message": message, + "warnings": "; ".join(warnings), + "blockers": "; ".join(blockers), + } + ) + return base + def _edge_length_anchor_mode(self, anchor_mode: str | None) -> str: normalized = str(anchor_mode or "auto").strip().lower() aliases = { @@ -1279,6 +1738,219 @@ class OperationMixin: "resize_strategy": "push-pull-shell-source-plane-to-target-thickness", } + def cylindrical_boss_height_plan( + self, + face_id: int, + target_height: float, + *, + require_boss: bool = True, + ) -> 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) + feature = self.feature_info(face_id) + blockers: list[str] = [] + edit_label = "凸台高度" if require_boss else "圆柱高度" + warnings: list[str] = [ + f"{edit_label}调整会推拉识别到的圆柱端盖 Face;这是 B-Rep 几何编辑,不是 CAD 历史特征参数。" + ] + risk = "low" + + if info.get("surface") != "cylinder": + blockers.append(f"当前选中 Face 不是圆柱面,不能调整{edit_label}。") + if require_boss and str(info.get("feature_guess", "")) != "boss/outer-round candidate": + blockers.append("凸台高度调整当前版本只支持明确的圆柱凸台候选。") + angular_span = float(info.get("angular_span", 0.0)) + if angular_span < math.tau * 0.92: + blockers.append(f"{edit_label}调整当前版本只支持接近完整圆柱的圆柱面。") + if target_height <= 1e-9: + blockers.append(f"目标{edit_label}必须大于 0。") + + axis_range: dict[str, object] = { + "span": 0.0, + "same_domain_face_ids": (), + "same_domain_face_count": 0, + } + if info.get("surface") == "cylinder": + axis_range = self._cylindrical_axis_range( + face_id, + BRepAdaptor_Surface(self.faces[face_id]), + _int_values(feature.get("feature_side_face_ids")), + ) + current_height = float(axis_range.get("span", 0.0)) + delta_height = float(target_height) - current_height + if current_height <= 1e-9: + blockers.append(f"当前{edit_label}估算无效。") + elif abs(delta_height) <= max(current_height * 1e-5, 1e-6): + blockers.append(f"目标{edit_label}与当前估算高度几乎相同,不需要修改。") + + if str(info.get("confidence", "low")) != "high": + risk = _max_risk(risk, "medium") + warnings.append(f"{edit_label}识别置信度不是 high,修改后请重点检查结果。") + if current_height > 1e-9: + delta_ratio = abs(delta_height) / current_height + if delta_ratio > 0.8: + risk = _max_risk(risk, "high") + warnings.append("目标高度变化超过当前高度的 80%,可能导致周边几何异常。") + elif delta_ratio > 0.3: + risk = _max_risk(risk, "medium") + warnings.append("目标高度变化超过当前高度的 30%,请确认预览方向和范围。") + + cap_candidates: list[tuple[tuple[int, int, float, int], dict[str, object]]] = [] + if not blockers: + endpoint_groups = [ + ("start", "起点端盖", _int_values(feature.get("feature_start_end_face_ids")), -1.0, bool(info.get("start_end_open"))), + ("end", "终点端盖", _int_values(feature.get("feature_end_end_face_ids")), 1.0, bool(info.get("end_end_open"))), + ] + axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis"))) + if axis_direction is None: + blockers.append(f"当前{edit_label}缺少稳定轴线方向,不能换算高度修改。") + else: + for endpoint_role, endpoint_label, cap_face_ids, axis_sign, is_open_end in endpoint_groups: + desired_movement = _tuple_scale(axis_direction, axis_sign * delta_height) + desired_unit = _tuple_normalized(desired_movement) + if desired_unit is None: + continue + for cap_face_id in cap_face_ids: + if cap_face_id < 0 or cap_face_id >= len(self.faces): + continue + try: + cap_info = self.face_info(cap_face_id) + except Exception: + continue + if cap_info.get("surface") != "plane": + continue + outward = _tuple_normalized(_tuple_or_none(cap_info.get("push_pull_outward_direction"))) + if outward is None: + continue + movement_alignment = abs(_tuple_dot(desired_unit, outward)) + if movement_alignment < 0.82: + continue + push_pull_distance = _tuple_dot(desired_movement, outward) + if abs(push_pull_distance) <= 1e-9: + continue + push_plan = self.push_pull_plan(cap_face_id, push_pull_distance) + if push_plan["status"] == "blocked": + warnings.append(f"{endpoint_label} Face {cap_face_id} 不能推拉:{push_plan.get('message', '')}") + continue + candidate_risk = str(push_plan.get("risk", "medium")) + score = ( + 0 if is_open_end else 1, + {"low": 0, "medium": 1, "high": 2}.get(candidate_risk, 3), + -movement_alignment, + cap_face_id, + ) + cap_candidates.append( + ( + score, + { + "boss_height_cap_face_id": cap_face_id, + "boss_height_endpoint_role": endpoint_role, + "boss_height_endpoint_label": endpoint_label, + "boss_height_open_end": is_open_end, + "boss_height_movement_alignment": movement_alignment, + "boss_height_desired_movement_vector": desired_movement, + "push_pull_distance": push_pull_distance, + "push_pull_status": push_plan.get("status"), + "push_pull_risk": push_plan.get("risk"), + "push_pull_message": push_plan.get("message"), + "push_pull_scope_face_ids": push_plan.get("push_pull_scope_face_ids", (cap_face_id,)), + "push_pull_scope_face_count": push_plan.get("push_pull_scope_face_count", 1), + "push_pull_scope_note": push_plan.get("push_pull_scope_note", ""), + }, + ) + ) + + selected_cap: dict[str, object] = {} + if not blockers: + if not cap_candidates: + blockers.append("没有找到可推拉的圆柱端盖 Face,暂不能直接调整凸台高度。") + else: + cap_candidates.sort(key=lambda item: item[0]) + selected_cap = cap_candidates[0][1] + risk = _max_risk(risk, str(selected_cap.get("push_pull_risk", "medium"))) + if not selected_cap.get("boss_height_open_end"): + risk = _max_risk(risk, "medium") + warnings.append("未能确认所选端盖是凸台外端,执行后请重点检查是否移动了正确端面。") + + if blockers: + status = "blocked" + risk = "blocked" + message = " ".join(blockers) + elif risk != "low": + status = "caution" + message = " ".join(warnings) + else: + status = "ready" + message = f"可以通过推拉圆柱端盖调整{edit_label}。" + + 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"), + "feature_type": feature.get("feature_type"), + "feature_guess": info.get("feature_guess"), + "confidence": info.get("confidence"), + "current_height": current_height, + "target_height": float(target_height), + "delta_height": delta_height, + "height_delta_ratio": abs(delta_height) / max(current_height, 1e-9), + "diameter": info.get("diameter"), + "radius": info.get("radius"), + "axis": info.get("axis"), + "same_domain_face_ids": axis_range.get("same_domain_face_ids"), + "same_domain_face_count": axis_range.get("same_domain_face_count"), + "feature_start_end_face_ids": feature.get("feature_start_end_face_ids"), + "feature_end_end_face_ids": feature.get("feature_end_end_face_ids"), + "resize_strategy": ( + "push-pull-cylindrical-boss-end-cap-to-target-height" + if require_boss + else "push-pull-cylindrical-end-cap-to-target-height" + ), + **selected_cap, + } + + def cylindrical_height_plan(self, face_id: int, target_height: float) -> dict[str, object]: + return self.cylindrical_boss_height_plan(face_id, target_height, require_boss=False) + + def cylindrical_boss_height_preview_polydata( + self, + face_id: int, + target_height: float, + deflection: float = 0.8, + ): + plan = self.cylindrical_boss_height_plan(face_id, target_height) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + return self.push_pull_preview_polydata( + int(plan["boss_height_cap_face_id"]), + float(plan["push_pull_distance"]), + deflection, + ) + + def resize_cylindrical_boss_height(self, face_id: int, target_height: float) -> str: + plan = self.cylindrical_boss_height_plan(face_id, target_height) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + push_result = self.push_pull_face( + int(plan["boss_height_cap_face_id"]), + float(plan["push_pull_distance"]), + ) + return ( + "Cylindrical boss height resize completed by end-cap push/pull: " + f"face {face_id}, cap_face={plan.get('boss_height_cap_face_id')}, " + f"current_height={float(plan['current_height']):g}, " + f"target_height={float(plan['target_height']):g}, " + f"delta={float(plan['delta_height']):g}, " + f"push_pull_distance={float(plan['push_pull_distance']):g}, " + f"risk={plan['risk']}. {push_result}" + ) + def shell_thickness_preview_polydata( self, face_id: int, @@ -1305,6 +1977,421 @@ class OperationMixin: f"risk={plan['risk']}. {push_result}" ) + def resize_cylindrical_height(self, face_id: int, target_height: float) -> str: + plan = self.cylindrical_height_plan(face_id, target_height) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + push_result = self.push_pull_face( + int(plan["boss_height_cap_face_id"]), + float(plan["push_pull_distance"]), + ) + return ( + "Cylindrical height resize completed by end-cap push/pull: " + f"face {face_id}, cap_face={plan.get('boss_height_cap_face_id')}, " + f"current_height={float(plan['current_height']):g}, " + f"target_height={float(plan['target_height']):g}, " + f"delta={float(plan['delta_height']):g}, " + f"push_pull_distance={float(plan['push_pull_distance']):g}, " + f"risk={plan['risk']}. {push_result}" + ) + + def conical_reference_radius_plan(self, face_id: int, target_radius: 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) + blockers: list[str] = [] + warnings: list[str] = [ + "圆锥面参考半径修改会围绕圆锥轴径向缩放所属零件/Solid;这是 B-Rep 几何缩放,不是 CAD 历史参数。" + ] + risk = "medium" + + try: + target_radius = float(target_radius) + except (TypeError, ValueError): + target_radius = 0.0 + blockers.append("目标圆锥参考半径必须是数字。") + + current_radius = _float_or_none(info.get("reference_radius")) + axis_point = _tuple_or_none(info.get("axis_point")) + axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis"))) + if info.get("surface") != "cone": + blockers.append("当前选中 Face 不是圆锥面。") + if current_radius is None or current_radius <= 1e-9: + blockers.append("当前圆锥面缺少有效参考半径。") + if axis_point is None or axis_direction is None: + blockers.append("当前圆锥面缺少稳定轴线,不能做径向缩放。") + if target_radius <= 1e-9: + blockers.append("目标圆锥参考半径必须大于 0。") + + scale = target_radius / max(current_radius or 1.0, 1e-9) + delta_radius = target_radius - float(current_radius or 0.0) + delta_ratio = abs(delta_radius) / max(float(current_radius or 0.0), 1e-9) + if current_radius is not None and abs(delta_radius) <= max(current_radius * 1e-6, 1e-6): + blockers.append("目标圆锥参考半径与当前值几乎相同,不需要修改。") + if delta_ratio > 0.6: + risk = _max_risk(risk, "high") + warnings.append("圆锥参考半径变化超过 60%,可能明显影响周边几何。") + elif delta_ratio > 0.25: + risk = _max_risk(risk, "high") + warnings.append("圆锥参考半径变化超过 25%,修改后请重点检查相邻面。") + + part_id = int(info.get("part_id", -1)) + solid_id = int(info.get("solid_id", -1)) + part = self.part_by_id(part_id) if part_id >= 0 else None + if part is None: + blockers.append("找不到当前圆锥面所属零件。") + part_solid_count = 0 + else: + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) + target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" + if target_kind == "part": + warnings.append("当前会缩放所属零件 shape,可能影响同一零件上的其它尺寸。") + else: + warnings.append("当前会缩放所属 Solid,可能影响同一 Solid 上的其它尺寸。") + + status = "blocked" if blockers else "caution" + if blockers: + risk = "blocked" + return { + "status": status, + "risk": risk, + "message": " ".join(blockers + warnings), + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": part_id, + "solid_id": solid_id, + "surface": info.get("surface"), + "current_reference_radius": current_radius, + "target_reference_radius": target_radius, + "current_reference_diameter": None if current_radius is None else current_radius * 2.0, + "target_reference_diameter": target_radius * 2.0, + "delta_reference_radius": delta_radius, + "reference_radius_delta_ratio": delta_ratio, + "semi_angle": info.get("semi_angle"), + "axis_point": axis_point, + "axis": axis_direction, + "resize_strategy": "radial-affine-scale-cone-reference-radius", + "affine_scale": scale, + "affine_transform_kind": "radial-affine", + "affine_transform_label": "围绕圆锥轴径向缩放", + "affine_axis_point": axis_point, + "affine_axis_direction": axis_direction, + "affine_axis_source": "cone axis", + "affine_anchor_source": "cone axis point", + "affine_target_kind": target_kind, + "part_solid_count": part_solid_count, + } + + def resize_conical_reference_radius(self, face_id: int, target_radius: float) -> str: + plan = self.conical_reference_radius_plan(face_id, target_radius) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + self._apply_edge_length_affine_transform(plan) + return ( + "Conical face reference radius resize completed by radial affine scaling: " + f"face {face_id}, " + f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, " + f"scale={float(plan['affine_scale']):g}, " + f"target={plan.get('affine_target_kind')}, " + f"risk={plan['risk']}." + ) + + def spherical_radius_plan(self, face_id: int, target_radius: 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) + blockers: list[str] = [] + warnings: list[str] = [ + "球面半径修改会围绕球心缩放所属零件/Solid;这是 B-Rep 几何缩放,不是 CAD 历史参数。" + ] + risk = "medium" + + try: + target_radius = float(target_radius) + except (TypeError, ValueError): + target_radius = 0.0 + blockers.append("目标球面半径必须是数字。") + + current_radius = _float_or_none(info.get("radius")) + center = _tuple_or_none(info.get("center")) + if info.get("surface") != "sphere": + blockers.append("当前选中 Face 不是球面。") + if current_radius is None or current_radius <= 1e-9: + blockers.append("当前球面缺少有效半径。") + if center is None: + blockers.append("当前球面缺少稳定球心,不能缩放。") + if target_radius <= 1e-9: + blockers.append("目标球面半径必须大于 0。") + + scale = target_radius / max(current_radius or 1.0, 1e-9) + delta_radius = target_radius - float(current_radius or 0.0) + delta_ratio = abs(delta_radius) / max(float(current_radius or 0.0), 1e-9) + if current_radius is not None and abs(delta_radius) <= max(current_radius * 1e-6, 1e-6): + blockers.append("目标球面半径与当前值几乎相同,不需要修改。") + if delta_ratio > 0.6: + risk = _max_risk(risk, "high") + warnings.append("球面半径变化超过 60%,可能明显影响周边几何。") + elif delta_ratio > 0.25: + risk = _max_risk(risk, "high") + warnings.append("球面半径变化超过 25%,修改后请重点检查相邻面。") + + part_id = int(info.get("part_id", -1)) + solid_id = int(info.get("solid_id", -1)) + part = self.part_by_id(part_id) if part_id >= 0 else None + if part is None: + blockers.append("找不到当前球面所属零件。") + part_solid_count = 0 + else: + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) + target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" + warnings.append(f"当前会缩放所属 {target_kind},可能影响同一对象上的其它尺寸。") + + status = "blocked" if blockers else "caution" + if blockers: + risk = "blocked" + return { + "status": status, + "risk": risk, + "message": " ".join(blockers + warnings), + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": part_id, + "solid_id": solid_id, + "surface": info.get("surface"), + "current_radius": current_radius, + "target_radius": target_radius, + "current_diameter": None if current_radius is None else current_radius * 2.0, + "target_diameter": target_radius * 2.0, + "delta_radius": delta_radius, + "radius_delta_ratio": delta_ratio, + "center": center, + "resize_strategy": "uniform-scale-sphere-radius", + "affine_scale": scale, + "affine_transform_kind": "uniform", + "affine_transform_label": "围绕球心均匀缩放", + "affine_axis_point": center, + "affine_axis_direction": (0.0, 0.0, 1.0), + "affine_axis_source": "sphere center", + "affine_anchor_source": "sphere center", + "affine_target_kind": target_kind, + "part_solid_count": part_solid_count, + } + + def resize_spherical_radius(self, face_id: int, target_radius: float) -> str: + plan = self.spherical_radius_plan(face_id, target_radius) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + self._apply_edge_length_affine_transform(plan) + return ( + "Spherical face radius resize completed by uniform scaling: " + f"face {face_id}, " + f"radius={float(plan['current_radius']):g}->{float(plan['target_radius']):g}, " + f"scale={float(plan['affine_scale']):g}, " + f"target={plan.get('affine_target_kind')}, " + f"risk={plan['risk']}." + ) + + def toroidal_radius_plan(self, face_id: int, target_radius: float, mode: str = "minor") -> dict[str, object]: + if face_id < 0 or face_id >= len(self.faces): + raise ValueError(f"Unknown face id {face_id}") + mode_key = "major" if str(mode).lower() in {"major", "main", "major_radius"} else "minor" + info = self.face_info(face_id) + blockers: list[str] = [] + warnings: list[str] = [ + "环面半径修改会围绕环面中心均匀缩放所属零件/Solid;主半径和小半径会等比例变化。" + ] + risk = "medium" + + try: + target_radius = float(target_radius) + except (TypeError, ValueError): + target_radius = 0.0 + blockers.append("目标环面半径必须是数字。") + + current_major = _float_or_none(info.get("major_radius")) + current_minor = _float_or_none(info.get("minor_radius")) + current_radius = current_major if mode_key == "major" else current_minor + center = _tuple_or_none(info.get("center")) + axis = _tuple_normalized(_tuple_or_none(info.get("axis"))) or (0.0, 0.0, 1.0) + if info.get("surface") != "torus": + blockers.append("当前选中 Face 不是环面。") + if current_major is None or current_major <= 1e-9 or current_minor is None or current_minor <= 1e-9: + blockers.append("当前环面缺少有效主半径或小半径。") + if center is None: + blockers.append("当前环面缺少稳定中心,不能缩放。") + if target_radius <= 1e-9: + blockers.append("目标环面半径必须大于 0。") + + scale = target_radius / max(current_radius or 1.0, 1e-9) + target_major = None if current_major is None else current_major * scale + target_minor = None if current_minor is None else current_minor * scale + delta_radius = target_radius - float(current_radius or 0.0) + delta_ratio = abs(delta_radius) / max(float(current_radius or 0.0), 1e-9) + if current_radius is not None and abs(delta_radius) <= max(current_radius * 1e-6, 1e-6): + blockers.append("目标环面半径与当前值几乎相同,不需要修改。") + if delta_ratio > 0.6: + risk = _max_risk(risk, "high") + warnings.append("环面半径变化超过 60%,可能明显影响周边几何。") + elif delta_ratio > 0.25: + risk = _max_risk(risk, "high") + warnings.append("环面半径变化超过 25%,修改后请重点检查相邻面。") + + part_id = int(info.get("part_id", -1)) + solid_id = int(info.get("solid_id", -1)) + part = self.part_by_id(part_id) if part_id >= 0 else None + if part is None: + blockers.append("找不到当前环面所属零件。") + part_solid_count = 0 + else: + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) + target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" + warnings.append(f"当前会缩放所属 {target_kind},可能影响同一对象上的其它尺寸。") + + status = "blocked" if blockers else "caution" + if blockers: + risk = "blocked" + return { + "status": status, + "risk": risk, + "message": " ".join(blockers + warnings), + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": part_id, + "solid_id": solid_id, + "surface": info.get("surface"), + "torus_radius_mode": mode_key, + "current_major_radius": current_major, + "target_major_radius": target_major, + "current_minor_radius": current_minor, + "target_minor_radius": target_minor, + "target_radius": target_radius, + "delta_radius": delta_radius, + "radius_delta_ratio": delta_ratio, + "center": center, + "axis": axis, + "resize_strategy": "uniform-scale-torus-radius", + "affine_scale": scale, + "affine_transform_kind": "uniform", + "affine_transform_label": "围绕环面中心均匀缩放", + "affine_axis_point": center, + "affine_axis_direction": axis, + "affine_axis_source": "torus center", + "affine_anchor_source": "torus center", + "affine_target_kind": target_kind, + "part_solid_count": part_solid_count, + } + + def resize_toroidal_radius(self, face_id: int, target_radius: float, mode: str = "minor") -> str: + plan = self.toroidal_radius_plan(face_id, target_radius, mode) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + self._apply_edge_length_affine_transform(plan) + mode_label = "major" if plan.get("torus_radius_mode") == "major" else "minor" + current = plan.get("current_major_radius") if mode_label == "major" else plan.get("current_minor_radius") + target = plan.get("target_major_radius") if mode_label == "major" else plan.get("target_minor_radius") + return ( + "Toroidal face radius resize completed by uniform scaling: " + f"face {face_id}, {mode_label}_radius={float(current):g}->{float(target):g}, " + f"scale={float(plan['affine_scale']):g}, " + f"target={plan.get('affine_target_kind')}, " + f"risk={plan['risk']}." + ) + + def face_area_scale_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}") + info = self.face_info(face_id) + blockers: list[str] = [] + warnings: list[str] = [ + "Face面积修改是复杂曲面的兜底几何缩放;会围绕Face面积中心均匀缩放所属零件/Solid。" + ] + risk = "high" + + try: + target_area = float(target_area) + except (TypeError, ValueError): + target_area = 0.0 + blockers.append("目标Face面积必须是数字。") + + current_area = _float_or_none(info.get("area")) + center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) + if current_area is None or current_area <= 1e-9: + blockers.append("当前Face缺少有效面积。") + if center is None: + blockers.append("当前Face缺少稳定面积中心,不能缩放。") + if target_area <= 1e-9: + blockers.append("目标Face面积必须大于 0。") + if current_area is not None and abs(target_area - current_area) <= max(current_area * 1e-6, 1e-6): + blockers.append("目标Face面积与当前值几乎相同,不需要修改。") + + scale = math.sqrt(target_area / max(current_area or 1.0, 1e-9)) + area_delta = target_area - float(current_area or 0.0) + area_delta_ratio = abs(area_delta) / max(float(current_area or 0.0), 1e-9) + if area_delta_ratio <= 0.15: + risk = "medium" + elif area_delta_ratio > 0.8: + warnings.append("目标面积变化超过 80%,很可能明显影响周边几何。") + else: + warnings.append("目标面积变化较大,修改后请重点检查周边尺寸。") + + part_id = int(info.get("part_id", -1)) + solid_id = int(info.get("solid_id", -1)) + part = self.part_by_id(part_id) if part_id >= 0 else None + if part is None: + blockers.append("找不到当前Face所属零件。") + part_solid_count = 0 + else: + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) + target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" + warnings.append(f"当前会缩放所属 {target_kind},可能影响同一对象上的其它尺寸。") + + status = "blocked" if blockers else "caution" + if blockers: + risk = "blocked" + return { + "status": status, + "risk": risk, + "message": " ".join(blockers + warnings), + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "face_id": face_id, + "part_id": part_id, + "solid_id": solid_id, + "surface": info.get("surface"), + "current_area": current_area, + "target_area": target_area, + "area_delta": area_delta, + "area_delta_ratio": area_delta_ratio, + "area_center": center, + "resize_strategy": "uniform-scale-face-area-fallback", + "affine_scale": scale, + "affine_transform_kind": "uniform", + "affine_transform_label": "围绕Face面积中心均匀缩放", + "affine_axis_point": center, + "affine_axis_direction": (0.0, 0.0, 1.0), + "affine_axis_source": "face area center", + "affine_anchor_source": "face area center", + "affine_target_kind": target_kind, + "part_solid_count": part_solid_count, + } + + def resize_face_area(self, face_id: int, target_area: float) -> str: + plan = self.face_area_scale_plan(face_id, target_area) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + self._apply_edge_length_affine_transform(plan) + return ( + "Face area resize completed by uniform scaling fallback: " + f"face {face_id}, " + f"area={float(plan['current_area']):g}->{float(plan['target_area']):g}, " + f"scale={float(plan['affine_scale']):g}, " + f"target={plan.get('affine_target_kind')}, " + f"risk={plan['risk']}." + ) + def cylindrical_resize_preview_polydata( self, face_id: int, @@ -1596,6 +2683,60 @@ class OperationMixin: f"risk={plan['risk']}. {result_check}" ) + def move_edge_endpoint( + self, + edge_id: int, + endpoint_role: str, + target_point: tuple[float, float, float], + ) -> str: + plan = self.edge_endpoint_move_plan(edge_id, endpoint_role, target_point) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + if plan.get("resize_strategy") != "local-edge-endpoint-deform": + raise ValueError("Unsupported Edge endpoint move strategy.") + + self._apply_local_edge_deform(plan) + result_check = self._edge_length_result_summary(plan) + return ( + "Edge endpoint move completed by local edge deformation: " + f"edge {edge_id}, " + f"endpoint={plan.get('edge_endpoint_role')}, " + f"current_endpoint={plan.get('current_endpoint_point')}, " + f"target_endpoint={plan.get('target_endpoint_point')}, " + f"move={plan.get('moved_endpoint_delta')}, " + f"current_length={float(plan['current_length']):g}, " + f"target_length={float(plan['target_length']):g}, " + f"delta={float(plan['delta_length']):g}, " + f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, " + f"target={plan.get('local_edge_deform_target_kind')}, " + f"risk={plan['risk']}. {result_check}" + ) + + def move_edge_center( + self, + edge_id: int, + target_center: tuple[float, float, float], + ) -> str: + plan = self.edge_center_move_plan(edge_id, target_center) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + if plan.get("resize_strategy") != "local-edge-center-deform": + raise ValueError("Unsupported Edge center move strategy.") + + self._apply_local_edge_deform(plan) + result_check = self._edge_length_result_summary(plan) + return ( + "Edge center move completed by local edge deformation: " + f"edge {edge_id}, " + f"current_center={plan.get('current_edge_center')}, " + f"target_center={plan.get('target_edge_center')}, " + f"move={plan.get('moved_edge_center_delta')}, " + f"edge_length={float(plan['current_length']):g}, " + f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, " + f"target={plan.get('local_edge_deform_target_kind')}, " + f"risk={plan['risk']}. {result_check}" + ) + def _edge_length_result_summary(self, plan: dict[str, object]) -> str: check = self._edge_length_result_check(plan) if check is None: @@ -1701,13 +2842,19 @@ class OperationMixin: if start is None or end is None: return None strategy = str(plan.get("resize_strategy", "")) - if strategy == "local-edge-only-deform": + if strategy in {"local-edge-only-deform", "local-edge-endpoint-deform", "local-edge-center-deform"}: start_move = _tuple_or_none(plan.get("local_edge_deform_start_move")) or (0.0, 0.0, 0.0) end_move = _tuple_or_none(plan.get("local_edge_deform_end_move")) or (0.0, 0.0, 0.0) + if strategy == "local-edge-endpoint-deform": + match_label = "endpoint-local-coordinate" + elif strategy == "local-edge-center-deform": + match_label = "endpoint-local-center" + else: + match_label = "endpoint-local" return ( (start[0] + start_move[0], start[1] + start_move[1], start[2] + start_move[2]), (end[0] + end_move[0], end[1] + end_move[1], end[2] + end_move[2]), - "endpoint-local", + match_label, ) if strategy == "move-edge-end-plane-by-push-pull": movement = _tuple_or_none(plan.get("desired_movement_vector")) @@ -2426,9 +3573,631 @@ class OperationMixin: f"risk={plan['risk']}." ) + def chamfer_edge_asymmetric( + self, + edge_id: int, + distance1: float, + distance2: float, + reference_face_id: int | None = None, + ) -> str: + plan = self.edge_asymmetric_chamfer_plan(edge_id, distance1, distance2, reference_face_id) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + part_id = self.edge_part_ids[edge_id] + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + resolved_reference_face_id = int(plan["reference_face_id"]) + + maker = BRepFilletAPI_MakeChamfer(part.shape) + try: + maker.Add( + float(distance1), + float(distance2), + topods.Edge(self.edges[edge_id]), + topods.Face(self.faces[resolved_reference_face_id]), + ) + except TypeError as exc: + raise RuntimeError("The current OCCT binding does not support asymmetric chamfer Add(D1, D2, Edge, Face).") from exc + result = _finalize_builder_result(maker, "asymmetric edge chamfer") + part.shape = result + self.refresh_topology() + return ( + f"Asymmetric Edge chamfer completed: edge {edge_id}, " + f"distance1={float(distance1):g}, distance2={float(distance2):g}, " + f"reference_face={resolved_reference_face_id}, " + f"edge_length={float(plan['edge_length']):g}, " + f"distance1_to_length_ratio={float(plan['distance1_to_length_ratio']):g}, " + f"distance2_to_length_ratio={float(plan['distance2_to_length_ratio']):g}, " + f"risk={plan['risk']}." + ) + + def chamfer_edge_distance_angle( + self, + edge_id: int, + distance: float, + angle_degrees: float, + reference_face_id: int | None = None, + ) -> str: + plan = self.edge_distance_angle_chamfer_plan(edge_id, distance, angle_degrees, reference_face_id) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + part_id = self.edge_part_ids[edge_id] + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + resolved_reference_face_id = int(plan["reference_face_id"]) + + maker = BRepFilletAPI_MakeChamfer(part.shape) + try: + maker.AddDA( + float(distance), + float(plan["target_angle_radians"]), + topods.Edge(self.edges[edge_id]), + topods.Face(self.faces[resolved_reference_face_id]), + ) + except AttributeError as exc: + raise RuntimeError("The current OCCT binding does not support distance-angle chamfer AddDA(D, Angle, Edge, Face).") from exc + except TypeError as exc: + raise RuntimeError("The current OCCT binding rejected distance-angle chamfer AddDA(D, Angle, Edge, Face).") from exc + result = _finalize_builder_result(maker, "distance-angle edge chamfer") + part.shape = result + self.refresh_topology() + return ( + f"Distance-angle Edge chamfer completed: edge {edge_id}, " + f"distance={float(distance):g}, angle_degrees={float(angle_degrees):g}, " + f"reference_face={resolved_reference_face_id}, " + f"edge_length={float(plan['edge_length']):g}, " + f"distance_to_length_ratio={float(plan['distance_to_length_ratio']):g}, " + f"risk={plan['risk']}." + ) + def enlarge_cylindrical_hole(self, face_id: int, new_diameter: float) -> str: return self.resize_cylindrical_hole(face_id, new_diameter) + def resize_cylindrical_slot_width( + self, + face_id: int, + target_width: float, + pair_face_id: int | None = None, + ) -> str: + return self._resize_cylindrical_slot_parameter(face_id, target_width, "width", pair_face_id=pair_face_id) + + def resize_cylindrical_slot_depth( + self, + face_id: int, + target_depth: float, + pair_face_id: int | None = None, + ) -> str: + return self._resize_cylindrical_slot_parameter(face_id, target_depth, "depth", pair_face_id=pair_face_id) + + def resize_cylindrical_slot_arc_length( + self, + face_id: int, + target_arc_length: float, + pair_face_id: int | None = None, + ) -> str: + return self._resize_cylindrical_slot_parameter(face_id, target_arc_length, "arc_length", pair_face_id=pair_face_id) + + def resize_cylindrical_slot_angular_span(self, face_id: int, target_angular_span: float) -> str: + plan = self.cylindrical_slot_angular_span_plan(face_id, target_angular_span) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + return self._resize_cylindrical_slot_angular_span_with_sector_tool(plan) + + def resize_cylindrical_slot_total_length( + self, + face_id: int, + target_total_length: float, + pair_face_id: int | None = None, + ) -> str: + plan = self.cylindrical_slot_total_length_plan(face_id, target_total_length, pair_face_id=pair_face_id) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + return self._resize_cylindrical_slot_length_with_capsule_tool(plan) + + def _resize_cylindrical_slot_parameter( + self, + face_id: int, + target_value: float, + mode: str, + pair_face_id: int | None = None, + ) -> str: + plan = self.cylindrical_slot_resize_plan(face_id, target_value, mode, pair_face_id=pair_face_id) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + target_diameter = float(plan["slot_target_diameter"]) + if plan.get("slot_resize_strategy") == "paired-obround-slot-prism": + resize_result = self._resize_cylindrical_slot_with_capsule_tool(plan) + tool_path = "paired-obround-slot-prism" + else: + resize_result = self._resize_cylindrical_slot_with_sector_tool(plan) + tool_path = "slot-sector" + mode_key = str(plan.get("slot_resize_mode") or mode) + if mode_key == "depth": + current_value = plan.get("slot_current_depth") + final_value = plan.get("slot_target_depth") + label = "depth" + elif mode_key == "arc_length": + current_value = plan.get("slot_current_arc_length") + final_value = plan.get("slot_target_arc_length") + label = "arc_length" + else: + current_value = plan.get("slot_current_width") + final_value = plan.get("slot_target_width") + label = "width" + + def fmt(value: object) -> str: + try: + return f"{float(value):g}" + except (TypeError, ValueError): + return "" + + return ( + f"Cylindrical slot {label} resize completed: face {face_id}, " + f"{label}={fmt(current_value)}->{fmt(final_value)}, " + f"derived_diameter={target_diameter:g}, " + f"angular_span={fmt(plan.get('slot_angular_span'))}, " + f"strategy={plan.get('slot_resize_strategy')}, " + f"tool_path={tool_path}, " + f"risk={plan['risk']}. {resize_result}" + ) + + def _resize_cylindrical_slot_length_with_capsule_tool(self, plan: dict[str, object]) -> str: + face_id = int(plan["face_id"]) + part_id = self.face_part_ids[face_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 + source_shape = part.shape + target_plan = dict(plan) + target_plan["slot_capsule_start_center_1"] = plan.get("slot_capsule_target_start_center_1") + target_plan["slot_capsule_start_center_2"] = plan.get("slot_capsule_target_start_center_2") + try: + filler = self._slot_capsule_prism_tool(plan, float(plan["fill_radius"])) + fuse = BRepAlgoAPI_Fuse(part.shape, filler) + source_shape = _finalize_boolean_result(fuse, "obround slot length fill/fuse") + + cutter = self._slot_capsule_prism_tool(target_plan, float(plan["slot_target_diameter"]) / 2.0) + op = BRepAlgoAPI_Cut(source_shape, cutter) + result = _finalize_boolean_result(op, "obround slot length cut") + part.shape = result + self.refresh_topology() + verification = self._verify_obround_slot_length_result(plan, part_id) + if not verification["matched"]: + raise RuntimeError(str(verification.get("detail", "obround slot length result verification failed"))) + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise + + return ( + "Obround cylindrical slot total length resize completed: " + f"length {float(plan.get('slot_current_total_length', 0.0)):g} -> {float(plan['slot_target_total_length']):g}, " + f"center_distance={float(plan['slot_current_center_distance']):g}->{float(plan['slot_target_center_distance']):g}, " + f"diameter={float(plan['slot_target_diameter']):g}, " + f"paired_face={plan.get('slot_pair_face_id', '')}, " + f"mode={plan['resize_mode']}, action=capsule fill and recut, " + f"verified_face={verification.get('face_id', '')}." + ) + + def _resize_cylindrical_slot_angular_span_with_sector_tool(self, plan: dict[str, object]) -> str: + face_id = int(plan["face_id"]) + part_id = self.face_part_ids[face_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 + source_shape = part.shape + try: + filler = self._slot_sector_prism_tool( + face_id, + float(plan["fill_radius"]), + float(plan["fill_start_parameter"]), + float(plan["fill_end_parameter"]), + ) + fuse = BRepAlgoAPI_Fuse(part.shape, filler) + source_shape = _finalize_boolean_result(fuse, "slot angular-span fill/fuse") + + cutter = self._slot_sector_prism_tool( + face_id, + float(plan["slot_target_diameter"]) / 2.0, + float(plan["cutter_start_parameter"]), + float(plan["cutter_end_parameter"]), + u_first_override=float(plan["slot_target_u_first"]), + u_last_override=float(plan["slot_target_u_last"]), + ) + op = BRepAlgoAPI_Cut(source_shape, cutter) + result = _finalize_boolean_result(op, "slot angular-span cut") + part.shape = result + self.refresh_topology() + verification = self._verify_slot_angular_span_result(plan, part_id) + if not verification["matched"]: + raise RuntimeError(str(verification.get("detail", "slot angular-span result verification failed"))) + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise + + return ( + "Cylindrical slot angular-span resize completed: " + f"span {float(plan['slot_current_angular_span']):g} -> {float(plan['slot_target_angular_span']):g}, " + f"degrees={math.degrees(float(plan['slot_current_angular_span'])):g}->{math.degrees(float(plan['slot_target_angular_span'])):g}, " + f"diameter={float(plan['slot_target_diameter']):g}, " + f"mode={plan['resize_mode']}, action=sector fill and recut, " + f"height={float(plan['cutter_height']):g}, " + f"verified_face={verification.get('face_id', '')}." + ) + + def _verify_slot_angular_span_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: + target_span = _float_or_none(plan.get("slot_target_angular_span")) + target_diameter = _float_or_none(plan.get("slot_target_diameter")) + if target_span is None or target_span <= 1e-9 or target_diameter is None or target_diameter <= 1e-9: + return {"matched": False, "detail": " Missing target slot angular-span verification data."} + try: + axis_point = gp_Pnt(*plan["cutter_axis_point"]) + axis_direction = gp_Dir(*plan["cutter_axis_direction"]) + except Exception: + return {"matched": False, "detail": " Missing original slot axis data."} + + target_radius = target_diameter * 0.5 + part = self.part_by_id(part_id) + diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_radius, 1.0) + radius_tolerance = max(target_radius * 0.03, diagonal * 1e-6, 1e-5) + axis_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4) + span_tolerance = max(target_span * 0.08, 0.02) + best: dict[str, object] | None = None + best_score = math.inf + + for face_id, face in enumerate(self.faces): + if self.face_part_ids[face_id] != part_id: + continue + try: + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + continue + cyl = surf.Cylinder() + candidate_radius = float(cyl.Radius()) + radius_error = abs(candidate_radius - target_radius) + axis_dot = abs(_direction_dot(axis_direction, cyl.Axis().Direction())) + if axis_dot < 1.0 - 1e-5: + continue + axis_distance = _point_axis_distance(axis_point, axis_direction, cyl.Axis().Location()) + angular_span = abs(float(surf.LastUParameter()) - float(surf.FirstUParameter())) + span_error = abs(angular_span - target_span) + except Exception: + continue + + score = ( + radius_error / max(radius_tolerance, 1e-9) + + axis_distance / max(axis_tolerance, 1e-9) + + span_error / max(span_tolerance, 1e-9) + ) + candidate = { + "matched": ( + radius_error <= radius_tolerance + and axis_distance <= axis_tolerance + and span_error <= span_tolerance + ), + "face_id": face_id, + "angular_span": angular_span, + "span_error": span_error, + "diameter": candidate_radius * 2.0, + "radius_error": radius_error, + "axis_distance": axis_distance, + "span_tolerance": span_tolerance, + } + if candidate["matched"]: + return candidate + if score < best_score: + best_score = score + best = candidate + + if best is None: + return {"matched": False, "detail": " No cylindrical slot face on the original axis was found after angular-span edit."} + return { + "matched": False, + "detail": ( + f" Closest slot Face {best['face_id']} span {float(best['angular_span']):.6g}, " + f"target {target_span:.6g}, span error {float(best['span_error']):.6g}, " + f"diameter {float(best['diameter']):.6g}." + ), + **best, + } + + def _verify_obround_slot_length_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: + target_distance = _float_or_none(plan.get("slot_target_center_distance")) + target_diameter = _float_or_none(plan.get("slot_target_diameter")) + axis_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_axis_direction"))) + length_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_length_direction"))) + if target_distance is None or target_diameter is None or axis_dir is None or length_dir is None: + return {"matched": False, "detail": " Missing obround slot length verification data."} + target_radius = target_diameter * 0.5 + if target_radius <= 1e-9 or target_distance <= 1e-9: + return {"matched": False, "detail": " Invalid target slot length or diameter."} + + part = self.part_by_id(part_id) + diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_distance, target_radius, 1.0) + radius_tolerance = max(target_radius * 0.03, diagonal * 1e-6, 1e-5) + distance_tolerance = max(target_distance * 0.05, target_radius * 0.08, diagonal * 1e-5, 1e-4) + candidates: list[dict[str, object]] = [] + for face_id, face in enumerate(self.faces): + if self.face_part_ids[face_id] != part_id: + continue + try: + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + continue + cyl = surf.Cylinder() + radius = float(cyl.Radius()) + if abs(radius - target_radius) > radius_tolerance: + continue + candidate_axis = _tuple_normalized(_dir_tuple(cyl.Axis().Direction())) + if candidate_axis is None or abs(_tuple_dot(candidate_axis, axis_dir)) < 1.0 - 1e-4: + continue + axis_range = self._cylindrical_axis_range(face_id, surf) + mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5 + mid = _point_tuple(_point_on_axis(cyl.Axis().Location(), cyl.Axis().Direction(), mid_parameter)) + candidates.append({"face_id": face_id, "mid": mid, "radius": radius}) + except Exception: + continue + + best: dict[str, object] | None = None + best_score = math.inf + for index, first in enumerate(candidates): + for second in candidates[index + 1 :]: + raw_offset = _tuple_sub(second["mid"], first["mid"]) + axis_offset = _tuple_scale(axis_dir, _tuple_dot(raw_offset, axis_dir)) + section_offset = _tuple_sub(raw_offset, axis_offset) + distance = _vector_length(section_offset) + if distance <= 1e-9: + continue + direction = _tuple_normalized(section_offset) + if direction is None or abs(_tuple_dot(direction, length_dir)) < 0.96: + continue + error = abs(distance - target_distance) + score = error / max(distance_tolerance, 1e-9) + candidate = { + "matched": error <= distance_tolerance, + "face_id": first["face_id"], + "paired_face_id": second["face_id"], + "center_distance": distance, + "target_center_distance": target_distance, + "center_distance_error": error, + "center_distance_tolerance": distance_tolerance, + } + if candidate["matched"]: + return candidate + if score < best_score: + best_score = score + best = candidate + + if best is None: + return {"matched": False, "detail": " No paired cylindrical slot ends matching the target radius were found."} + return { + "matched": False, + "detail": ( + f" Closest paired slot center distance {float(best['center_distance']):.6g}, " + f"target {target_distance:.6g}, error {float(best['center_distance_error']):.6g}." + ), + **best, + } + + def _resize_cylindrical_slot_with_capsule_tool(self, plan: dict[str, object]) -> str: + face_id = int(plan["face_id"]) + face = self.faces[face_id] + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + raise ValueError("Obround slot resize currently supports cylindrical faces only.") + + cyl = surf.Cylinder() + old_radius = float(cyl.Radius()) + new_radius = float(plan["slot_target_diameter"]) / 2.0 + if new_radius <= 0: + raise ValueError("Target slot diameter must be greater than 0.") + + part_id = self.face_part_ids[face_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 + source_shape = part.shape + try: + if plan["resize_mode"] == "shrink": + filler = self._slot_capsule_prism_tool(plan, float(plan["fill_radius"])) + fuse = BRepAlgoAPI_Fuse(part.shape, filler) + source_shape = _finalize_boolean_result(fuse, "obround slot fill/fuse") + + cutter = self._slot_capsule_prism_tool(plan, new_radius) + op = BRepAlgoAPI_Cut(source_shape, cutter) + result = _finalize_boolean_result(op, "obround slot cut") + part.shape = result + self.refresh_topology() + verification = self._verify_cylindrical_resize_result(plan, part_id) + if not verification["matched"]: + raise RuntimeError(str(verification.get("detail", "obround slot result verification failed"))) + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise + + action = "capsule cut" if plan["resize_mode"] == "enlarge" else "capsule fill and recut" + return ( + "Obround cylindrical slot resize completed: " + f"diameter {old_radius * 2.0:g} -> {float(plan['slot_target_diameter']):g}, " + f"paired_face={plan.get('slot_pair_face_id', '')}, " + f"axis_distance={float(plan.get('slot_pair_axis_distance', 0.0)):g}, " + f"mode={plan['resize_mode']}, action={action}, " + f"height={float(plan['cutter_height']):g}, " + f"verified_face={verification.get('face_id', '')}." + ) + + def _slot_capsule_prism_tool(self, plan: dict[str, object], radius: float) -> TopoDS_Shape: + if radius <= 1e-9: + raise ValueError("Slot capsule radius must be greater than 0.") + center_1 = _tuple_or_none(plan.get("slot_capsule_start_center_1")) + center_2 = _tuple_or_none(plan.get("slot_capsule_start_center_2")) + axis_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_axis_direction"))) + length_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_length_direction"))) + side_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_side_direction"))) + if center_1 is None or center_2 is None or axis_dir is None or length_dir is None or side_dir is None: + raise ValueError("Obround slot tool is missing capsule frame information.") + center_distance = _vector_length(_tuple_sub(center_2, center_1)) + if center_distance <= 1e-9: + raise ValueError("Obround slot tool requires two distinct slot centers.") + if abs(_tuple_dot(axis_dir, length_dir)) > 1e-4 or abs(_tuple_dot(axis_dir, side_dir)) > 1e-4: + raise ValueError("Obround slot capsule frame is not perpendicular to the extrusion axis.") + if abs(_tuple_dot(length_dir, side_dir)) > 1e-4: + raise ValueError("Obround slot capsule frame length/side axes are not perpendicular.") + + sample_count = max(12, min(96, int(math.pi * max(radius, 1.0) / max(radius * 0.12, 0.02)))) + points: list[tuple[float, float, float]] = [ + _tuple_add(center_1, _tuple_scale(side_dir, radius)), + _tuple_add(center_2, _tuple_scale(side_dir, radius)), + ] + for index in range(1, sample_count + 1): + theta = math.pi / 2.0 - math.pi * index / sample_count + radial = _tuple_add(_tuple_scale(length_dir, math.cos(theta) * radius), _tuple_scale(side_dir, math.sin(theta) * radius)) + points.append(_tuple_add(center_2, radial)) + points.append(_tuple_add(center_1, _tuple_scale(side_dir, -radius))) + for index in range(1, sample_count + 1): + theta = -math.pi / 2.0 - math.pi * index / sample_count + radial = _tuple_add(_tuple_scale(length_dir, math.cos(theta) * radius), _tuple_scale(side_dir, math.sin(theta) * radius)) + points.append(_tuple_add(center_1, radial)) + + tool_face = self._make_local_polygon_face(self._dedupe_local_points(points, max(radius * 1e-7, 1e-7))) + height = max(float(plan.get("cutter_height", 0.0)), 1e-6) + vec = gp_Vec(axis_dir[0] * height, axis_dir[1] * height, axis_dir[2] * height) + return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, vec), "obround slot capsule prism") + + def _resize_cylindrical_slot_with_sector_tool(self, plan: dict[str, object]) -> str: + face_id = int(plan["face_id"]) + face = self.faces[face_id] + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + raise ValueError("Slot resize currently supports cylindrical faces only.") + + cyl = surf.Cylinder() + old_radius = cyl.Radius() + new_radius = float(plan["slot_target_diameter"]) / 2.0 + if new_radius <= 0: + raise ValueError("Target slot diameter must be greater than 0.") + + part_id = self.face_part_ids[face_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 + source_shape = part.shape + try: + if plan["resize_mode"] == "shrink": + filler = self._slot_sector_prism_tool( + face_id, + float(plan["fill_radius"]), + float(plan["fill_start_parameter"]), + float(plan["fill_end_parameter"]), + ) + fuse = BRepAlgoAPI_Fuse(part.shape, filler) + source_shape = _finalize_boolean_result(fuse, "slot sector fill/fuse") + + cutter = self._slot_sector_prism_tool( + face_id, + new_radius, + float(plan["cutter_start_parameter"]), + float(plan["cutter_end_parameter"]), + ) + op = BRepAlgoAPI_Cut(source_shape, cutter) + result = _finalize_boolean_result(op, "slot sector cut") + part.shape = result + self.refresh_topology() + verification = self._verify_cylindrical_resize_result(plan, part_id) + if not verification["matched"]: + raise RuntimeError(str(verification.get("detail", "slot sector result verification failed"))) + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise + + action = "sector cut" if plan["resize_mode"] == "enlarge" else "sector fill and recut" + return ( + "Cylindrical slot sector resize completed: " + f"diameter {old_radius * 2.0:g} -> {float(plan['slot_target_diameter']):g}, " + f"mode={plan['resize_mode']}, action={action}, " + f"height={float(plan['cutter_height']):g}, " + f"verified_face={verification.get('face_id', '')}." + ) + + def _slot_sector_prism_tool( + self, + face_id: int, + radius: float, + start_parameter: float, + end_parameter: float, + *, + u_first_override: float | None = None, + u_last_override: float | None = None, + ) -> TopoDS_Shape: + face = self.faces[face_id] + surf = BRepAdaptor_Surface(face) + if surf.GetType() != GeomAbs_Cylinder: + raise ValueError("Slot sector tool requires a cylindrical face.") + if radius <= 1e-9: + raise ValueError("Slot sector tool radius must be greater than 0.") + + cyl = surf.Cylinder() + axis = cyl.Axis() + axis_point = axis.Location() + direction = axis.Direction() + u_first = float(surf.FirstUParameter()) if u_first_override is None else float(u_first_override) + u_last = float(surf.LastUParameter()) if u_last_override is None else float(u_last_override) + span = abs(u_last - u_first) + if span <= 1e-6 or span >= math.tau * 0.98: + raise ValueError("Slot sector tool requires a stable partial-cylinder U span.") + if end_parameter < start_parameter: + start_parameter, end_parameter = end_parameter, start_parameter + height = max(float(end_parameter) - float(start_parameter), 1e-6) + + pad = min(max(1e-4, 0.001 / max(float(radius), 1e-6)), max(span * 0.02, 1e-4)) + if u_last >= u_first: + u_start = u_first - pad + u_end = u_last + pad + else: + u_start = u_first + pad + u_end = u_last - pad + sample_count = max(8, min(96, int(abs(u_end - u_start) / (math.pi / 36.0)) + 2)) + v_mid = (float(surf.FirstVParameter()) + float(surf.LastVParameter())) / 2.0 + axis_start = _point_on_axis(axis_point, direction, float(start_parameter)) + axis_start_tuple = _point_tuple(axis_start) + points: list[tuple[float, float, float]] = [axis_start_tuple] + for index in range(sample_count): + u = u_start + (u_end - u_start) * index / max(sample_count - 1, 1) + source = surf.Value(u, v_mid) + source_parameter = _axis_parameter(axis_point, direction, source) + source_axis = _point_on_axis(axis_point, direction, source_parameter) + radial = _tuple_sub(_point_tuple(source), _point_tuple(source_axis)) + unit = _tuple_normalized(radial) + if unit is None: + raise ValueError("Could not derive slot sector radial direction.") + points.append( + ( + axis_start_tuple[0] + unit[0] * float(radius), + axis_start_tuple[1] + unit[1] * float(radius), + axis_start_tuple[2] + unit[2] * float(radius), + ) + ) + + tool_face = self._make_local_polygon_face(points) + vec = gp_Vec(direction.X() * height, direction.Y() * height, direction.Z() * height) + return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, vec), "slot sector prism") + def resize_cylindrical_hole(self, face_id: int, new_diameter: float) -> str: plan = self.cylindrical_resize_plan(face_id, new_diameter) if plan["status"] == "blocked": @@ -2491,6 +4260,67 @@ class OperationMixin: f"verified_face={verification.get('face_id', '')}." ) + def move_cylindrical_hole_axis( + self, + face_id: int, + target_center: tuple[float, float, float], + ) -> str: + plan = self.cylindrical_axis_move_plan(face_id, target_center) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + part_id = self.face_part_ids[face_id] + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + + direction = gp_Dir(*plan["cutter_axis_direction"]) + old_part_shape = part.shape + try: + fill_start = gp_Pnt(*plan["fill_start_point"]) + fill_axis = gp_Ax2(fill_start, direction) + filler = BRepPrimAPI_MakeCylinder( + fill_axis, + float(plan["fill_radius"]), + float(plan["fill_height"]), + ).Shape() + fuse = BRepAlgoAPI_Fuse(part.shape, filler) + filled_shape = _finalize_boolean_result(fuse, "cylinder axis move old-hole fill/fuse") + + cutter_start = gp_Pnt(*plan["target_cutter_start_point"]) + cutter_axis = gp_Ax2(cutter_start, direction) + cutter = BRepPrimAPI_MakeCylinder( + cutter_axis, + float(plan["target_radius"]), + float(plan["cutter_height"]), + ).Shape() + op = BRepAlgoAPI_Cut(filled_shape, cutter) + result = _finalize_boolean_result(op, "cylinder axis move target-hole cut") + part.shape = result + self.refresh_topology() + + verification_plan = dict(plan) + verification_plan["cutter_axis_point"] = plan["target_cutter_axis_point"] + verification_plan["cutter_start_point"] = plan["target_cutter_start_point"] + verification = self._verify_cylindrical_resize_result(verification_plan, part_id) + if not verification["matched"]: + raise RuntimeError(str(verification.get("detail", "cylinder axis move result verification failed"))) + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise + + return ( + "Cylindrical hole axis move completed: " + f"face {face_id}, " + f"center={plan.get('current_axis_center')}->{plan.get('target_axis_center')}, " + f"move={plan.get('axis_move_vector')}, " + f"diameter={float(plan['target_diameter']):g}, " + f"height={float(plan['cutter_height']):g}, " + f"risk={plan['risk']}, " + f"verified_face={verification.get('face_id', '')}." + ) + def _verify_cylindrical_resize_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: target_diameter = float(plan.get("target_diameter", 0.0)) target_radius = target_diameter / 2.0 @@ -2602,6 +4432,91 @@ class OperationMixin: f"height={float(plan['boss_tool_height']):g}." ) + def move_cylindrical_boss_axis( + self, + face_id: int, + target_center: tuple[float, float, float], + ) -> str: + plan = self.cylindrical_boss_axis_move_plan(face_id, target_center) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + + part_id = self.face_part_ids[face_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 + verification: dict[str, object] = {} + try: + direction = gp_Dir(*plan["boss_tool_axis_direction"]) + height = float(plan["boss_tool_height"]) + removal_start = gp_Pnt(*plan["boss_tool_start_point"]) + removal_axis = gp_Ax2(removal_start, direction) + removal_radius = float( + plan.get("boss_tool_outer_radius") + or plan.get("boss_tool_old_radius") + or plan.get("target_radius") + ) + removal = BRepPrimAPI_MakeCylinder(removal_axis, removal_radius, height).Shape() + remove_op = BRepAlgoAPI_Cut(part.shape, removal) + removed = _finalize_boolean_result(remove_op, "cylindrical boss axis move remove old envelope") + + target_start_values = _tuple_or_none(plan.get("target_boss_tool_start_point")) + target_height = height + if _topology_shape_count(removed, TopAbs_FACE) == 0: + exact_start = _tuple_or_none(plan.get("target_boss_tool_exact_start_point")) + if exact_start is not None: + target_start_values = exact_start + target_height = float(plan.get("boss_tool_exact_height") or height) + if target_start_values is None: + raise ValueError("Could not derive target boss tool start point.") + + target_start = gp_Pnt(*target_start_values) + target_axis = gp_Ax2(target_start, direction) + replacement = BRepPrimAPI_MakeCylinder( + target_axis, + float(plan["target_boss_tool_radius"]), + max(target_height, 1e-6), + ).Shape() + if _topology_shape_count(removed, TopAbs_FACE) == 0: + result = _ensure_valid_or_repaired_shape(replacement, "cylindrical boss axis move replacement") + action = "rebuilt moved cylinder" + else: + fuse_op = BRepAlgoAPI_Fuse(removed, replacement) + result = _finalize_boolean_result(fuse_op, "cylindrical boss axis move fuse target cylinder") + action = "removed old envelope and fused moved cylinder" + + part.shape = result + self.refresh_topology() + + verification_plan = dict(plan) + verification_plan["cutter_axis_point"] = plan["target_boss_tool_axis_point"] + verification_plan["cutter_axis_direction"] = plan["boss_tool_axis_direction"] + verification = self._verify_cylindrical_resize_result(verification_plan, part_id) + if not verification["matched"]: + detail = str(verification.get("detail", "cylindrical boss axis move result verification failed")) + raise RuntimeError( + "圆柱凸台轴心坐标布尔计算返回了结果,但结果里没有检测到目标轴心位置的圆柱凸台," + "已回滚到修改前状态。" + f"{detail}" + ) + except Exception: + part.shape = old_part_shape + self.refresh_topology() + raise + + return ( + "Cylindrical boss axis move completed: " + f"face {face_id}, " + f"center={plan.get('current_axis_center')}->{plan.get('target_axis_center')}, " + f"move={plan.get('axis_move_vector')}, " + f"diameter={float(plan['target_diameter']):g}, " + f"height={float(plan['boss_tool_height']):g}, " + f"risk={plan['risk']}, action={action}, " + f"verified_face={verification.get('face_id', '')}." + ) + def suppress_cylindrical_hole(self, face_id: int) -> str: plan = self.cylindrical_suppress_plan(face_id) if plan["status"] == "blocked": diff --git a/step_editor/transforms.py b/step_editor/transforms.py index b943d3e..448af7c 100644 --- a/step_editor/transforms.py +++ b/step_editor/transforms.py @@ -262,3 +262,300 @@ class TransformMixin: f"risk={plan['risk']}." ) + def scale_part_plan(self, part_id: int, target_diagonal: float) -> dict[str, object]: + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + return self._scale_shape_plan( + target_kind="part", + part_id=part.id, + solid_id=-1, + shape=part.shape, + target_diagonal=target_diagonal, + part_solid_count=len(_explore(part.shape, TopAbs_SOLID)), + ) + + def scale_solid_plan(self, solid_id: int, target_diagonal: float) -> dict[str, object]: + if solid_id < 0 or solid_id >= len(self.solids): + raise ValueError(f"Unknown solid id {solid_id}") + part_id, solid = self.solids[solid_id] + part = self.part_by_id(part_id) + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0 + plan = self._scale_shape_plan( + target_kind="solid", + part_id=part_id, + solid_id=solid_id, + shape=solid, + target_diagonal=target_diagonal, + part_solid_count=part_solid_count, + ) + if part_solid_count <= 1 and plan["status"] != "blocked": + plan["warnings"] = _join_nonempty( + plan.get("warnings", ""), + "当前零件只有一个Solid,缩放Solid实际会缩放整个零件 shape。", + ) + if plan["risk"] == "low": + plan["risk"] = "medium" + plan["status"] = "caution" + plan["message"] = _join_nonempty(plan.get("message", ""), plan.get("warnings", "")) + return plan + + def scale_part_axis_plan(self, part_id: int, axis: str, target_size: float) -> dict[str, object]: + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + return self._axis_scale_shape_plan( + target_kind="part", + part_id=part.id, + solid_id=-1, + shape=part.shape, + axis=axis, + target_size=target_size, + part_solid_count=len(_explore(part.shape, TopAbs_SOLID)), + ) + + def scale_solid_axis_plan(self, solid_id: int, axis: str, target_size: float) -> dict[str, object]: + if solid_id < 0 or solid_id >= len(self.solids): + raise ValueError(f"Unknown solid id {solid_id}") + part_id, solid = self.solids[solid_id] + part = self.part_by_id(part_id) + part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0 + plan = self._axis_scale_shape_plan( + target_kind="solid", + part_id=part_id, + solid_id=solid_id, + shape=solid, + axis=axis, + target_size=target_size, + part_solid_count=part_solid_count, + ) + if part_solid_count <= 1 and plan["status"] != "blocked": + plan["warnings"] = _join_nonempty( + plan.get("warnings", ""), + "当前零件只有一个Solid,按轴缩放Solid实际会缩放整个零件shape。", + ) + if plan["risk"] == "low": + plan["risk"] = "medium" + plan["status"] = "caution" + plan["message"] = _join_nonempty(plan.get("message", ""), plan.get("warnings", "")) + return plan + + def _scale_shape_plan( + self, + *, + target_kind: str, + part_id: int, + solid_id: int, + shape: TopoDS_Shape, + target_diagonal: float, + part_solid_count: int, + ) -> dict[str, object]: + blockers: list[str] = [] + warnings: list[str] = [] + try: + target_diagonal = float(target_diagonal) + except (TypeError, ValueError): + target_diagonal = 0.0 + blockers.append("目标包围盒对角线必须是数字。") + current_diagonal = _shape_diagonal(shape) + center = _shape_center(shape) + if current_diagonal <= 1e-9: + blockers.append("当前对象包围盒对角线无效,不能缩放。") + if target_diagonal <= 1e-9: + blockers.append("目标包围盒对角线必须大于 0。") + scale = target_diagonal / max(current_diagonal, 1e-9) + delta_ratio = abs(target_diagonal - current_diagonal) / max(current_diagonal, 1e-9) + risk = "low" + status = "ready" + if abs(target_diagonal - current_diagonal) <= max(current_diagonal * 1e-6, 1e-6): + blockers.append("目标包围盒对角线与当前值几乎相同,不需要缩放。") + elif delta_ratio > 1.0: + risk = "high" + status = "caution" + warnings.append("目标尺寸变化超过当前包围盒对角线的 100%,请确认单位和目标。") + elif delta_ratio > 0.35: + risk = "medium" + status = "caution" + warnings.append("目标尺寸变化超过当前包围盒对角线的 35%,请确认缩放结果。") + if blockers: + risk = "blocked" + status = "blocked" + return { + "status": status, + "risk": risk, + "message": _join_nonempty(" ".join(blockers), " ".join(warnings)) or "可以按目标包围盒对角线等比缩放当前对象。", + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "target_kind": target_kind, + "part_id": part_id, + "solid_id": solid_id, + "part_solid_count": part_solid_count, + "current_bbox_diagonal": current_diagonal, + "target_bbox_diagonal": target_diagonal, + "scale_factor": scale, + "scale_delta_ratio": delta_ratio, + "scale_center": center, + "resize_strategy": "uniform-scale-selected-shape-to-bbox-diagonal", + } + + def _axis_scale_shape_plan( + self, + *, + target_kind: str, + part_id: int, + solid_id: int, + shape: TopoDS_Shape, + axis: str, + target_size: float, + part_solid_count: int, + ) -> dict[str, object]: + axis = str(axis or "X").strip().upper() + axis_index = {"X": 0, "Y": 1, "Z": 2}.get(axis) + blockers: list[str] = [] + warnings: list[str] = [ + "按单轴尺寸缩放是 B-Rep 仿射编辑,可能把解析几何转换成 B-spline 或影响同一对象上的其它特征。" + ] + try: + target_size = float(target_size) + except (TypeError, ValueError): + target_size = 0.0 + blockers.append("目标轴向尺寸必须是数字。") + bounds = _shape_bounds_info(shape) + bbox_size = tuple(bounds.get("bbox_size", (0.0, 0.0, 0.0))) + current_size = float(bbox_size[axis_index]) if axis_index is not None else 0.0 + center = _shape_center(shape) + if axis_index is None: + blockers.append("缩放轴必须是 X、Y 或 Z。") + if current_size <= 1e-9: + blockers.append("当前对象在该轴向的包围盒尺寸无效,不能按轴缩放。") + if target_size <= 1e-9: + blockers.append("目标轴向尺寸必须大于 0。") + scale = target_size / max(current_size, 1e-9) + delta_ratio = abs(target_size - current_size) / max(current_size, 1e-9) + risk = "medium" + status = "caution" + if abs(target_size - current_size) <= max(current_size * 1e-6, 1e-6): + blockers.append("目标轴向尺寸与当前值几乎相同,不需要缩放。") + elif delta_ratio > 1.0: + risk = "high" + warnings.append("目标轴向尺寸变化超过当前尺寸的 100%,请确认单位和目标。") + elif delta_ratio <= 0.2: + risk = "medium" + if blockers: + risk = "blocked" + status = "blocked" + return { + "status": status, + "risk": risk, + "message": _join_nonempty(" ".join(blockers), " ".join(warnings)) or "可以按目标轴向尺寸缩放当前对象。", + "warnings": ";".join(warnings), + "blockers": ";".join(blockers), + "target_kind": target_kind, + "part_id": part_id, + "solid_id": solid_id, + "part_solid_count": part_solid_count, + "scale_axis": axis, + "current_axis_size": current_size, + "target_axis_size": target_size, + "axis_scale_factor": scale, + "axis_scale_delta_ratio": delta_ratio, + "scale_center": center, + "bbox_size": bbox_size, + "resize_strategy": "axis-scale-selected-shape-to-bbox-size", + } + + def scale_part(self, part_id: int, target_diagonal: float) -> str: + plan = self.scale_part_plan(part_id, target_diagonal) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + part.shape = _scaled_shape(part.shape, float(plan["scale_factor"]), plan["scale_center"]) + _ensure_valid_shape(part.shape) + self.refresh_topology() + return ( + f"Part scaled: part {part_id}, " + f"bbox_diagonal={float(plan['current_bbox_diagonal']):g}->{float(plan['target_bbox_diagonal']):g}, " + f"scale={float(plan['scale_factor']):g}, risk={plan['risk']}." + ) + + def scale_part_axis(self, part_id: int, axis: str, target_size: float) -> str: + plan = self.scale_part_axis_plan(part_id, axis, target_size) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + part.shape = _axis_scaled_shape(part.shape, str(plan["scale_axis"]), float(plan["axis_scale_factor"]), plan["scale_center"]) + _ensure_valid_shape(part.shape) + self.refresh_topology() + return ( + f"Part axis-scaled: part {part_id}, axis={plan['scale_axis']}, " + f"size={float(plan['current_axis_size']):g}->{float(plan['target_axis_size']):g}, " + f"scale={float(plan['axis_scale_factor']):g}, risk={plan['risk']}." + ) + + def scale_solid(self, solid_id: int, target_diagonal: float) -> str: + plan = self.scale_solid_plan(solid_id, target_diagonal) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + part_id, solid = self.solids[solid_id] + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + part_solids = _explore(part.shape, TopAbs_SOLID) + if len(part_solids) <= 1: + part.shape = _scaled_shape(part.shape, float(plan["scale_factor"]), plan["scale_center"]) + else: + scaled = _scaled_shape(solid, float(plan["scale_factor"]), plan["scale_center"]) + replaced = False + shapes: list[TopoDS_Shape] = [] + for item in part_solids: + if not replaced and _same_shape(item, solid): + shapes.append(scaled) + replaced = True + else: + shapes.append(item) + if not replaced: + raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.") + part.shape = _compound_from_shapes(shapes) + _ensure_valid_shape(part.shape) + self.refresh_topology() + return ( + f"Solid scaled: solid {solid_id}, part {part_id}, " + f"bbox_diagonal={float(plan['current_bbox_diagonal']):g}->{float(plan['target_bbox_diagonal']):g}, " + f"scale={float(plan['scale_factor']):g}, risk={plan['risk']}." + ) + + def scale_solid_axis(self, solid_id: int, axis: str, target_size: float) -> str: + plan = self.scale_solid_axis_plan(solid_id, axis, target_size) + if plan["status"] == "blocked": + raise ValueError(str(plan["message"])) + part_id, solid = self.solids[solid_id] + part = self.part_by_id(part_id) + if part is None: + raise ValueError(f"Unknown part id {part_id}") + part_solids = _explore(part.shape, TopAbs_SOLID) + if len(part_solids) <= 1: + part.shape = _axis_scaled_shape(part.shape, str(plan["scale_axis"]), float(plan["axis_scale_factor"]), plan["scale_center"]) + else: + scaled = _axis_scaled_shape(solid, str(plan["scale_axis"]), float(plan["axis_scale_factor"]), plan["scale_center"]) + replaced = False + shapes: list[TopoDS_Shape] = [] + for item in part_solids: + if not replaced and _same_shape(item, solid): + shapes.append(scaled) + replaced = True + else: + shapes.append(item) + if not replaced: + raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.") + part.shape = _compound_from_shapes(shapes) + _ensure_valid_shape(part.shape) + self.refresh_topology() + return ( + f"Solid axis-scaled: solid {solid_id}, part {part_id}, axis={plan['scale_axis']}, " + f"size={float(plan['current_axis_size']):g}->{float(plan['target_axis_size']):g}, " + f"scale={float(plan['axis_scale_factor']):g}, risk={plan['risk']}." + ) diff --git a/step_editor/ui_helpers.py b/step_editor/ui_helpers.py index da49313..1f528a7 100644 --- a/step_editor/ui_helpers.py +++ b/step_editor/ui_helpers.py @@ -459,6 +459,8 @@ INFO_LABELS = { "target_to_height_ratio": "目标直径/估算高度", "major_radius": "主半径", "minor_radius": "小半径", + "ellipse_edge_major_radius": "椭圆Edge主半径", + "ellipse_edge_minor_radius": "椭圆Edge小半径", "reference_radius": "参考半径", "semi_angle": "半角", "angular_span": "角度跨度", @@ -583,6 +585,28 @@ INFO_LABELS = { "existing_fillet_kind": "已有圆角类型", "existing_fillet_status": "已有圆角识别状态", "existing_fillet_radius_estimate": "已有圆角半径估算", + "feature_reference_radius": "特征参考半径", + "feature_reference_diameter": "特征参考直径", + "feature_sphere_radius": "球面半径", + "feature_sphere_diameter": "球面直径", + "feature_torus_major_radius": "环面主半径", + "feature_torus_minor_radius": "环面小半径", + "current_major_radius": "当前主半径", + "target_major_radius": "目标主半径", + "current_minor_radius": "当前小半径", + "target_minor_radius": "目标小半径", + "torus_radius_mode": "环面半径模式", + "current_area": "当前面积", + "target_area": "目标面积", + "area_delta": "面积变化量", + "area_delta_ratio": "面积变化比例", + "area_center": "面积中心", + "current_reference_radius": "当前参考半径", + "target_reference_radius": "目标参考半径", + "current_reference_diameter": "当前参考直径", + "target_reference_diameter": "目标参考直径", + "delta_reference_radius": "参考半径变化量", + "reference_radius_delta_ratio": "参考半径变化比例", "existing_fillet_angular_span": "已有圆角圆弧角度", "existing_fillet_arc_length_estimate": "已有圆角圆弧长度估算", "existing_fillet_note": "已有圆角识别说明", diff --git a/step_editor/window_actions.py b/step_editor/window_actions.py index bd73a7e..f313b4f 100644 --- a/step_editor/window_actions.py +++ b/step_editor/window_actions.py @@ -309,12 +309,12 @@ class WindowActionMixin: QMessageBox.critical(self, "距离无效", "请输入数字形式的Face偏移距离。") return face_id = self.selected_face_id - plan = self.model.push_pull_plan(face_id, distance) + plan = self._quick_push_pull_plan(face_id, distance) if plan["status"] == "blocked": QMessageBox.information(self, "不能推拉平面", str(plan["message"])) self.statusBar().showMessage("推拉平面已阻止") return - self._show_push_pull_preview(face_id, distance) + self._show_push_pull_preview(face_id, distance, plan=plan) def action(): return self.model.push_pull_face(face_id, distance) @@ -330,6 +330,8 @@ class WindowActionMixin: "distance_rule": "positive=outward fuse, negative=inward cut", "surface": plan.get("surface"), "outward_direction": plan.get("outward_direction"), + "current_plane_position": plan.get("current_plane_position"), + "target_plane_position": plan.get("target_plane_position"), "direction_confidence": plan.get("direction_confidence"), "direction_note": plan.get("direction_note"), "push_pull_risk": plan.get("risk"), @@ -344,6 +346,106 @@ class WindowActionMixin: target_id=face_id, ) + def _quick_push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]: + info = dict(getattr(self, "current_info_values", {}) or {}) + surface = str(info.get("surface", "")) + if not surface and self.model is not None: + surface = self.model.face_surface_kind(face_id) + if surface != "plane": + return { + "status": "blocked", + "risk": "blocked", + "message": "当前选中的 Face 不是平面,不能执行 Face 偏移。", + "face_id": face_id, + "distance": distance, + "surface": surface, + } + + outward = info.get("push_pull_outward_direction") + if not isinstance(outward, (tuple, list)) or len(outward) != 3: + outward = info.get("oriented_normal") or info.get("normal") + if not isinstance(outward, (tuple, list)) or len(outward) != 3: + return { + "status": "blocked", + "risk": "blocked", + "message": "当前平面缺少稳定推拉方向,请重新选择该 Face 后再试。", + "face_id": face_id, + "distance": distance, + "surface": surface, + } + + bbox_diagonal = _float_or_none(info.get("bbox_diagonal")) + distance_abs = abs(float(distance)) + risk = "low" + status = "ready" + warnings: list[str] = [] + if distance_abs <= 1e-9: + return { + "status": "blocked", + "risk": "blocked", + "message": "Face 偏移距离为 0,不需要修改。", + "face_id": face_id, + "distance": distance, + "surface": surface, + } + if bbox_diagonal is not None and bbox_diagonal > 0: + if distance_abs > bbox_diagonal * 0.2: + risk = "high" + status = "caution" + warnings.append("Face 偏移距离相对当前 Face 尺寸较大,布尔失败或大范围变形风险较高。") + elif distance_abs > bbox_diagonal * 0.08: + risk = "medium" + status = "caution" + warnings.append("Face 偏移距离相对当前 Face 尺寸偏大,请确认预览范围。") + + scope_face_ids = ( + _int_values(info.get("push_pull_scope_face_ids")) + or _int_values(info.get("feature_highlight_face_ids")) + or _int_values(info.get("same_domain_face_ids")) + or [face_id] + ) + plane_origin = info.get("plane_origin") + current_plane_position = None + if isinstance(plane_origin, (tuple, list)) and len(plane_origin) == 3: + try: + current_plane_position = sum(float(plane_origin[index]) * float(outward[index]) for index in range(3)) + except (TypeError, ValueError): + current_plane_position = None + target_plane_position = ( + current_plane_position + float(distance) + if current_plane_position is not None + else None + ) + part_id = info.get("part_id") + solid_id = info.get("solid_id") + if part_id is None and self.model is not None and 0 <= face_id < len(self.model.face_part_ids): + part_id = self.model.face_part_ids[face_id] + if solid_id is None and self.model is not None and 0 <= face_id < len(self.model.face_solid_ids): + solid_id = self.model.face_solid_ids[face_id] + message = " ".join(warnings) if warnings else "可以尝试偏移该平面;完整几何检查会在后台执行。" + return { + "status": status, + "risk": risk, + "message": message, + "warnings": ";".join(warnings), + "blockers": "", + "face_id": face_id, + "part_id": part_id, + "solid_id": solid_id, + "distance": distance, + "surface": surface, + "area": info.get("area"), + "bbox_diagonal": bbox_diagonal, + "outward_direction": tuple(float(item) for item in outward), + "current_plane_position": current_plane_position, + "target_plane_position": target_plane_position, + "direction_confidence": info.get("push_pull_confidence", "cached"), + "direction_note": info.get("push_pull_note", "使用当前选中 Face 的缓存方向,完整检查在后台执行。"), + "push_pull_scope_face_ids": tuple(scope_face_ids), + "push_pull_scope_face_count": len(scope_face_ids), + "push_pull_scope_note": "使用当前显示/选中缓存生成快速预览;后台会重新计算真实推拉区域。", + } + def resize_shell_thickness(self) -> None: if self.model is None: return @@ -706,6 +808,88 @@ class WindowActionMixin: target_id=face_id, ) + def move_cylindrical_hole_axis(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_center = ( + float(self.hole_center_x_input.text()), + float(self.hole_center_y_input.text()), + float(self.hole_center_z_input.text()), + ) + except (AttributeError, ValueError): + QMessageBox.critical(self, "孔轴心坐标无效", "请输入 X, Y, Z 三个数字形式的目标坐标。") + return + + face_id = self.selected_face_id + plan = self.model.cylindrical_axis_move_plan(face_id, target_center) + lines = [ + f"Face: {face_id}", + f"当前轴心: {_format_value(plan.get('current_axis_center'))}", + f"目标轴心: {_format_value(plan.get('target_axis_center'))}", + f"移动向量: {_format_value(plan.get('axis_move_vector'))}", + f"移动距离: {_format_value(plan.get('axis_move_distance'))}", + f"孔直径: {_format_value(plan.get('target_diameter'))}", + f"候选判断: {plan.get('feature_guess')}", + f"置信度: {plan.get('confidence')}", + ] + if not self._confirm_quick_edit_plan( + "圆柱孔轴心坐标修改", + plan, + lines, + "圆柱孔轴心坐标修改已阻止", + "已取消圆柱孔轴心坐标修改", + ): + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.move_cylindrical_hole_axis(face_id, target_center) + + 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"), + "surface": plan.get("surface", "cylinder"), + "feature_type": plan.get("feature_type"), + "feature_guess": plan.get("feature_guess"), + "confidence": plan.get("confidence"), + "current_diameter": plan.get("current_diameter"), + "target_diameter": plan.get("target_diameter"), + "current_axis_center": plan.get("current_axis_center"), + "target_axis_center": plan.get("target_axis_center"), + "axis_move_vector": plan.get("axis_move_vector"), + "axis_move_distance": plan.get("axis_move_distance"), + "axis_move_radial_distance": plan.get("axis_move_radial_distance"), + "axis_move_axial_delta": plan.get("axis_move_axial_delta"), + "resize_strategy": plan.get("resize_strategy"), + "move_axis_status": plan.get("status"), + "move_axis_risk": plan.get("risk"), + "move_axis_message": plan.get("message"), + "move_axis_warnings": plan.get("warnings"), + "move_axis_blockers": plan.get("blockers"), + "fill_strategy": plan.get("fill_strategy"), + "fill_height": plan.get("fill_height"), + "fill_radius": plan.get("fill_radius"), + "cutter_strategy": plan.get("cutter_strategy"), + "cutter_height": plan.get("cutter_height"), + "target_cutter_axis_point": plan.get("target_cutter_axis_point"), + "target_cutter_start_point": plan.get("target_cutter_start_point"), + "quick_preflight": True, + "ui_preview": "skipped-to-avoid-ui-freeze", + }, + target_kind="face", + target_id=face_id, + ) + def resize_slot_width(self) -> None: if self.model is None: return @@ -743,7 +927,13 @@ class WindowActionMixin: target_diameter = target_width / sin_half_span current_width = _float_or_none(info.get("slot_chord_width_estimate")) - plan = self._quick_cylinder_resize_plan(face_id, target_diameter, info) + try: + pair_face_id = self._slot_pair_face_override(face_id) + except ValueError as exc: + QMessageBox.critical(self, "槽孔配对端无效", str(exc)) + return + plan = self.model.cylindrical_slot_resize_plan(face_id, target_width, "width", pair_face_id=pair_face_id) + target_diameter = float(plan.get("slot_target_diameter") or plan.get("target_diameter") or target_diameter) lines = [ f"Face: {face_id}", f"当前槽宽估算: {_format_value(current_width)}", @@ -763,7 +953,7 @@ class WindowActionMixin: self.clear_edit_preview(render=False) def action(): - return self.model.resize_cylindrical_hole(face_id, target_diameter) + return self.model.resize_cylindrical_slot_width(face_id, target_width, pair_face_id=pair_face_id) self._run_edit_action( action, @@ -776,6 +966,16 @@ class WindowActionMixin: "old_slot_width": current_width, "slot_width_delta": None if current_width is None else target_width - current_width, "slot_angular_span": angular_span, + "slot_resize_mode": "width", + "slot_resize_strategy": plan.get("slot_resize_strategy"), + "resize_strategy": plan.get("slot_resize_strategy"), + "surface": plan.get("surface", "cylinder"), + "slot_pair_face_id": plan.get("slot_pair_face_id"), + "slot_pair_manual_face_id": pair_face_id, + "slot_pair_manual": plan.get("slot_pair_manual"), + "slot_pair_axis_distance": plan.get("slot_pair_axis_distance"), + "slot_pair_boundary_overlap_count": plan.get("slot_pair_boundary_overlap_count"), + "slot_target_diameter": target_diameter, "derived_new_diameter": target_diameter, "old_diameter": plan.get("current_diameter"), "delta_diameter": plan.get("delta_diameter"), @@ -804,6 +1004,455 @@ class WindowActionMixin: target_id=face_id, ) + def resize_slot_depth(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 + if not hasattr(self, "slot_depth_input"): + return + try: + target_depth = float(self.slot_depth_input.text()) + except ValueError: + QMessageBox.critical(self, "目标槽深无效", "请输入数字形式的目标槽/半孔深度。") + return + if target_depth <= 0: + QMessageBox.critical(self, "目标槽深无效", "目标槽/半孔深度必须大于 0。") + return + + face_id = self.selected_face_id + info = self._selected_face_info_snapshot() + if info.get("surface") != "cylinder" or info.get("slot_kind") != "partial-cylindrical-groove": + QMessageBox.information(self, "不是槽/半孔候选", "当前选中Face没有被识别为槽/半孔候选。") + return + angular_span = _float_or_none(info.get("slot_angular_span")) + if angular_span is None: + angular_span = _float_or_none(info.get("angular_span")) + if angular_span is None or angular_span <= 1e-6 or angular_span >= math.tau * 0.92: + QMessageBox.information(self, "不能调整槽深", "当前候选没有稳定的部分圆柱角度,不能换算槽深。") + return + + sagitta_factor = 1.0 - math.cos(min(max(angular_span, 1e-6), math.pi) / 2.0) + if sagitta_factor <= 1e-6: + QMessageBox.information(self, "不能调整槽深", "当前槽圆弧角度过小,不能可靠换算为圆柱直径。") + return + + target_diameter = 2.0 * target_depth / sagitta_factor + current_depth = _float_or_none(info.get("slot_sagitta_depth_estimate")) + current_width = _float_or_none(info.get("slot_chord_width_estimate")) + try: + pair_face_id = self._slot_pair_face_override(face_id) + except ValueError as exc: + QMessageBox.critical(self, "槽孔配对端无效", str(exc)) + return + plan = self.model.cylindrical_slot_resize_plan(face_id, target_depth, "depth", pair_face_id=pair_face_id) + target_diameter = float(plan.get("slot_target_diameter") or plan.get("target_diameter") or target_diameter) + lines = [ + f"Face: {face_id}", + f"当前槽深估算: {_format_value(current_depth)}", + f"目标槽深: {_format_value(target_depth)}", + f"当前槽宽估算: {_format_value(current_width)}", + f"槽圆弧角度: {_format_value(angular_span)}", + f"换算目标圆柱直径: {_format_value(target_diameter)}", + "当前版本会固定圆弧角度,把槽深换算成圆柱直径后执行孔/槽重建,不是完整 CAD 槽参数编辑。", + ] + if not self._confirm_quick_edit_plan( + "槽/半孔深度调整", + plan, + lines, + "槽/半孔深度调整已阻止", + "已取消槽/半孔深度调整", + ): + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_cylindrical_slot_depth(face_id, target_depth, pair_face_id=pair_face_id) + + 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"), + "new_slot_depth": target_depth, + "old_slot_depth": current_depth, + "slot_depth_delta": None if current_depth is None else target_depth - current_depth, + "old_slot_width": current_width, + "slot_angular_span": angular_span, + "slot_sagitta_factor": sagitta_factor, + "slot_resize_mode": "depth", + "slot_resize_strategy": plan.get("slot_resize_strategy"), + "resize_strategy": plan.get("slot_resize_strategy"), + "surface": plan.get("surface", "cylinder"), + "slot_pair_face_id": plan.get("slot_pair_face_id"), + "slot_pair_manual_face_id": pair_face_id, + "slot_pair_manual": plan.get("slot_pair_manual"), + "slot_pair_axis_distance": plan.get("slot_pair_axis_distance"), + "slot_pair_boundary_overlap_count": plan.get("slot_pair_boundary_overlap_count"), + "slot_target_diameter": target_diameter, + "derived_new_diameter": target_diameter, + "old_diameter": plan.get("current_diameter"), + "delta_diameter": plan.get("delta_diameter"), + "diameter_delta_ratio": plan.get("diameter_delta_ratio"), + "resize_mode": plan.get("resize_mode"), + "feature_type": plan.get("feature_type"), + "feature_guess": plan.get("feature_guess"), + "confidence": plan.get("confidence"), + "slot_status": info.get("slot_status"), + "slot_kind": info.get("slot_kind"), + "feature_slot_boundary_face_ids": info.get("feature_slot_boundary_face_ids"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + "material_vote_summary": plan.get("material_vote_summary"), + "cylinder_end_type": plan.get("cylinder_end_type"), + "hole_depth_estimate": plan.get("hole_depth_estimate"), + "feature_bottom_face_ids": plan.get("feature_bottom_face_ids"), + "feature_opening_face_ids": plan.get("feature_opening_face_ids"), + "quick_preflight": True, + "ui_preview": "skipped-to-avoid-ui-freeze", + }, + target_kind="face", + target_id=face_id, + ) + + def resize_slot_arc_length(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 + if not hasattr(self, "slot_arc_length_input"): + return + try: + target_arc_length = float(self.slot_arc_length_input.text()) + except ValueError: + QMessageBox.critical(self, "目标圆弧长度无效", "请输入数字形式的目标槽/半孔圆弧长度。") + return + if target_arc_length <= 0: + QMessageBox.critical(self, "目标圆弧长度无效", "目标槽/半孔圆弧长度必须大于 0。") + return + + face_id = self.selected_face_id + info = self._selected_face_info_snapshot() + if info.get("surface") != "cylinder" or info.get("slot_kind") != "partial-cylindrical-groove": + QMessageBox.information(self, "不是槽/半孔候选", "当前选中Face没有被识别为槽/半孔候选。") + return + angular_span = _float_or_none(info.get("slot_angular_span")) + if angular_span is None: + angular_span = _float_or_none(info.get("angular_span")) + if angular_span is None or angular_span <= 1e-6 or angular_span >= math.tau * 0.92: + QMessageBox.information(self, "不能调整圆弧长度", "当前候选没有稳定的部分圆柱角度,不能换算圆弧长度。") + return + + target_diameter = 2.0 * target_arc_length / angular_span + current_arc_length = _float_or_none(info.get("slot_arc_length_estimate")) + current_width = _float_or_none(info.get("slot_chord_width_estimate")) + current_depth = _float_or_none(info.get("slot_sagitta_depth_estimate")) + try: + pair_face_id = self._slot_pair_face_override(face_id) + except ValueError as exc: + QMessageBox.critical(self, "槽孔配对端无效", str(exc)) + return + plan = self.model.cylindrical_slot_resize_plan( + face_id, + target_arc_length, + "arc_length", + pair_face_id=pair_face_id, + ) + target_diameter = float(plan.get("slot_target_diameter") or plan.get("target_diameter") or target_diameter) + lines = [ + f"Face: {face_id}", + f"当前圆弧长度估算: {_format_value(current_arc_length)}", + f"目标圆弧长度: {_format_value(target_arc_length)}", + f"当前槽宽估算: {_format_value(current_width)}", + f"当前槽深估算: {_format_value(current_depth)}", + f"槽圆弧角度: {_format_value(angular_span)}", + f"换算目标圆柱直径: {_format_value(target_diameter)}", + "当前版本会固定圆弧角度,把槽/半孔圆弧长度换算成圆柱直径后执行孔/槽重建,不是完整 CAD 槽参数编辑。", + ] + if not self._confirm_quick_edit_plan( + "槽/半孔圆弧长度调整", + plan, + lines, + "槽/半孔圆弧长度调整已阻止", + "已取消槽/半孔圆弧长度调整", + ): + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_cylindrical_slot_arc_length( + face_id, + target_arc_length, + pair_face_id=pair_face_id, + ) + + 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"), + "new_slot_arc_length": target_arc_length, + "old_slot_arc_length": current_arc_length, + "slot_arc_length_delta": None + if current_arc_length is None + else target_arc_length - current_arc_length, + "old_slot_width": current_width, + "old_slot_depth": current_depth, + "slot_angular_span": angular_span, + "slot_resize_mode": "arc_length", + "slot_resize_strategy": plan.get("slot_resize_strategy"), + "resize_strategy": plan.get("slot_resize_strategy"), + "surface": plan.get("surface", "cylinder"), + "slot_pair_face_id": plan.get("slot_pair_face_id"), + "slot_pair_manual_face_id": pair_face_id, + "slot_pair_manual": plan.get("slot_pair_manual"), + "slot_pair_axis_distance": plan.get("slot_pair_axis_distance"), + "slot_pair_boundary_overlap_count": plan.get("slot_pair_boundary_overlap_count"), + "slot_target_diameter": target_diameter, + "derived_new_diameter": target_diameter, + "old_diameter": plan.get("current_diameter"), + "delta_diameter": plan.get("delta_diameter"), + "diameter_delta_ratio": plan.get("diameter_delta_ratio"), + "resize_mode": plan.get("resize_mode"), + "feature_type": plan.get("feature_type"), + "feature_guess": plan.get("feature_guess"), + "confidence": plan.get("confidence"), + "slot_status": info.get("slot_status"), + "slot_kind": info.get("slot_kind"), + "feature_slot_boundary_face_ids": info.get("feature_slot_boundary_face_ids"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + "material_vote_summary": plan.get("material_vote_summary"), + "cylinder_end_type": plan.get("cylinder_end_type"), + "hole_depth_estimate": plan.get("hole_depth_estimate"), + "feature_bottom_face_ids": plan.get("feature_bottom_face_ids"), + "feature_opening_face_ids": plan.get("feature_opening_face_ids"), + "quick_preflight": True, + "ui_preview": "skipped-to-avoid-ui-freeze", + }, + target_kind="face", + target_id=face_id, + ) + + def resize_slot_angular_span(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 + if not hasattr(self, "slot_angular_span_input"): + return + try: + target_angular_span = float(self.slot_angular_span_input.text()) + except ValueError: + QMessageBox.critical(self, "目标圆弧角度无效", "请输入数字形式的目标槽/半孔圆弧角度。") + return + if target_angular_span <= 0: + QMessageBox.critical(self, "目标圆弧角度无效", "目标槽/半孔圆弧角度必须大于 0。") + return + if target_angular_span >= math.tau * 0.92: + QMessageBox.critical(self, "目标圆弧角度无效", "目标槽/半孔圆弧角度不能接近完整圆柱。") + return + + face_id = self.selected_face_id + info = self._selected_face_info_snapshot() + if info.get("surface") != "cylinder" or info.get("slot_kind") != "partial-cylindrical-groove": + QMessageBox.information(self, "不是槽/半孔候选", "当前选中Face没有被识别为槽/半孔候选。") + return + current_angular_span = _float_or_none(info.get("slot_angular_span")) + if current_angular_span is None: + current_angular_span = _float_or_none(info.get("angular_span")) + if current_angular_span is None or current_angular_span <= 1e-6 or current_angular_span >= math.tau * 0.92: + QMessageBox.information(self, "不能调整圆弧角度", "当前候选没有稳定的部分圆柱角度。") + return + + plan = self.model.cylindrical_slot_angular_span_plan(face_id, target_angular_span) + current_width = _float_or_none(plan.get("slot_current_width") or info.get("slot_chord_width_estimate")) + target_width = _float_or_none(plan.get("slot_target_width")) + current_depth = _float_or_none(plan.get("slot_current_depth") or info.get("slot_sagitta_depth_estimate")) + target_depth = _float_or_none(plan.get("slot_target_depth")) + current_arc_length = _float_or_none(plan.get("slot_current_arc_length") or info.get("slot_arc_length_estimate")) + target_arc_length = _float_or_none(plan.get("slot_target_arc_length")) + lines = [ + f"Face: {face_id}", + f"当前圆弧角度: {_format_value(current_angular_span)} rad / {_format_value(math.degrees(current_angular_span))} 度", + f"目标圆弧角度: {_format_value(target_angular_span)} rad / {_format_value(math.degrees(target_angular_span))} 度", + f"当前槽宽估算: {_format_value(current_width)}", + f"目标槽宽估算: {_format_value(target_width)}", + f"当前槽深估算: {_format_value(current_depth)}", + f"目标槽深估算: {_format_value(target_depth)}", + f"当前圆弧长度估算: {_format_value(current_arc_length)}", + f"目标圆弧长度估算: {_format_value(target_arc_length)}", + "当前版本会保持圆柱半径不变,先补旧扇形槽,再按目标角度重切局部扇形槽。", + ] + if not self._confirm_quick_edit_plan( + "槽/半孔圆弧角度调整", + plan, + lines, + "槽/半孔圆弧角度调整已阻止", + "已取消槽/半孔圆弧角度调整", + ): + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_cylindrical_slot_angular_span(face_id, target_angular_span) + + 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"), + "surface": plan.get("surface", "cylinder"), + "new_slot_angular_span": target_angular_span, + "new_slot_angular_span_degrees": math.degrees(target_angular_span), + "old_slot_angular_span": current_angular_span, + "old_slot_angular_span_degrees": math.degrees(current_angular_span), + "slot_angular_span_delta": plan.get("slot_angular_span_delta"), + "slot_angular_span_delta_ratio": plan.get("slot_angular_span_delta_ratio"), + "old_slot_width": current_width, + "new_slot_width_estimate": target_width, + "old_slot_depth": current_depth, + "new_slot_depth_estimate": target_depth, + "old_slot_arc_length": current_arc_length, + "new_slot_arc_length_estimate": target_arc_length, + "slot_target_diameter": plan.get("slot_target_diameter"), + "resize_mode": plan.get("resize_mode"), + "resize_strategy": plan.get("slot_resize_strategy"), + "slot_resize_strategy": plan.get("slot_resize_strategy"), + "slot_resize_mode": plan.get("slot_resize_mode"), + "slot_pair_face_id": plan.get("slot_pair_face_id"), + "slot_pair_axis_distance": plan.get("slot_pair_axis_distance"), + "slot_pair_boundary_overlap_count": plan.get("slot_pair_boundary_overlap_count"), + "feature_type": plan.get("feature_type"), + "feature_guess": plan.get("feature_guess"), + "confidence": plan.get("confidence"), + "slot_status": info.get("slot_status"), + "slot_kind": info.get("slot_kind"), + "feature_slot_boundary_face_ids": info.get("feature_slot_boundary_face_ids"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + "quick_preflight": True, + "ui_preview": "skipped-to-avoid-ui-freeze", + }, + target_kind="face", + target_id=face_id, + ) + + def resize_slot_total_length(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 + if not hasattr(self, "slot_total_length_input"): + return + try: + target_total_length = float(self.slot_total_length_input.text()) + except ValueError: + QMessageBox.critical(self, "目标槽孔总长度无效", "请输入数字形式的目标槽孔总长度。") + return + + face_id = self.selected_face_id + try: + pair_face_id = self._slot_pair_face_override(face_id) + except ValueError as exc: + QMessageBox.critical(self, "槽孔配对端无效", str(exc)) + return + plan = self.model.cylindrical_slot_total_length_plan( + face_id, + target_total_length, + pair_face_id=pair_face_id, + ) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能调整槽孔总长度", str(plan["message"])) + self.statusBar().showMessage("槽孔总长度调整已阻止") + return + lines = [ + f"Face: {face_id}", + f"当前槽孔总长度: {_format_value(plan.get('slot_current_total_length'))}", + f"目标槽孔总长度: {_format_value(plan.get('slot_target_total_length'))}", + f"当前两端中心距: {_format_value(plan.get('slot_current_center_distance'))}", + f"目标两端中心距: {_format_value(plan.get('slot_target_center_distance'))}", + f"槽宽/直径: {_format_value(plan.get('slot_target_diameter'))}", + f"配对端 Face: {_format_value(plan.get('slot_pair_face_id'))}", + ] + if not self._confirm_quick_edit_plan( + "槽孔总长度调整", + plan, + lines, + "槽孔总长度调整已阻止", + "已取消槽孔总长度调整", + ): + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_cylindrical_slot_total_length( + face_id, + target_total_length, + pair_face_id=pair_face_id, + ) + + 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"), + "surface": plan.get("surface", "cylinder"), + "new_slot_total_length": target_total_length, + "old_slot_total_length": plan.get("slot_current_total_length"), + "slot_total_length_delta": plan.get("slot_total_length_delta"), + "slot_total_length_delta_ratio": plan.get("slot_total_length_delta_ratio"), + "slot_current_center_distance": plan.get("slot_current_center_distance"), + "slot_target_center_distance": plan.get("slot_target_center_distance"), + "slot_target_diameter": plan.get("slot_target_diameter"), + "derived_new_diameter": plan.get("slot_target_diameter"), + "resize_strategy": plan.get("slot_resize_strategy"), + "slot_resize_strategy": plan.get("slot_resize_strategy"), + "slot_resize_mode": plan.get("slot_resize_mode"), + "slot_pair_face_id": plan.get("slot_pair_face_id"), + "slot_pair_manual_face_id": pair_face_id, + "slot_pair_manual": plan.get("slot_pair_manual"), + "slot_pair_axis_distance": plan.get("slot_pair_axis_distance"), + "slot_pair_boundary_overlap_count": plan.get("slot_pair_boundary_overlap_count"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + }, + target_kind="face", + target_id=face_id, + ) + def resize_boss(self) -> None: if self.model is None: return @@ -908,6 +1557,257 @@ class WindowActionMixin: target_id=face_id, ) + def move_cylindrical_boss_axis(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_center = ( + float(self.boss_center_x_input.text()), + float(self.boss_center_y_input.text()), + float(self.boss_center_z_input.text()), + ) + except (AttributeError, ValueError): + QMessageBox.critical(self, "凸台轴心坐标无效", "请输入 X, Y, Z 三个数字形式的目标坐标。") + return + + face_id = self.selected_face_id + plan = self.model.cylindrical_boss_axis_move_plan(face_id, target_center) + lines = [ + f"Face: {face_id}", + f"当前轴心: {_format_value(plan.get('current_axis_center'))}", + f"目标轴心: {_format_value(plan.get('target_axis_center'))}", + f"移动向量: {_format_value(plan.get('axis_move_vector'))}", + f"移动距离: {_format_value(plan.get('axis_move_distance'))}", + f"凸台直径: {_format_value(plan.get('target_diameter'))}", + f"工具高度: {_format_value(plan.get('boss_tool_height'))}", + f"候选判断: {plan.get('feature_guess')}", + f"置信度: {plan.get('confidence')}", + "当前版本会先移除旧凸台包络,再按同直径在目标轴心补出凸台。", + ] + if not self._confirm_quick_edit_plan( + "圆柱凸台轴心坐标修改", + plan, + lines, + "圆柱凸台轴心坐标修改已阻止", + "已取消圆柱凸台轴心坐标修改", + ): + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.move_cylindrical_boss_axis(face_id, target_center) + + 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"), + "surface": plan.get("surface", "cylinder"), + "feature_type": plan.get("feature_type"), + "feature_guess": plan.get("feature_guess"), + "confidence": plan.get("confidence"), + "current_diameter": plan.get("current_diameter"), + "target_diameter": plan.get("target_diameter"), + "current_axis_center": plan.get("current_axis_center"), + "target_axis_center": plan.get("target_axis_center"), + "axis_move_vector": plan.get("axis_move_vector"), + "axis_move_distance": plan.get("axis_move_distance"), + "axis_move_radial_distance": plan.get("axis_move_radial_distance"), + "axis_move_axial_delta": plan.get("axis_move_axial_delta"), + "resize_strategy": plan.get("resize_strategy"), + "move_axis_status": plan.get("status"), + "move_axis_risk": plan.get("risk"), + "move_axis_message": plan.get("message"), + "move_axis_warnings": plan.get("warnings"), + "move_axis_blockers": plan.get("blockers"), + "boss_tool_strategy": plan.get("boss_tool_strategy"), + "boss_tool_height": plan.get("boss_tool_height"), + "boss_tool_axial_margin": plan.get("boss_tool_axial_margin"), + "boss_tool_outer_radius": plan.get("boss_tool_outer_radius"), + "target_boss_tool_radius": plan.get("target_boss_tool_radius"), + "target_boss_tool_axis_point": plan.get("target_boss_tool_axis_point"), + "target_boss_tool_start_point": plan.get("target_boss_tool_start_point"), + "quick_preflight": True, + "ui_preview": "skipped-to-avoid-ui-freeze", + }, + target_kind="face", + target_id=face_id, + ) + + def resize_boss_height(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 + if not hasattr(self, "boss_height_input"): + return + try: + target_height = float(self.boss_height_input.text()) + except ValueError: + QMessageBox.critical(self, "高度无效", "请输入数字形式的目标凸台高度。") + return + + face_id = self.selected_face_id + plan = self.model.cylindrical_boss_height_plan(face_id, target_height) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能调整凸台高度", str(plan["message"])) + self.statusBar().showMessage("圆柱凸台高度调整已阻止") + return + + if plan["risk"] != "low": + 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"端盖Face: {_format_value(plan.get('boss_height_cap_face_id'))}\n" + f"端盖位置: {_format_value(plan.get('boss_height_endpoint_label'))}\n" + f"当前凸台高度: {_format_value(plan['current_height'])}\n" + f"目标凸台高度: {_format_value(plan['target_height'])}\n" + f"高度变化量: {_format_value(plan['delta_height'])}\n" + f"高度变化比例: {_format_percent(plan['height_delta_ratio'])}\n" + f"端盖推拉距离: {_format_value(plan.get('push_pull_distance'))}\n" + f"候选判断: {plan.get('feature_guess')}\n" + f"置信度: {plan.get('confidence')}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会推拉识别到的凸台端盖,真实修改当前 B-Rep 几何。\n\n" + "确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if result != QMessageBox.StandardButton.Yes: + self.statusBar().showMessage("已取消圆柱凸台高度调整") + return + + self._show_cylinder_boss_height_preview(face_id, target_height) + + def action(): + return self.model.resize_cylindrical_boss_height(face_id, target_height) + + 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"), + "new_height": target_height, + "old_height": plan.get("current_height"), + "delta_height": plan.get("delta_height"), + "height_delta_ratio": plan.get("height_delta_ratio"), + "boss_height_cap_face_id": plan.get("boss_height_cap_face_id"), + "boss_height_endpoint_role": plan.get("boss_height_endpoint_role"), + "boss_height_endpoint_label": plan.get("boss_height_endpoint_label"), + "boss_height_open_end": plan.get("boss_height_open_end"), + "push_pull_distance": plan.get("push_pull_distance"), + "feature_type": plan.get("feature_type"), + "feature_guess": plan.get("feature_guess"), + "confidence": plan.get("confidence"), + "boss_height_status": plan.get("status"), + "boss_height_risk": plan.get("risk"), + "boss_height_message": plan.get("message"), + "boss_height_warnings": plan.get("warnings"), + "boss_height_blockers": plan.get("blockers"), + "resize_strategy": plan.get("resize_strategy"), + "same_domain_face_ids": plan.get("same_domain_face_ids"), + "feature_start_end_face_ids": plan.get("feature_start_end_face_ids"), + "feature_end_end_face_ids": plan.get("feature_end_end_face_ids"), + }, + target_kind="face", + target_id=face_id, + ) + + def resize_cylinder_height(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 + if not hasattr(self, "boss_height_input"): + return + try: + target_height = float(self.boss_height_input.text()) + except ValueError: + QMessageBox.critical(self, "高度无效", "请输入数字形式的目标圆柱高度。") + return + + face_id = self.selected_face_id + plan = self.model.cylindrical_height_plan(face_id, target_height) + lines = [ + f"Face: {face_id}", + f"端盖Face: {_format_value(plan.get('boss_height_cap_face_id'))}", + f"端盖位置: {_format_value(plan.get('boss_height_endpoint_label'))}", + f"当前圆柱高度: {_format_value(plan.get('current_height'))}", + f"目标圆柱高度: {_format_value(plan.get('target_height'))}", + f"高度变化量: {_format_value(plan.get('delta_height'))}", + f"端盖推拉距离: {_format_value(plan.get('push_pull_distance'))}", + f"候选判断: {plan.get('feature_guess')}", + "当前版本会推拉识别到的圆柱端盖,不依赖 CAD 原始建模历史。", + ] + if not self._confirm_quick_edit_plan( + "圆柱高度调整", + plan, + lines, + "圆柱高度调整已阻止", + "已取消圆柱高度调整", + ): + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_cylindrical_height(face_id, target_height) + + 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"), + "surface": plan.get("surface", "cylinder"), + "new_height": target_height, + "old_height": plan.get("current_height"), + "delta_height": plan.get("delta_height"), + "height_delta_ratio": plan.get("height_delta_ratio"), + "boss_height_cap_face_id": plan.get("boss_height_cap_face_id"), + "boss_height_endpoint_role": plan.get("boss_height_endpoint_role"), + "boss_height_endpoint_label": plan.get("boss_height_endpoint_label"), + "boss_height_open_end": plan.get("boss_height_open_end"), + "push_pull_distance": plan.get("push_pull_distance"), + "feature_type": plan.get("feature_type"), + "feature_guess": plan.get("feature_guess"), + "confidence": plan.get("confidence"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + "resize_strategy": plan.get("resize_strategy"), + "same_domain_face_ids": plan.get("same_domain_face_ids"), + "feature_start_end_face_ids": plan.get("feature_start_end_face_ids"), + "feature_end_end_face_ids": plan.get("feature_end_end_face_ids"), + }, + target_kind="face", + target_id=face_id, + ) + def suppress_hole(self) -> None: if self.model is None: return @@ -995,6 +1895,22 @@ class WindowActionMixin: target_id=face_id, ) + def _slot_pair_face_override(self, source_face_id: int | None = None) -> int | None: + if not hasattr(self, "slot_pair_face_input"): + return None + text = self.slot_pair_face_input.text().strip() + if not text: + return None + try: + pair_face_id = int(text) + except ValueError as exc: + raise ValueError("槽孔配对端 Face ID 必须是整数。") from exc + if source_face_id is not None and pair_face_id == int(source_face_id): + raise ValueError("槽孔配对端不能和当前槽端使用同一个 Face ID。") + if self.model is not None and (pair_face_id < 0 or pair_face_id >= len(self.model.faces)): + raise ValueError(f"槽孔配对端 Face ID {pair_face_id} 不存在。") + return pair_face_id + def _hole_bottom_face_override(self) -> int | None: if not hasattr(self, "hole_bottom_face_input"): return None @@ -1149,11 +2065,11 @@ class WindowActionMixin: if bottom_face_id is not None: lines.append(f"手动底面 Face: {bottom_face_id}") if not self._confirm_quick_edit_plan( - "盲孔深度调整", + "盲孔/盲槽深度调整", plan, lines, - "盲孔深度调整已阻止", - "已取消盲孔深度调整", + "盲孔/盲槽深度调整已阻止", + "已取消盲孔/盲槽深度调整", ): return self.clear_edit_preview(render=False) @@ -1167,7 +2083,7 @@ class WindowActionMixin: self._run_edit_action( action, - operation_name="调整盲孔深度", + operation_name="调整盲孔/盲槽深度", target=f"Face {face_id}", parameters={ "part_id": plan.get("part_id"), @@ -1445,6 +2361,165 @@ class WindowActionMixin: target_id=edge_id, ) + def chamfer_edge_asymmetric(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再添加不等距倒角。"): + return + if self.selected_edge_id is None: + QMessageBox.information(self, "未选择Edge", "请先选择一条直线Edge。") + return + try: + distance1 = float(self.edge_chamfer_distance1_input.text()) + distance2 = float(self.edge_chamfer_distance2_input.text()) + except (AttributeError, ValueError): + QMessageBox.critical(self, "距离无效", "请输入数字形式的不等距倒角 D1, D2。") + return + reference_face_id = None + if hasattr(self, "edge_chamfer_reference_face_input"): + text = self.edge_chamfer_reference_face_input.text().strip() + if text: + try: + reference_face_id = int(text) + except ValueError: + QMessageBox.critical(self, "参考Face无效", "倒角参考 Face ID 必须是整数。") + return + + edge_id = self.selected_edge_id + plan = self.model.edge_asymmetric_chamfer_plan(edge_id, distance1, distance2, reference_face_id) + if self._block_unisolated_high_risk_operation("已阻止高风险不等距倒角", plan): + return + lines = [ + f"Edge: {edge_id}", + f"Edge长度: {_format_value(plan.get('edge_length'))}", + f"D1: {_format_value(plan.get('target_distance1'))}", + f"D2: {_format_value(plan.get('target_distance2'))}", + f"参考Face: {_format_value(plan.get('reference_face_id'))}", + f"可用相邻Face: {_format_value(plan.get('reference_face_candidates'))}", + f"D1/Edge长度比例: {_format_percent(plan.get('distance1_to_length_ratio'))}", + f"D2/Edge长度比例: {_format_percent(plan.get('distance2_to_length_ratio'))}", + ] + if not self._confirm_quick_edit_plan( + "给Edge添加不等距倒角", + plan, + lines, + blocked_status="不等距倒角已阻止", + cancelled_status="已取消不等距倒角", + ): + return + + self._show_edge_chamfer_preview(edge_id, max(distance1, distance2)) + + def action(): + return self.model.chamfer_edge_asymmetric(edge_id, distance1, distance2, reference_face_id) + + self._run_edit_action( + action, + operation_name="给Edge添加不等距倒角", + target=f"Edge {edge_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "edge_length": plan.get("edge_length"), + "chamfer_mode": plan.get("chamfer_mode"), + "chamfer_distance1": distance1, + "chamfer_distance2": distance2, + "distance1_to_length_ratio": plan.get("distance1_to_length_ratio"), + "distance2_to_length_ratio": plan.get("distance2_to_length_ratio"), + "reference_face_id": plan.get("reference_face_id"), + "reference_face_candidates": plan.get("reference_face_candidates"), + "curve": plan.get("curve"), + "adjacent_face_ids": plan.get("adjacent_face_ids"), + "adjacent_face_count": plan.get("adjacent_face_count"), + "chamfer_status": plan.get("status"), + "chamfer_risk": plan.get("risk"), + "chamfer_message": plan.get("message"), + "chamfer_warnings": plan.get("warnings"), + "chamfer_blockers": plan.get("blockers"), + }, + target_kind="edge", + target_id=edge_id, + ) + + def chamfer_edge_distance_angle(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再添加距离+角度倒角。"): + return + if self.selected_edge_id is None: + QMessageBox.information(self, "未选择Edge", "请先选择一条直线Edge。") + return + try: + distance = float(self.edge_chamfer_angle_distance_input.text()) + angle_degrees = float(self.edge_chamfer_angle_degrees_input.text()) + except (AttributeError, ValueError): + QMessageBox.critical(self, "目标值无效", "请输入数字形式的距离和角度,例如 1, 45。") + return + reference_face_id = None + if hasattr(self, "edge_chamfer_reference_face_input"): + text = self.edge_chamfer_reference_face_input.text().strip() + if text: + try: + reference_face_id = int(text) + except ValueError: + QMessageBox.critical(self, "参考Face无效", "倒角参考 Face ID 必须是整数。") + return + + edge_id = self.selected_edge_id + plan = self.model.edge_distance_angle_chamfer_plan(edge_id, distance, angle_degrees, reference_face_id) + if self._block_unisolated_high_risk_operation("已阻止高风险距离+角度倒角", plan): + return + lines = [ + f"Edge: {edge_id}", + f"Edge长度: {_format_value(plan.get('edge_length'))}", + f"距离D: {_format_value(plan.get('target_distance'))}", + f"角度: {_format_value(plan.get('target_angle_degrees'))} 度", + f"参考Face: {_format_value(plan.get('reference_face_id'))}", + f"可用相邻Face: {_format_value(plan.get('reference_face_candidates'))}", + f"D/Edge长度比例: {_format_percent(plan.get('distance_to_length_ratio'))}", + ] + if not self._confirm_quick_edit_plan( + "给Edge添加距离+角度倒角", + plan, + lines, + blocked_status="距离+角度倒角已阻止", + cancelled_status="已取消距离+角度倒角", + ): + return + + self._show_edge_chamfer_preview(edge_id, distance) + + def action(): + return self.model.chamfer_edge_distance_angle(edge_id, distance, angle_degrees, reference_face_id) + + self._run_edit_action( + action, + operation_name="给Edge添加距离+角度倒角", + target=f"Edge {edge_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "edge_length": plan.get("edge_length"), + "chamfer_mode": plan.get("chamfer_mode"), + "chamfer_distance": distance, + "chamfer_angle_degrees": angle_degrees, + "chamfer_angle_radians": plan.get("target_angle_radians"), + "distance_to_length_ratio": plan.get("distance_to_length_ratio"), + "reference_face_id": plan.get("reference_face_id"), + "reference_face_candidates": plan.get("reference_face_candidates"), + "curve": plan.get("curve"), + "adjacent_face_ids": plan.get("adjacent_face_ids"), + "adjacent_face_count": plan.get("adjacent_face_count"), + "chamfer_status": plan.get("status"), + "chamfer_risk": plan.get("risk"), + "chamfer_message": plan.get("message"), + "chamfer_warnings": plan.get("warnings"), + "chamfer_blockers": plan.get("blockers"), + }, + target_kind="edge", + target_id=edge_id, + ) + def resize_edge_length(self) -> None: if self.model is None: return @@ -1735,6 +2810,520 @@ class WindowActionMixin: data = combo.currentData() return str(data or "auto") + def _edge_endpoint_target_from_inputs(self, endpoint_role: str) -> tuple[float, float, float] | None: + prefix = "edge_start" if endpoint_role == "start" else "edge_end" + try: + return ( + float(getattr(self, f"{prefix}_x_input").text()), + float(getattr(self, f"{prefix}_y_input").text()), + 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.") + return None + + def move_edge_start_point(self) -> None: + self._move_edge_endpoint("start") + + def move_edge_end_point(self) -> None: + self._move_edge_endpoint("end") + + 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."): + return + if self.selected_edge_id is None: + QMessageBox.information(self, "No Edge selected", "Please select an Edge first.") + return + try: + target_center = ( + float(self.edge_center_x_input.text()), + float(self.edge_center_y_input.text()), + 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.") + return + + edge_id = self.selected_edge_id + plan = self.model.edge_center_move_plan(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'))}", + ] + if not self._confirm_quick_edit_plan( + "移动Edge中心", + plan, + lines, + blocked_status="移动Edge中心已阻止", + cancelled_status="已取消移动Edge中心", + ): + return + + self.clear_edit_preview(render=False) + + def action(): + return self.model.move_edge_center(edge_id, target_center) + + self._run_edit_action( + action, + operation_name="移动Edge中心", + target=f"Edge {edge_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "curve": plan.get("curve"), + "current_edge_center": plan.get("current_edge_center"), + "target_edge_center": plan.get("target_edge_center"), + "moved_edge_center_delta": plan.get("moved_edge_center_delta"), + "moved_edge_center_distance": plan.get("moved_edge_center_distance"), + "moved_edge_center_ratio": plan.get("moved_edge_center_ratio"), + "edge_length": plan.get("current_length"), + "expected_start_point": plan.get("expected_start_point"), + "expected_end_point": plan.get("expected_end_point"), + "local_edge_deform_target_kind": plan.get("local_edge_deform_target_kind"), + "local_edge_deform_face_count": plan.get("local_edge_deform_face_count"), + "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"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + }, + target_kind="edge", + target_id=edge_id, + ) + + 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."): + return + if self.selected_edge_id is None: + QMessageBox.information(self, "No Edge selected", "Please select an Edge first.") + return + + target_point = self._edge_endpoint_target_from_inputs(endpoint_role) + if target_point is None: + return + + edge_id = self.selected_edge_id + plan = self.model.edge_endpoint_move_plan(edge_id, endpoint_role, target_point) + 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'))}", + ] + if not self._confirm_quick_edit_plan( + title, + plan, + lines, + blocked_status=f"{title}已阻止", + cancelled_status=f"已取消{title}", + ): + return + + self.clear_edit_preview(render=False) + + def action(): + return self.model.move_edge_endpoint(edge_id, endpoint_role, target_point) + + self._run_edit_action( + action, + operation_name=title, + target=f"Edge {edge_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "curve": plan.get("curve"), + "endpoint_role": plan.get("edge_endpoint_role"), + "current_endpoint_point": plan.get("current_endpoint_point"), + "target_endpoint_point": plan.get("target_endpoint_point"), + "fixed_endpoint_point": plan.get("fixed_endpoint_point"), + "moved_endpoint_delta": plan.get("moved_endpoint_delta"), + "moved_endpoint_distance": plan.get("moved_endpoint_distance"), + "moved_endpoint_ratio": plan.get("moved_endpoint_ratio"), + "current_length": plan.get("current_length"), + "target_length": plan.get("target_length"), + "delta_length": plan.get("delta_length"), + "length_change_ratio": plan.get("length_change_ratio"), + "expected_start_point": plan.get("expected_start_point"), + "expected_end_point": plan.get("expected_end_point"), + "local_edge_deform_target_kind": plan.get("local_edge_deform_target_kind"), + "local_edge_deform_face_count": plan.get("local_edge_deform_face_count"), + "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"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + }, + target_kind="edge", + target_id=edge_id, + ) + + def resize_cone_reference_radius(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 + if not hasattr(self, "cone_reference_radius_input"): + return + try: + target_radius = float(self.cone_reference_radius_input.text()) + except ValueError: + QMessageBox.critical(self, "圆锥参考半径无效", "请输入数字形式的目标圆锥参考半径。") + return + + face_id = self.selected_face_id + plan = self.model.conical_reference_radius_plan(face_id, target_radius) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能修改圆锥面", str(plan["message"])) + self.statusBar().showMessage("圆锥面修改已阻止") + return + if plan["risk"] != "low": + warnings = str(plan.get("warnings", "")) + warnings_line = f"警告: {warnings}\n\n" if warnings else "" + result = QMessageBox.question( + self, + "确认修改圆锥面", + ( + f"Face: {face_id}\n" + f"当前参考半径: {_format_value(plan.get('current_reference_radius'))}\n" + f"目标参考半径: {_format_value(plan.get('target_reference_radius'))}\n" + f"变化比例: {_format_percent(plan.get('reference_radius_delta_ratio'))}\n" + f"缩放目标: {_format_value(plan.get('affine_target_kind'))}\n" + f"缩放比例: {_format_value(plan.get('affine_scale'))}\n" + f"策略: {_format_value(plan.get('resize_strategy'))}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if result != QMessageBox.StandardButton.Yes: + self.statusBar().showMessage("已取消圆锥面修改") + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_conical_reference_radius(face_id, target_radius) + + 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"), + "surface": "cone", + "current_reference_radius": plan.get("current_reference_radius"), + "target_reference_radius": plan.get("target_reference_radius"), + "current_reference_diameter": plan.get("current_reference_diameter"), + "target_reference_diameter": plan.get("target_reference_diameter"), + "delta_reference_radius": plan.get("delta_reference_radius"), + "reference_radius_delta_ratio": plan.get("reference_radius_delta_ratio"), + "semi_angle": plan.get("semi_angle"), + "affine_scale": plan.get("affine_scale"), + "affine_transform_kind": plan.get("affine_transform_kind"), + "affine_transform_label": plan.get("affine_transform_label"), + "affine_axis_point": plan.get("affine_axis_point"), + "affine_axis_direction": plan.get("affine_axis_direction"), + "affine_target_kind": plan.get("affine_target_kind"), + "resize_strategy": plan.get("resize_strategy"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + }, + target_kind="face", + target_id=face_id, + ) + + def resize_sphere_radius(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 + if not hasattr(self, "sphere_radius_input"): + return + try: + target_radius = float(self.sphere_radius_input.text()) + except ValueError: + QMessageBox.critical(self, "球面半径无效", "请输入数字形式的目标球面半径。") + return + + face_id = self.selected_face_id + plan = self.model.spherical_radius_plan(face_id, target_radius) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能修改球面", str(plan["message"])) + self.statusBar().showMessage("球面修改已阻止") + return + if plan["risk"] != "low": + warnings = str(plan.get("warnings", "")) + warnings_line = f"警告: {warnings}\n\n" if warnings else "" + result = QMessageBox.question( + self, + "确认修改球面", + ( + f"Face: {face_id}\n" + f"当前半径: {_format_value(plan.get('current_radius'))}\n" + f"目标半径: {_format_value(plan.get('target_radius'))}\n" + f"变化比例: {_format_percent(plan.get('radius_delta_ratio'))}\n" + f"缩放目标: {_format_value(plan.get('affine_target_kind'))}\n" + f"缩放比例: {_format_value(plan.get('affine_scale'))}\n" + f"策略: {_format_value(plan.get('resize_strategy'))}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if result != QMessageBox.StandardButton.Yes: + self.statusBar().showMessage("已取消球面修改") + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_spherical_radius(face_id, target_radius) + + 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"), + "surface": "sphere", + "current_radius": plan.get("current_radius"), + "target_radius": plan.get("target_radius"), + "current_diameter": plan.get("current_diameter"), + "target_diameter": plan.get("target_diameter"), + "delta_radius": plan.get("delta_radius"), + "radius_delta_ratio": plan.get("radius_delta_ratio"), + "center": plan.get("center"), + "affine_scale": plan.get("affine_scale"), + "affine_transform_kind": plan.get("affine_transform_kind"), + "affine_transform_label": plan.get("affine_transform_label"), + "affine_axis_point": plan.get("affine_axis_point"), + "affine_target_kind": plan.get("affine_target_kind"), + "resize_strategy": plan.get("resize_strategy"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + }, + target_kind="face", + target_id=face_id, + ) + + def resize_torus_major_radius(self) -> None: + self._resize_torus_radius("major") + + def resize_torus_minor_radius(self) -> None: + self._resize_torus_radius("minor") + + def _resize_torus_radius(self, mode: str) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再修改环面。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择环面", "请先选择一个环面 Face 或环面特征。") + return + if not hasattr(self, "torus_radius_input"): + return + try: + target_radius = float(self.torus_radius_input.text()) + except ValueError: + QMessageBox.critical(self, "环面半径无效", "请输入数字形式的目标环面半径。") + return + + mode_key = "major" if mode == "major" else "minor" + mode_label = "主半径" if mode_key == "major" else "小半径" + face_id = self.selected_face_id + plan = self.model.toroidal_radius_plan(face_id, target_radius, mode_key) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能修改环面", str(plan["message"])) + self.statusBar().showMessage("环面修改已阻止") + return + if plan["risk"] != "low": + warnings = str(plan.get("warnings", "")) + warnings_line = f"警告: {warnings}\n\n" if warnings else "" + result = QMessageBox.question( + self, + f"确认修改环面{mode_label}", + ( + f"Face: {face_id}\n" + f"当前主半径: {_format_value(plan.get('current_major_radius'))}\n" + f"目标主半径: {_format_value(plan.get('target_major_radius'))}\n" + f"当前小半径: {_format_value(plan.get('current_minor_radius'))}\n" + f"目标小半径: {_format_value(plan.get('target_minor_radius'))}\n" + f"变化比例: {_format_percent(plan.get('radius_delta_ratio'))}\n" + f"缩放目标: {_format_value(plan.get('affine_target_kind'))}\n" + f"缩放比例: {_format_value(plan.get('affine_scale'))}\n" + f"策略: {_format_value(plan.get('resize_strategy'))}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if result != QMessageBox.StandardButton.Yes: + self.statusBar().showMessage("已取消环面修改") + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_toroidal_radius(face_id, target_radius, mode_key) + + self._run_edit_action( + action, + operation_name=f"修改环面{mode_label}", + target=f"Face {face_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "surface": "torus", + "torus_radius_mode": plan.get("torus_radius_mode"), + "current_major_radius": plan.get("current_major_radius"), + "target_major_radius": plan.get("target_major_radius"), + "current_minor_radius": plan.get("current_minor_radius"), + "target_minor_radius": plan.get("target_minor_radius"), + "target_radius": plan.get("target_radius"), + "delta_radius": plan.get("delta_radius"), + "radius_delta_ratio": plan.get("radius_delta_ratio"), + "center": plan.get("center"), + "axis": plan.get("axis"), + "affine_scale": plan.get("affine_scale"), + "affine_transform_kind": plan.get("affine_transform_kind"), + "affine_transform_label": plan.get("affine_transform_label"), + "affine_axis_point": plan.get("affine_axis_point"), + "affine_axis_direction": plan.get("affine_axis_direction"), + "affine_target_kind": plan.get("affine_target_kind"), + "resize_strategy": plan.get("resize_strategy"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + }, + target_kind="face", + target_id=face_id, + ) + + def resize_face_area(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再修改Face面积。"): + return + if self.selected_face_id is None: + QMessageBox.information(self, "未选择Face", "请先选择一个 Face。") + return + if not hasattr(self, "face_area_input"): + return + try: + target_area = float(self.face_area_input.text()) + except ValueError: + QMessageBox.critical(self, "Face面积无效", "请输入数字形式的目标Face面积。") + return + + face_id = self.selected_face_id + plan = self.model.face_area_scale_plan(face_id, target_area) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能修改Face面积", str(plan["message"])) + self.statusBar().showMessage("Face面积修改已阻止") + return + warnings = str(plan.get("warnings", "")) + warnings_line = f"警告: {warnings}\n\n" if warnings else "" + result = QMessageBox.question( + self, + "确认修改Face面积", + ( + f"Face: {face_id}\n" + f"曲面类型: {_format_value(plan.get('surface'))}\n" + f"当前面积: {_format_value(plan.get('current_area'))}\n" + f"目标面积: {_format_value(plan.get('target_area'))}\n" + f"变化比例: {_format_percent(plan.get('area_delta_ratio'))}\n" + f"缩放目标: {_format_value(plan.get('affine_target_kind'))}\n" + f"缩放比例: {_format_value(plan.get('affine_scale'))}\n" + f"策略: {_format_value(plan.get('resize_strategy'))}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if result != QMessageBox.StandardButton.Yes: + self.statusBar().showMessage("已取消Face面积修改") + return + self.clear_edit_preview(render=False) + + def action(): + return self.model.resize_face_area(face_id, target_area) + + self._run_edit_action( + action, + operation_name="修改Face面积", + target=f"Face {face_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "surface": plan.get("surface"), + "current_area": plan.get("current_area"), + "target_area": plan.get("target_area"), + "area_delta": plan.get("area_delta"), + "area_delta_ratio": plan.get("area_delta_ratio"), + "area_center": plan.get("area_center"), + "affine_scale": plan.get("affine_scale"), + "affine_transform_kind": plan.get("affine_transform_kind"), + "affine_transform_label": plan.get("affine_transform_label"), + "affine_axis_point": plan.get("affine_axis_point"), + "affine_target_kind": plan.get("affine_target_kind"), + "resize_strategy": plan.get("resize_strategy"), + "resize_status": plan.get("status"), + "resize_risk": plan.get("risk"), + "resize_message": plan.get("message"), + "resize_warnings": plan.get("warnings"), + "resize_blockers": plan.get("blockers"), + }, + target_kind="face", + target_id=face_id, + ) + def translate_selected_part(self) -> None: if self.model is None: return @@ -1957,6 +3546,259 @@ class WindowActionMixin: target_id=solid_id, ) + def scale_selected_part(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再缩放零件。"): + return + if self.selected_part_id is None: + QMessageBox.information(self, "未选择零件", "请先选择一个零件,或选择属于某个零件的对象。") + return + try: + target_diagonal = float(self.scale_target_diagonal_input.text()) + except ValueError: + QMessageBox.critical(self, "目标尺寸无效", "请输入数字形式的目标包围盒对角线。") + return + part_id = self.selected_part_id + plan = self.model.scale_part_plan(part_id, target_diagonal) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能缩放零件", str(plan["message"])) + self.statusBar().showMessage("缩放零件已阻止") + return + if not self._confirm_scale_plan("确认缩放零件", plan): + self.statusBar().showMessage("已取消缩放零件") + return + + def action(): + return self.model.scale_part(part_id, target_diagonal) + + self._run_edit_action( + action, + operation_name="缩放零件", + target=f"零件 {part_id}", + parameters={ + "part_id": part_id, + "current_bbox_diagonal": plan.get("current_bbox_diagonal"), + "target_bbox_diagonal": plan.get("target_bbox_diagonal"), + "scale_factor": plan.get("scale_factor"), + "scale_delta_ratio": plan.get("scale_delta_ratio"), + "scale_center": plan.get("scale_center"), + "resize_strategy": plan.get("resize_strategy"), + "scale_status": plan.get("status"), + "scale_risk": plan.get("risk"), + "scale_message": plan.get("message"), + "scale_warnings": plan.get("warnings"), + "scale_blockers": plan.get("blockers"), + }, + target_kind="part", + target_id=part_id, + ) + + def scale_selected_solid(self) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再缩放Solid。"): + return + if self.selected_solid_id is None: + QMessageBox.information(self, "未选择 Solid", "请先选择一个 Solid,或选择属于 Solid 的 Face/Edge。") + return + try: + target_diagonal = float(self.scale_target_diagonal_input.text()) + except ValueError: + QMessageBox.critical(self, "目标尺寸无效", "请输入数字形式的目标包围盒对角线。") + return + solid_id = self.selected_solid_id + plan = self.model.scale_solid_plan(solid_id, target_diagonal) + if plan["status"] == "blocked": + QMessageBox.information(self, "不能缩放Solid", str(plan["message"])) + self.statusBar().showMessage("缩放Solid已阻止") + return + if not self._confirm_scale_plan("确认缩放Solid", plan): + self.statusBar().showMessage("已取消缩放Solid") + return + + def action(): + return self.model.scale_solid(solid_id, target_diagonal) + + self._run_edit_action( + action, + operation_name="缩放Solid", + target=f"Solid {solid_id}", + parameters={ + "part_id": plan.get("part_id"), + "solid_id": solid_id, + "part_solid_count": plan.get("part_solid_count"), + "current_bbox_diagonal": plan.get("current_bbox_diagonal"), + "target_bbox_diagonal": plan.get("target_bbox_diagonal"), + "scale_factor": plan.get("scale_factor"), + "scale_delta_ratio": plan.get("scale_delta_ratio"), + "scale_center": plan.get("scale_center"), + "resize_strategy": plan.get("resize_strategy"), + "scale_status": plan.get("status"), + "scale_risk": plan.get("risk"), + "scale_message": plan.get("message"), + "scale_warnings": plan.get("warnings"), + "scale_blockers": plan.get("blockers"), + }, + target_kind="solid", + target_id=solid_id, + ) + + def scale_selected_part_x_size(self) -> None: + self._scale_selected_axis_size("part", "X") + + def scale_selected_part_y_size(self) -> None: + self._scale_selected_axis_size("part", "Y") + + def scale_selected_part_z_size(self) -> None: + self._scale_selected_axis_size("part", "Z") + + def scale_selected_solid_x_size(self) -> None: + self._scale_selected_axis_size("solid", "X") + + def scale_selected_solid_y_size(self) -> None: + self._scale_selected_axis_size("solid", "Y") + + def scale_selected_solid_z_size(self) -> None: + self._scale_selected_axis_size("solid", "Z") + + def _scale_selected_axis_size(self, target_kind: str, axis: str) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前编辑完成后再按轴缩放。"): + return + input_name = { + "X": "scale_x_size_input", + "Y": "scale_y_size_input", + "Z": "scale_z_size_input", + }.get(axis.upper()) + input_widget = getattr(self, input_name or "", None) + if input_widget is None: + return + try: + target_size = float(input_widget.text()) + except ValueError: + QMessageBox.critical(self, "目标尺寸无效", f"请输入数字形式的目标 {axis.upper()} 向尺寸。") + return + + if target_kind == "part": + if self.selected_part_id is None: + QMessageBox.information(self, "未选择零件", "请先选择一个零件,或选择属于某个零件的对象。") + return + target_id = self.selected_part_id + plan = self.model.scale_part_axis_plan(target_id, axis, target_size) + title = f"确认缩放零件{axis.upper()}向尺寸" + + def action(): + return self.model.scale_part_axis(target_id, axis, target_size) + + operation_name = f"缩放零件{axis.upper()}向尺寸" + target_label = f"零件 {target_id}" + run_target_kind = "part" + else: + if self.selected_solid_id is None: + QMessageBox.information(self, "未选择 Solid", "请先选择一个 Solid,或选择属于 Solid 的 Face/Edge。") + return + target_id = self.selected_solid_id + plan = self.model.scale_solid_axis_plan(target_id, axis, target_size) + title = f"确认缩放Solid{axis.upper()}向尺寸" + + def action(): + return self.model.scale_solid_axis(target_id, axis, target_size) + + operation_name = f"缩放Solid{axis.upper()}向尺寸" + target_label = f"Solid {target_id}" + run_target_kind = "solid" + + if plan["status"] == "blocked": + QMessageBox.information(self, "不能按轴缩放", str(plan["message"])) + self.statusBar().showMessage("按轴缩放已阻止") + return + if not self._confirm_axis_scale_plan(title, plan): + self.statusBar().showMessage("已取消按轴缩放") + return + + self._run_edit_action( + action, + operation_name=operation_name, + target=target_label, + parameters={ + "part_id": plan.get("part_id"), + "solid_id": plan.get("solid_id"), + "part_solid_count": plan.get("part_solid_count"), + "scale_axis": plan.get("scale_axis"), + "current_axis_size": plan.get("current_axis_size"), + "target_axis_size": plan.get("target_axis_size"), + "axis_scale_factor": plan.get("axis_scale_factor"), + "axis_scale_delta_ratio": plan.get("axis_scale_delta_ratio"), + "scale_center": plan.get("scale_center"), + "bbox_size": plan.get("bbox_size"), + "resize_strategy": plan.get("resize_strategy"), + "scale_status": plan.get("status"), + "scale_risk": plan.get("risk"), + "scale_message": plan.get("message"), + "scale_warnings": plan.get("warnings"), + "scale_blockers": plan.get("blockers"), + }, + target_kind=run_target_kind, + target_id=target_id, + ) + + def _confirm_scale_plan(self, title: str, plan: dict[str, object]) -> bool: + if plan["risk"] == "low": + return True + warnings = str(plan.get("warnings", "")) + warnings_line = f"警告: {warnings}\n\n" if warnings else "" + result = QMessageBox.question( + self, + title, + ( + f"对象: {plan.get('target_kind', '')}\n" + f"零件: {_format_value(plan.get('part_id', ''))}\n" + f"Solid: {_format_value(plan.get('solid_id', ''))}\n" + f"当前包围盒对角线: {_format_value(plan.get('current_bbox_diagonal'))}\n" + f"目标包围盒对角线: {_format_value(plan.get('target_bbox_diagonal'))}\n" + f"缩放比例: {_format_value(plan.get('scale_factor'))}\n" + f"缩放中心: {_format_value(plan.get('scale_center'))}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会等比缩放当前对象的 B-Rep 形状,并刷新模型拓扑。\n\n" + "确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + return result == QMessageBox.StandardButton.Yes + + def _confirm_axis_scale_plan(self, title: str, plan: dict[str, object]) -> bool: + if plan["risk"] == "low": + return True + warnings = str(plan.get("warnings", "")) + warnings_line = f"警告: {warnings}\n\n" if warnings else "" + result = QMessageBox.question( + self, + title, + ( + f"对象: {plan.get('target_kind', '')}\n" + f"零件: {_format_value(plan.get('part_id', ''))}\n" + f"Solid: {_format_value(plan.get('solid_id', ''))}\n" + f"缩放轴: {_format_value(plan.get('scale_axis'))}\n" + f"当前轴向尺寸: {_format_value(plan.get('current_axis_size'))}\n" + f"目标轴向尺寸: {_format_value(plan.get('target_axis_size'))}\n" + f"缩放比例: {_format_value(plan.get('axis_scale_factor'))}\n" + f"缩放中心: {_format_value(plan.get('scale_center'))}\n" + f"风险: {plan['risk']}\n\n" + f"{warnings_line}" + f"{plan['message']}\n\n" + "继续操作会对当前对象做单轴 B-Rep 仿射缩放,并刷新模型拓扑。\n\n" + "确定继续吗?" + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + return result == QMessageBox.StandardButton.Yes + def _rotation_values_from_inputs(self) -> tuple[str, float]: try: angle = float(self.rotate_angle_input.text()) @@ -2317,6 +4159,9 @@ class WindowActionMixin: self.statusBar().showMessage("后台编辑正在计算,请等待当前操作完成。") return target_logical_id = self._edit_target_logical_id(target_kind, target_id) + result_deflection = float(getattr(self, "edit_result_deflection", 1.6)) + if operation_name == "推拉平面": + result_deflection = max(result_deflection, 2.4) context = { "operation_name": operation_name, "target": target, @@ -2326,6 +4171,7 @@ class WindowActionMixin: "target_logical_id": target_logical_id, "pick_position": self.selected_pick_position, "show_same_domain_internal_edges": self._show_same_domain_internal_edges(), + "edit_result_deflection": result_deflection, } self._begin_edit_task(operation_name) @@ -2365,13 +4211,13 @@ class WindowActionMixin: target_part_id = self._edit_context_part_id(context) before_stats = self.model.stats() before_part_stats = self._part_stats_or_none(target_part_id) - before_geometry = self.model.geometry_stats() + before_geometry = {} try: result = action() after_snapshot = self.model.snapshot() after_stats = self.model.stats() after_part_stats = self._part_stats_or_none(target_part_id) - after_geometry = self.model.geometry_stats() + after_geometry = {} except Exception as exc: try: self.model.restore_snapshot(snapshot) @@ -2383,8 +4229,10 @@ class WindowActionMixin: model_polydata = None edge_polydata = None try: - model_polydata = self.model.build_face_polydata() + deflection = float(context.get("edit_result_deflection", 1.6)) + model_polydata = self.model.build_face_polydata(deflection=deflection) edge_polydata = self.model.build_edge_polydata( + deflection=deflection, show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False)) ) except Exception: @@ -2455,6 +4303,12 @@ class WindowActionMixin: progress_text = f"{operation_name} 正在后台计算;为避免复杂模型卡顿,已跳过同步红/绿预览。" elif "槽/半孔宽度" in operation_name: progress_text = f"{operation_name} 正在后台计算;槽宽会在后台换算并重建孔/槽几何。" + elif "槽/半孔深度" in operation_name: + progress_text = f"{operation_name} 正在后台计算;槽深会在后台换算并重建孔/槽几何。" + elif "槽/半孔圆弧长度" in operation_name: + progress_text = f"{operation_name} 正在后台计算;圆弧长度会在后台换算并重建孔/槽几何。" + elif "凸台高度" in operation_name: + progress_text = f"{operation_name} 正在后台计算,预览表示将推拉的凸台端盖范围。" elif "凸台" in operation_name: progress_text = f"{operation_name} 正在后台计算,绿色预览表示扩大补料范围,红色预览表示缩小切削范围。" elif "封堵" in operation_name: @@ -2476,11 +4330,39 @@ class WindowActionMixin: progress_text = f"{operation_name} 正在后台计算,完成后会刷新模型姿态和拓扑索引。" else: progress_text = f"{operation_name} 正在后台计算。" - self.statusBar().showMessage(progress_text) + self._start_edit_busy_status(progress_text) if self.edit_preview_timer is not None: self.edit_preview_timer.stop() QApplication.processEvents() + def _start_edit_busy_status(self, text: str) -> None: + self.edit_busy_base_text = text + self.edit_busy_started_at = datetime.now() + self.edit_busy_phase = 0 + self.statusBar().showMessage(text) + if hasattr(self, "edit_busy_timer"): + self.edit_busy_timer.start(350) + + def _pulse_edit_busy_status(self) -> None: + if not self.operation_in_progress or not getattr(self, "edit_busy_base_text", ""): + if hasattr(self, "edit_busy_timer"): + self.edit_busy_timer.stop() + return + self.edit_busy_phase = int(getattr(self, "edit_busy_phase", 0)) + 1 + elapsed = "" + if getattr(self, "edit_busy_started_at", None) is not None: + seconds = max(0.0, (datetime.now() - self.edit_busy_started_at).total_seconds()) + elapsed = f" {seconds:.1f}s" + dots = "." * (self.edit_busy_phase % 4) + self.statusBar().showMessage(f"{self.edit_busy_base_text}{dots}{elapsed}") + + def _stop_edit_busy_status(self) -> None: + if hasattr(self, "edit_busy_timer"): + self.edit_busy_timer.stop() + self.edit_busy_base_text = "" + self.edit_busy_started_at = None + self.edit_busy_phase = 0 + def _set_edit_status_text(self, text: str) -> None: self.statusBar().showMessage(text) QApplication.processEvents() @@ -2528,8 +4410,10 @@ class WindowActionMixin: model_polydata = result.get("model_polydata") edge_polydata = result.get("edge_polydata") if model_polydata is None or edge_polydata is None: - model_polydata = self.model.build_face_polydata() + deflection = float(context.get("edit_result_deflection", 1.6)) + model_polydata = self.model.build_face_polydata(deflection=deflection) edge_polydata = self.model.build_edge_polydata( + deflection=deflection, show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False)) ) self.clear_edit_preview(render=False) @@ -2586,10 +4470,13 @@ class WindowActionMixin: def _end_edit_task(self, clear_preview: bool = True) -> None: self.operation_in_progress = False + self._stop_edit_busy_status() self.pending_edit_context = None if clear_preview: self.clear_edit_preview(render=False) self._update_action_states() + if self.selected_kind is not None and getattr(self, "current_info_values", None): + self._refresh_property_editor() self._request_thread_quit(self.edit_thread) QApplication.processEvents() @@ -2627,10 +4514,32 @@ class WindowActionMixin: summary_parts.append(f"distance={_format_value(parameters['distance'])}") if "semantic_distance" in parameters: summary_parts.append(f"distance={_format_value(parameters['semantic_distance'])}") + if parameters.get("chamfer_distance1") is not None and parameters.get("chamfer_distance2") is not None: + summary_parts.append( + f"chamfer_d1_d2={_format_value(parameters['chamfer_distance1'])}," + f"{_format_value(parameters['chamfer_distance2'])}" + ) + elif parameters.get("chamfer_distance") is not None: + summary_parts.append(f"chamfer={_format_value(parameters['chamfer_distance'])}") + if parameters.get("chamfer_angle_degrees") is not None: + summary_parts.append(f"chamfer_angle={_format_value(parameters['chamfer_angle_degrees'])}deg") + if parameters.get("target_edge_center") is not None: + summary_parts.append(f"edge_center={_format_value(parameters['target_edge_center'])}") + if parameters.get("target_endpoint_point") is not None: + summary_parts.append(f"edge_endpoint={_format_value(parameters['target_endpoint_point'])}") if "new_diameter" in parameters: summary_parts.append(f"diameter={_format_value(parameters['new_diameter'])}") if parameters.get("target_length") is not None: summary_parts.append(f"target_length={_format_value(parameters['target_length'])}") + if parameters.get("new_slot_total_length") is not None: + summary_parts.append(f"slot_total_length={_format_value(parameters['new_slot_total_length'])}") + if parameters.get("new_slot_angular_span_degrees") is not None: + summary_parts.append(f"slot_angle={_format_value(parameters['new_slot_angular_span_degrees'])}deg") + if parameters.get("target_bbox_diagonal") is not None: + summary_parts.append(f"bbox_diagonal={_format_value(parameters['target_bbox_diagonal'])}") + if parameters.get("target_axis_size") is not None: + axis = parameters.get("scale_axis") or "axis" + summary_parts.append(f"{axis}_size={_format_value(parameters['target_axis_size'])}") if parameters.get("resize_strategy"): summary_parts.append(f"strategy={parameters['resize_strategy']}") actual_edge_length = _message_field(result_message, "nearest_length") @@ -2684,6 +4593,13 @@ class WindowActionMixin: f" solids: {after_stats.solids} ({_signed_delta(after_stats.solids - before_stats.solids)})", f" faces: {after_stats.faces} ({_signed_delta(after_stats.faces - before_stats.faces)})", f" edges: {after_stats.edges} ({_signed_delta(after_stats.edges - before_stats.edges)})", + "result:", + f" {result_message}", + ] + ) + if before_geometry or after_geometry: + insert_at = max(len(lines) - 2, 0) + lines[insert_at:insert_at] = [ "geometry before:", f" volume: {_format_value(before_geometry.get('volume', ''))}", f" surface_area: {_format_value(before_geometry.get('surface_area', ''))}", @@ -2694,10 +4610,9 @@ class WindowActionMixin: f" surface_area: {_format_after_delta(before_geometry, after_geometry, 'surface_area')}", f" bbox_size: {_format_after_delta(before_geometry, after_geometry, 'bbox_size')}", f" bbox_diagonal: {_format_after_delta(before_geometry, after_geometry, 'bbox_diagonal')}", - "result:", - f" {result_message}", ] - ) + else: + lines.insert(max(len(lines) - 2, 0), "geometry: skipped during edit for faster UI feedback; use diff preview/report when needed.") return OperationRecord( summary=summary, detail="\n".join(lines), diff --git a/step_editor/window_core.py b/step_editor/window_core.py index 3ddc658..baefb54 100644 --- a/step_editor/window_core.py +++ b/step_editor/window_core.py @@ -43,15 +43,35 @@ class WindowCoreMixin: if watched is getattr(self, "vtk_widget", None): event_type = event.type() if event_type == QEvent.Type.MouseButtonRelease: + if event.button() == Qt.MouseButton.LeftButton: + x, y = self._vtk_position_from_qt_event(event) + self.skip_next_vtk_left_release = True + self._handle_left_button_release(x, y) self.pointer_button_down = bool(event.buttons() != Qt.MouseButton.NoButton) elif event_type == QEvent.Type.MouseButtonPress: + if event.button() == Qt.MouseButton.LeftButton: + x, y = self._vtk_position_from_qt_event(event) + self.skip_next_vtk_left_press = True + self._handle_left_button_press(x, y) self.pointer_button_down = True elif event_type == QEvent.Type.MouseMove: self.pointer_button_down = bool(event.buttons() != Qt.MouseButton.NoButton) + if event.buttons() & Qt.MouseButton.LeftButton: + x, y = self._vtk_position_from_qt_event(event) + self._update_left_button_drag_state(x, y) if not self.pointer_button_down: self._queue_hover_from_qt_event(event) + elif event_type == QEvent.Type.Leave: + self._cancel_hover_tracking(render=True) return super().eventFilter(watched, event) + def _vtk_position_from_qt_event(self, event) -> tuple[int, int]: + position = event.position() if hasattr(event, "position") else event.pos() + scale = self.vtk_widget._getPixelRatio() if hasattr(self.vtk_widget, "_getPixelRatio") else 1.0 + x = int(round(float(position.x()) * scale)) + y = int(round((float(self.vtk_widget.height()) - float(position.y()) - 1.0) * scale)) + return x, y + def _build_vtk(self) -> None: self.renderer = vtk.vtkRenderer() self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR) @@ -62,13 +82,19 @@ class WindowCoreMixin: self.interactor.SetInteractorStyle(vtk.vtkInteractorStyleTrackballCamera()) self.picker = vtk.vtkCellPicker() self.picker.SetTolerance(0.003) - self.interactor.AddObserver("LeftButtonPressEvent", self.on_left_click) - self.interactor.AddObserver("LeftButtonReleaseEvent", self.on_pointer_button_release) + self.interactor.AddObserver("LeftButtonPressEvent", self.on_left_button_press) + self.interactor.AddObserver("LeftButtonReleaseEvent", self.on_left_button_release) self.interactor.AddObserver("MiddleButtonPressEvent", self.on_pointer_button_press) self.interactor.AddObserver("MiddleButtonReleaseEvent", self.on_pointer_button_release) self.interactor.AddObserver("RightButtonPressEvent", self.on_pointer_button_press) self.interactor.AddObserver("RightButtonReleaseEvent", self.on_pointer_button_release) self.interactor.AddObserver("MouseMoveEvent", self.on_mouse_move) + self.interactor.AddObserver("StartInteractionEvent", self.on_camera_interaction_start) + self.interactor.AddObserver("EndInteractionEvent", self.on_camera_interaction_end) + if hasattr(self.interactor, "SetDesiredUpdateRate"): + self.interactor.SetDesiredUpdateRate(24.0) + if hasattr(self.interactor, "SetStillUpdateRate"): + self.interactor.SetStillUpdateRate(0.2) light = vtk.vtkLight() light.SetLightTypeToHeadlight() @@ -78,6 +104,55 @@ class WindowCoreMixin: self.interactor.Initialize() + def on_camera_interaction_start(self, _obj, _event) -> None: + if not self._is_ui_thread(): + self._invoke_on_ui_thread(self._begin_camera_interaction) + return + self._begin_camera_interaction() + + def on_camera_interaction_end(self, _obj, _event) -> None: + if not self._is_ui_thread(): + self._invoke_on_ui_thread(self._end_camera_interaction) + return + self._end_camera_interaction() + + def _begin_camera_interaction(self) -> None: + if getattr(self, "camera_interaction_active", False): + return + self.camera_interaction_active = True + self._cancel_hover_tracking(render=False) + if self.edge_actor is not None: + try: + self.edge_actor_visible_before_interaction = bool(self.edge_actor.GetVisibility()) + self.edge_actor.SetVisibility(False) + except Exception: + self.edge_actor_visible_before_interaction = True + if self.model_actor is not None: + try: + prop = self.model_actor.GetProperty() + self.model_interpolation_before_interaction = int(prop.GetInterpolation()) + prop.SetInterpolationToGouraud() + except Exception: + self.model_interpolation_before_interaction = None + + def _end_camera_interaction(self) -> None: + if not getattr(self, "camera_interaction_active", False): + return + self.camera_interaction_active = False + if self.edge_actor is not None: + try: + self.edge_actor.SetVisibility(bool(getattr(self, "edge_actor_visible_before_interaction", True))) + except Exception: + pass + if self.model_actor is not None and self.model_interpolation_before_interaction is not None: + try: + self.model_actor.GetProperty().SetInterpolation(int(self.model_interpolation_before_interaction)) + except Exception: + pass + self.model_interpolation_before_interaction = None + if hasattr(self, "render_window"): + self.render_window.Render() + def closeEvent(self, event) -> None: load_thread_running = bool( (self.load_thread is not None and self.load_thread.isRunning()) @@ -839,18 +914,73 @@ class WindowCoreMixin: if hasattr(self, "_update_id_select_title"): self._update_id_select_title(mode) - def on_left_click(self, _obj, _event) -> None: + def _cancel_hover_tracking(self, render: bool = True) -> None: + self.pending_hover_position = None + self.last_hover_pick_position = None + if hasattr(self, "hover_timer"): + self.hover_timer.stop() + self._clear_hover(render=render) + + def on_left_button_press(self, _obj, _event) -> None: + if getattr(self, "skip_next_vtk_left_press", False): + self.skip_next_vtk_left_press = False + return try: x, y = self.interactor.GetEventPosition() except Exception: x, y = 0, 0 if not self._is_ui_thread(): - self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._handle_left_click(x, y)) + self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._handle_left_button_press(x, y)) return - self._handle_left_click(int(x), int(y)) + self._handle_left_button_press(int(x), int(y)) + + def _handle_left_button_press(self, x: int, y: int) -> None: + self.pointer_button_down = True + self.left_button_press_position = (int(x), int(y)) + self.left_button_dragged = False + self.pending_hover_position = None + self.last_hover_pick_position = None + self.hover_timer.stop() + self._clear_hover(render=True) + + def on_left_button_release(self, _obj, _event) -> None: + if getattr(self, "skip_next_vtk_left_release", False): + self.skip_next_vtk_left_release = False + return + try: + x, y = self.interactor.GetEventPosition() + except Exception: + x, y = 0, 0 + if not self._is_ui_thread(): + self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._handle_left_button_release(x, y)) + return + self._handle_left_button_release(int(x), int(y)) + + def _handle_left_button_release(self, x: int, y: int) -> None: + self._update_left_button_drag_state(x, y) + was_dragged = bool(getattr(self, "left_button_dragged", False)) + self.pointer_button_down = False + self.left_button_press_position = None + self.left_button_dragged = False + self.pending_hover_position = None + self.last_hover_pick_position = None + if getattr(self, "camera_interaction_active", False): + self._end_camera_interaction() + if was_dragged: + return + self._handle_left_click(x, y) + + def _update_left_button_drag_state(self, x: int, y: int) -> None: + press_position = getattr(self, "left_button_press_position", None) + if press_position is None: + return + threshold = int(getattr(self, "left_click_drag_threshold_px", 6) or 6) + dx = abs(int(x) - int(press_position[0])) + dy = abs(int(y) - int(press_position[1])) + if max(dx, dy) > threshold: + self.left_button_dragged = True def _handle_left_click(self, x: int, y: int) -> None: - self.pointer_button_down = True self.pending_hover_position = None self.last_hover_pick_position = None self.hover_timer.stop() @@ -868,7 +998,11 @@ class WindowCoreMixin: mode = self._current_selection_mode() target = self._pick_selection_target(mode, x, y) if target is None: - self.statusBar().showMessage("未选中对象") + self._cancel_hover_tracking(render=False) + self._reset_selection(clear_highlight=True) + if hasattr(self, "render_window"): + self.render_window.Render() + self.statusBar().showMessage("已取消选择") return self._clear_hover(render=False) @@ -897,6 +1031,7 @@ class WindowCoreMixin: self.pointer_button_down = False self.pending_hover_position = None self.last_hover_pick_position = None + self._end_camera_interaction() def on_mouse_move(self, _obj, _event) -> None: try: @@ -909,8 +1044,15 @@ class WindowCoreMixin: self._handle_mouse_move(int(x), int(y)) def _handle_mouse_move(self, x: int, y: int) -> None: - if QApplication.mouseButtons() != Qt.MouseButton.NoButton: + buttons = QApplication.mouseButtons() + if buttons != Qt.MouseButton.NoButton: self.pointer_button_down = True + if buttons & Qt.MouseButton.LeftButton: + self._update_left_button_drag_state(x, y) + if getattr(self, "left_button_dragged", False): + self._begin_camera_interaction() + else: + self._begin_camera_interaction() return self.pointer_button_down = False if ( @@ -934,10 +1076,7 @@ class WindowCoreMixin: ): self._clear_hover(render=True) return - position = event.position() if hasattr(event, "position") else event.pos() - scale = self.vtk_widget._getPixelRatio() if hasattr(self.vtk_widget, "_getPixelRatio") else 1.0 - x = int(round(float(position.x()) * scale)) - y = int(round((float(self.vtk_widget.height()) - float(position.y()) - 1.0) * scale)) + x, y = self._vtk_position_from_qt_event(event) self._queue_hover_position(x, y) def _queue_hover_position(self, x: int, y: int) -> None: @@ -1213,10 +1352,26 @@ class WindowCoreMixin: self._set_selection_mode("Feature") self.select_feature(int(target_id)) self.statusBar().showMessage(f"已选择可调整凸台直径候选Face {target_id}") + elif action == "resize_boss_height": + self._set_selection_mode("Feature") + self.select_feature(int(target_id)) + self.statusBar().showMessage(f"已选择可调整凸台高度候选Face {target_id}") + elif action == "move_boss_axis": + self._set_selection_mode("Feature") + self.select_feature(int(target_id)) + self.statusBar().showMessage(f"已选择可移动凸台轴心候选Face {target_id}") elif action == "resize_slot_width": self._set_selection_mode("Feature") self.select_feature(int(target_id)) self.statusBar().showMessage(f"已选择可调整槽/半孔宽度候选Face {target_id}") + elif action == "resize_slot_depth": + self._set_selection_mode("Feature") + self.select_feature(int(target_id)) + self.statusBar().showMessage(f"已选择可调整槽/半孔深度候选Face {target_id}") + elif action == "resize_slot_arc_length": + self._set_selection_mode("Feature") + self.select_feature(int(target_id)) + self.statusBar().showMessage(f"已选择可调整槽/半孔圆弧长度候选Face {target_id}") elif action == "resize_shell_thickness": self._set_selection_mode("Feature") self.select_feature(int(target_id)) @@ -1228,7 +1383,7 @@ class WindowCoreMixin: elif action == "resize_depth": self._set_selection_mode("Feature") self.select_feature(int(target_id)) - self.statusBar().showMessage(f"已选择可调整盲孔深度候选Face {target_id}") + self.statusBar().showMessage(f"已选择可调整盲孔/盲槽深度候选Face {target_id}") elif action == "inspect_existing_fillet": self._set_selection_mode("Feature") self.select_feature(int(target_id)) @@ -1305,7 +1460,7 @@ class WindowCoreMixin: if self._edit_busy("编辑计算中,暂时不能选择零件。"): return info = self.model.part_info(part_id) - self._reset_selection(clear_highlight=False) + self._reset_selection(clear_highlight=False, clear_info=False) self.selected_kind = "part" self.selected_part_id = part_id self.selected_pick_position = pick_position @@ -1328,7 +1483,7 @@ class WindowCoreMixin: if self._edit_busy("编辑计算中,暂时不能选择Solid。"): return info = self.model.solid_info(solid_id) - self._reset_selection(clear_highlight=False) + self._reset_selection(clear_highlight=False, clear_info=False) self.selected_kind = "solid" self.selected_part_id = part_id self.selected_solid_id = solid_id @@ -1351,7 +1506,7 @@ class WindowCoreMixin: return if self._edit_busy("编辑计算中,暂时不能选择Face。"): return - self._reset_selection(clear_highlight=False) + self._reset_selection(clear_highlight=False, clear_info=False) self.selected_kind = "feature" if feature_mode else "face" self.selected_face_id = face_id self.selected_pick_position = pick_position @@ -1386,7 +1541,7 @@ class WindowCoreMixin: if self._edit_busy("编辑计算中,暂时不能选择特征。"): return info = self.model.feature_info(face_id) - self._reset_selection(clear_highlight=False) + self._reset_selection(clear_highlight=False, clear_info=False) self.selected_kind = "feature" self.selected_face_id = face_id self.selected_pick_position = pick_position @@ -1417,7 +1572,21 @@ class WindowCoreMixin: self.hole_diameter_input.clear() if hasattr(self, "slot_width_input"): self.slot_width_input.clear() + if hasattr(self, "slot_depth_input"): + self.slot_depth_input.clear() + if hasattr(self, "slot_arc_length_input"): + self.slot_arc_length_input.clear() + if hasattr(self, "slot_total_length_input"): + self.slot_total_length_input.clear() self.boss_diameter_input.setText(suggested_diameter) + if hasattr(self, "boss_height_input"): + boss_height = _float_or_none(info.get("same_domain_height_estimate")) + if boss_height is None: + boss_height = _float_or_none(info.get("height_estimate")) + if boss_height is not None and boss_height > 0: + self.boss_height_input.setText(_format_float(boss_height * 1.2)) + else: + self.boss_height_input.clear() elif feature_guess == "round/fillet candidate": radius = _float_or_none(info.get("existing_fillet_radius_estimate")) if radius is None: @@ -1427,22 +1596,54 @@ class WindowCoreMixin: self.hole_diameter_input.clear() if hasattr(self, "slot_width_input"): self.slot_width_input.clear() + if hasattr(self, "slot_depth_input"): + self.slot_depth_input.clear() + if hasattr(self, "slot_arc_length_input"): + self.slot_arc_length_input.clear() + if hasattr(self, "slot_total_length_input"): + self.slot_total_length_input.clear() self.boss_diameter_input.clear() + if hasattr(self, "boss_height_input"): + self.boss_height_input.clear() else: suggested_diameter = _format_float(float(info["diameter"]) * 1.2) self.hole_diameter_input.setText(suggested_diameter) self.boss_diameter_input.clear() + if hasattr(self, "boss_height_input"): + self.boss_height_input.clear() if hasattr(self, "slot_width_input"): slot_width = _float_or_none(info.get("slot_chord_width_estimate")) if slot_width is not None and slot_width > 0: self.slot_width_input.setText(_format_float(slot_width * 1.2)) else: self.slot_width_input.clear() + if hasattr(self, "slot_depth_input"): + slot_depth = _float_or_none(info.get("slot_sagitta_depth_estimate")) + if slot_depth is not None and slot_depth > 0: + self.slot_depth_input.setText(_format_float(slot_depth * 1.2)) + else: + self.slot_depth_input.clear() + if hasattr(self, "slot_arc_length_input"): + slot_arc = _float_or_none(info.get("slot_arc_length_estimate")) + if slot_arc is not None and slot_arc > 0: + self.slot_arc_length_input.setText(_format_float(slot_arc * 1.2)) + else: + self.slot_arc_length_input.clear() + if hasattr(self, "slot_total_length_input"): + self.slot_total_length_input.clear() else: self.hole_diameter_input.clear() if hasattr(self, "slot_width_input"): self.slot_width_input.clear() + if hasattr(self, "slot_depth_input"): + self.slot_depth_input.clear() + if hasattr(self, "slot_arc_length_input"): + self.slot_arc_length_input.clear() + if hasattr(self, "slot_total_length_input"): + self.slot_total_length_input.clear() self.boss_diameter_input.clear() + if hasattr(self, "boss_height_input"): + self.boss_height_input.clear() if info.get("cylinder_end_type") == "blind" and "hole_depth_estimate" in info: self.hole_depth_input.setText(_format_float(float(info["hole_depth_estimate"]) * 1.2)) else: @@ -1469,7 +1670,15 @@ class WindowCoreMixin: self.shell_thickness_input.clear() if hasattr(self, "slot_width_input"): self.slot_width_input.clear() + if hasattr(self, "slot_depth_input"): + self.slot_depth_input.clear() + if hasattr(self, "slot_arc_length_input"): + self.slot_arc_length_input.clear() + if hasattr(self, "slot_total_length_input"): + self.slot_total_length_input.clear() self.boss_diameter_input.clear() + if hasattr(self, "boss_height_input"): + self.boss_height_input.clear() self.hole_depth_input.clear() if hasattr(self, "hole_bottom_face_input"): self.hole_bottom_face_input.clear() @@ -1491,7 +1700,7 @@ class WindowCoreMixin: return if self._edit_busy("编辑计算中,暂时不能选择Edge。"): return - self._reset_selection(clear_highlight=False) + self._reset_selection(clear_highlight=False, clear_info=False) self.selected_kind = "edge" self.selected_edge_id = edge_id self.selected_pick_position = pick_position @@ -1583,8 +1792,7 @@ class WindowCoreMixin: face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == target_id] self._highlight_hover_faces(face_ids=face_ids) elif kind == "feature": - info = self.model.feature_info(target_id) - face_ids = _int_values(info.get("feature_highlight_face_ids")) or [target_id] + face_ids = self.model.connected_same_domain_face_ids(target_id) or [target_id] self._highlight_hover_faces(face_ids=face_ids) elif kind == "edge": self._highlight_hover_edge(target_id) @@ -1699,6 +1907,7 @@ class WindowCoreMixin: polydata, color: tuple[float, float, float], opacity: float | None = None, + position: tuple[float, float, float] | None = None, ) -> None: mapper = vtk.vtkPolyDataMapper() mapper.SetInputData(polydata) @@ -1708,16 +1917,24 @@ class WindowCoreMixin: actor.GetProperty().SetOpacity(opacity if opacity is not None else self.edit_preview_base_opacity) actor.GetProperty().SetSpecular(0.28) actor.GetProperty().SetLineWidth(1) + if position is not None: + actor.SetPosition(float(position[0]), float(position[1]), float(position[2])) self.edit_preview_actors.append(actor) self.edit_preview_actor = self.edit_preview_actor or actor self.renderer.AddActor(actor) - def _show_push_pull_preview(self, face_id: int, distance: float) -> None: + def _show_push_pull_preview(self, face_id: int, distance: float, plan: dict[str, object] | None = None) -> None: if self.model is None: return self.clear_edit_preview(render=False) try: - polydata = self.model.push_pull_preview_polydata(face_id, distance) + plan = dict(plan or self.model.push_pull_plan(face_id, distance)) + scope_face_ids = _int_values(plan.get("push_pull_scope_face_ids")) or [face_id] + polydata = self._cached_face_overlay_polydata(face_ids=scope_face_ids, smooth=False) + if polydata is None: + polydata = self._cached_face_overlay_polydata(face_ids=[face_id], smooth=False) + if polydata is None: + raise RuntimeError("当前显示网格里没有可复用的选中 Face 预览数据") except Exception as exc: self.statusBar().showMessage(f"推拉预览不可用:{exc}") return @@ -1725,7 +1942,15 @@ class WindowCoreMixin: color = (0.0, 0.86, 0.34) else: color = (1.0, 0.18, 0.06) - self._add_edit_preview_actor(polydata, color) + outward = plan.get("outward_direction") if isinstance(plan, dict) else None + if not isinstance(outward, (tuple, list)) or len(outward) != 3: + outward = (0.0, 0.0, 0.0) + position = ( + float(outward[0]) * float(distance), + float(outward[1]) * float(distance), + float(outward[2]) * float(distance), + ) + self._add_edit_preview_actor(polydata, color, opacity=0.38, position=position) self._start_edit_preview_pulse() self.render_window.Render() @@ -1776,6 +2001,19 @@ class WindowCoreMixin: self._start_edit_preview_pulse() self.render_window.Render() + def _show_cylinder_boss_height_preview(self, face_id: int, target_height: float) -> None: + if self.model is None: + return + self.clear_edit_preview(render=False) + try: + polydata = self.model.cylindrical_boss_height_preview_polydata(face_id, target_height) + except Exception as exc: + self.statusBar().showMessage(f"凸台高度调整预览不可用:{exc}") + return + self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34) + self._start_edit_preview_pulse() + self.render_window.Render() + def _show_cylinder_suppress_preview(self, face_id: int) -> None: if self.model is None: return diff --git a/step_editor/window_state.py b/step_editor/window_state.py index 300ba10..badb49d 100644 --- a/step_editor/window_state.py +++ b/step_editor/window_state.py @@ -3,12 +3,11 @@ from __future__ import annotations from datetime import datetime import math from pathlib import Path - -import vtk from PySide6.QtCore import Qt, QThread, QTimer, Slot from PySide6.QtWidgets import ( QApplication, QFileDialog, + QLineEdit, QMessageBox, QPushButton, QTableWidgetItem, @@ -16,6 +15,7 @@ from PySide6.QtWidgets import ( ) from PySide6.QtGui import QColor +from .constants import SNAPSHOT_FACE_LOGICAL_IDS_KEY from .model import StepModel from .records import OperationRecord from .ui_helpers import * # noqa: F403 @@ -25,49 +25,74 @@ from .workers import EditWorker, LoadWorker, ScanWorker PROPERTY_VALUE_TOLERANCE = 1e-9 +def _record_message_field(message: str | None, key: str) -> str | None: + if not message: + return None + marker = f"{key}=" + start = str(message).find(marker) + if start < 0: + return None + start += len(marker) + end = str(message).find(",", start) + if end < 0: + end = len(str(message)) + value = str(message)[start:end].strip().rstrip(".") + return value or None + + +def _triple_or_none(value: object) -> tuple[float, float, float] | None: + if isinstance(value, str): + text = value.strip().strip("()[]") + chunks = [chunk.strip() for chunk in text.replace(";", ",").split(",") if chunk.strip()] + elif isinstance(value, (list, tuple)): + chunks = list(value) + else: + return None + if len(chunks) != 3: + return None + try: + return (float(chunks[0]), float(chunks[1]), float(chunks[2])) + except (TypeError, ValueError): + return None + + +def _int_or_none(value: object) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + class WindowStateMixin: - def _reset_selection(self, clear_highlight: bool = True) -> None: + def _reset_selection(self, clear_highlight: bool = True, clear_info: bool = True) -> None: self.selected_kind = None self.selected_part_id = None self.selected_solid_id = None self.selected_face_id = None self.selected_edge_id = None self.selected_pick_position = None + if clear_info: + self._clear_current_object_info() if clear_highlight: self._clear_highlight() self._update_selected_object_title() self._update_action_states() + def _clear_current_object_info(self) -> None: + self.current_info_values = {} + self.current_info_text = "" + if hasattr(self, "info_tree"): + self.info_tree.clear() + if hasattr(self, "info_text"): + self.info_text.clear() + self._clear_property_editor() + def _show_pick_marker(self, pick_position: tuple[float, float, float] | None) -> None: - if pick_position is None: - return - radius = self._pick_marker_radius() - sphere = vtk.vtkSphereSource() - sphere.SetCenter(*pick_position) - sphere.SetRadius(radius) - sphere.SetThetaResolution(20) - sphere.SetPhiResolution(12) - - mapper = vtk.vtkPolyDataMapper() - mapper.SetInputConnection(sphere.GetOutputPort()) - actor = vtk.vtkActor() - actor.SetMapper(mapper) - actor.GetProperty().SetColor(0.1, 0.95, 0.95) - actor.GetProperty().SetSpecular(0.4) - actor.GetProperty().SetSpecularPower(18) - self.pick_marker_actor = actor - self.renderer.AddActor(actor) - self.render_window.Render() - - def _pick_marker_radius(self) -> float: - bounds = self.model_actor.GetBounds() if self.model_actor is not None else None - if bounds is None: - return 1.0 - dx = bounds[1] - bounds[0] - dy = bounds[3] - bounds[2] - dz = bounds[5] - bounds[4] - diagonal = math.sqrt(dx * dx + dy * dy + dz * dz) - return max(diagonal * 0.004, 0.1) + if getattr(self, "pick_marker_actor", None) is not None and hasattr(self, "renderer"): + self.renderer.RemoveActor(self.pick_marker_actor) + self.pick_marker_actor = None + if getattr(self, "render_window", None) is not None: + self.render_window.Render() def _with_pick_info( self, @@ -152,6 +177,13 @@ class WindowStateMixin: "按当前显示路径读取 STEP 模型。", wait_or_load_tip, ) + if hasattr(self, "quick_export_all_button"): + self._set_control_state( + self.quick_export_all_button, + has_model, + "导出当前完整 STEP 模型。", + wait_or_load_tip, + ) if hasattr(self, "part_tree"): self._set_control_state( self.part_tree, @@ -334,7 +366,7 @@ class WindowStateMixin: if hasattr(self, "property_table"): self._set_control_state( self.property_table, - has_current_info, + has_selection and has_current_info, "查看当前选中对象的属性;可修改的行可以输入目标值。", "请先选择一个对象。", ) @@ -354,6 +386,17 @@ class WindowStateMixin: is_plane = has_face and surface == "plane" is_shell_candidate = is_plane and action_info.get("shell_region_status") == "candidate" is_cylinder = has_face and surface == "cylinder" and "diameter" in action_info + is_cone = has_face and surface == "cone" and "reference_radius" in action_info + is_sphere = has_face and surface == "sphere" and "radius" in action_info + is_torus = has_face and surface == "torus" and "major_radius" in action_info and "minor_radius" in action_info + is_generic_surface = has_face and surface in { + "bezier surface", + "b-spline surface", + "surface of revolution", + "surface of extrusion", + "offset surface", + "other surface", + } 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" @@ -437,6 +480,19 @@ class WindowStateMixin: "按目标槽宽调整当前槽/半孔候选;当前版本会换算为对应圆柱直径后执行。", "请先选择被识别为槽/半孔的部分圆柱 Face。", ) + if hasattr(self, "resize_slot_depth_button"): + self._set_control_state( + self.slot_depth_input, + can_resize_slot, + "输入槽/半孔候选的目标凹入深度。", + "请先选择被识别为槽/半孔的部分圆柱 Face。", + ) + self._set_control_state( + self.resize_slot_depth_button, + can_resize_slot, + "按目标槽深调整当前槽/半孔候选;当前版本会换算为对应圆柱直径后执行。", + "请先选择被识别为槽/半孔的部分圆柱 Face。", + ) self._set_control_state( self.boss_diameter_input, can_resize_boss, @@ -668,18 +724,45 @@ class WindowStateMixin: if self.selected_kind == "solid" and self.selected_solid_id is not None: return f"Solid {self.selected_solid_id}" if self.selected_kind == "feature" and self.selected_face_id is not None: - return f"特征 Face {self.selected_face_id}" + feature_label = self._selected_feature_label() + if feature_label: + return f"{feature_label} {self.selected_face_id}(来源Face)" + return f"特征 {self.selected_face_id}(来源Face)" if self.selected_kind == "face" and self.selected_face_id is not None: logical_id = self.current_info_values.get("face_region_logical_id") if logical_id is None: logical_id = self.current_info_values.get("logical_face_id") + feature_label = self._selected_feature_label() + feature_suffix = f"(识别为{feature_label})" if feature_label else "" if logical_id not in {None, "", self.selected_face_id}: - return f"Face {logical_id}(拓扑 {self.selected_face_id})" - return f"Face {self.selected_face_id}" + return f"Face {logical_id}(拓扑 {self.selected_face_id}){feature_suffix}" + return f"Face {self.selected_face_id}{feature_suffix}" if self.selected_kind == "edge" and self.selected_edge_id is not None: return f"Edge {self.selected_edge_id}" return "未选择" + def _selected_feature_label(self) -> str: + info = dict(getattr(self, "current_info_values", {}) or {}) + explicit_type = str(info.get("feature_type", "") or "").strip() + if explicit_type: + return explicit_type + surface = str(info.get("surface", "") or "") + feature_guess = str(info.get("feature_guess", "") or "") + if surface == "cylinder" and feature_guess == "hole/groove candidate": + angular_span = _float_or_none(info.get("angular_span")) + if info.get("slot_kind") == "partial-cylindrical-groove" or ( + angular_span is not None and angular_span < math.tau * 0.92 + ): + return "槽/半孔候选" + return "圆柱孔/槽候选" + if surface == "cylinder" and feature_guess == "boss/outer-round candidate": + return "凸台/外圆候选" + if surface == "cylinder" and feature_guess == "round/fillet candidate": + return "圆角/倒圆候选" + if surface == "plane" and info.get("push_pull_status"): + return "可推拉平面候选" + return "" + def _clear_property_editor(self) -> None: if not hasattr(self, "property_table"): return @@ -696,6 +779,18 @@ class WindowStateMixin: def _refresh_property_editor(self) -> None: if not hasattr(self, "property_table"): return + has_selection = any( + item is not None + for item in ( + self.selected_part_id, + self.selected_solid_id, + self.selected_face_id, + self.selected_edge_id, + ) + ) + if not has_selection: + self._clear_property_editor() + return info = dict(getattr(self, "current_info_values", {}) or {}) action_info = self._selected_action_info() for key, value in action_info.items(): @@ -710,25 +805,31 @@ class WindowStateMixin: self.property_table.setRowCount(len(specs)) for row, spec in enumerate(specs): editable = bool(spec.get("editable") and spec.get("enabled")) + value_type = str(spec.get("value_type", "number")) + input_editable = editable and value_type != "command" label_item = self._property_table_item(str(spec.get("label", "")), editable=False) current_item = self._property_table_item(str(spec.get("current_text", "")), editable=False) - target_item = self._property_table_item(str(spec.get("target_text", "")), editable=editable) + target_item = self._property_table_item( + "" if input_editable else str(spec.get("target_text", "")), + editable=False, + ) status_text = "" if editable else str(spec.get("status_text", "")) status_item = self._property_table_item(status_text, editable=False) target_item.setToolTip(self._property_target_tooltip(spec, editable=editable)) - if editable: - target_item.setBackground(QColor("#fff3d9")) - status_item.setForeground(QColor("#166534")) - else: - target_item.setBackground(QColor("#eef2f6")) - target_item.setForeground(QColor("#8f99a8")) - status_item.setForeground(QColor("#64748b")) - for column, item in enumerate((label_item, current_item, target_item, status_item)): + row_items = (label_item, current_item, target_item, status_item) + self._style_property_row_items(row_items, editable=editable) + for column, item in enumerate(row_items): item.setToolTip(item.toolTip() or item.text()) self.property_table.setItem(row, column, item) + self.property_table.setRowHeight(row, 28 if editable else 24) if editable: + if input_editable: + self._set_property_target_editor(row, spec) + else: + self.property_table.removeCellWidget(row, 2) self._set_property_row_button(row, spec) else: + self.property_table.removeCellWidget(row, 2) self.property_table.removeCellWidget(row, 3) self._resize_property_table_height() finally: @@ -736,8 +837,44 @@ class WindowStateMixin: self.property_editor_updating = False self._update_property_apply_state() + def _style_property_row_items( + self, + items: tuple[QTableWidgetItem, QTableWidgetItem, QTableWidgetItem, QTableWidgetItem], + *, + editable: bool, + ) -> None: + label_item, current_item, target_item, status_item = items + if editable: + row_backgrounds = ("#fff7ed", "#fffbeb", "#fff7ed", "#fff7ed") + for item, color in zip(items, row_backgrounds): + item.setBackground(QColor(color)) + label_item.setForeground(QColor("#7c2d12")) + current_item.setForeground(QColor("#431407")) + target_item.setForeground(QColor("#111827")) + status_item.setForeground(QColor("#9a3412")) + label_font = label_item.font() + label_font.setBold(True) + label_item.setFont(label_font) + status_font = status_item.font() + status_font.setBold(True) + status_item.setFont(status_font) + return + target_item.setBackground(QColor("#eef2f6")) + target_item.setForeground(QColor("#8f99a8")) + status_item.setForeground(QColor("#64748b")) + + def _set_property_target_editor(self, row: int, spec: dict[str, object]) -> None: + editor = QLineEdit(str(spec.get("target_text", ""))) + editor.setObjectName("propertyTargetEditor") + editor.setToolTip(self._property_target_tooltip(spec, editable=True)) + editor.setCursor(Qt.CursorShape.IBeamCursor) + editor.setFrame(True) + editor.textChanged.connect(lambda _text="", _row=row: self._update_property_apply_state()) + editor.returnPressed.connect(lambda target_row=row: self.apply_property_row_edit(target_row)) + self.property_table.setCellWidget(row, 2, editor) + def _set_property_row_button(self, row: int, spec: dict[str, object]) -> None: - button = QPushButton("可修改") + button = QPushButton("未改动") button.setObjectName("propertyRowEditButton") tooltip = f"只应用“{spec.get('label', '当前属性')}”这一行的目标值。" range_hint = self._property_range_hint(spec) @@ -745,6 +882,7 @@ class WindowStateMixin: tooltip = f"{tooltip}\n\n{range_hint}" button.setToolTip(tooltip) button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setProperty("changed", False) button.clicked.connect(lambda _checked=False, target_row=row: self.apply_property_row_edit(target_row)) self.property_table.setCellWidget(row, 3, button) @@ -800,10 +938,10 @@ class WindowStateMixin: if hasattr(self, "property_expand_button"): self.property_expand_button.setVisible(has_hidden_rows) if expanded: - self.property_expand_button.setText(f"收起,只显示前 {collapsed_rows} 项") + self.property_expand_button.setText(f"收起到前 {collapsed_rows} 项") self.property_expand_button.setToolTip("收起当前选中对象属性表,让面板只保留最常用的前几行。") else: - self.property_expand_button.setText(f"展开全部属性({row_count} 项)") + self.property_expand_button.setText(f"展开全部属性 ({row_count} 项)") self.property_expand_button.setToolTip("展开当前选中对象的完整属性表;参数化建模按钮会继续留在下方。") def toggle_property_table_expanded(self) -> None: @@ -827,7 +965,7 @@ class WindowStateMixin: ) -> list[dict[str, object]]: editable_specs, used_keys = self._editable_property_specs(action_info) specs = list(editable_specs) - for key, value in self._ordered_info_items(info): + for key, value in self._ordered_property_info_items(info): if key in used_keys: continue specs.append( @@ -858,6 +996,65 @@ class WindowStateMixin: ) return specs + def _ordered_property_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]: + items = self._ordered_info_items(info) + if not self._is_feature_like_info(info): + return items + item_by_key = {key: value for key, value in items} + priority_keys = [ + "feature_type", + "feature_edit_actions", + "feature_mode", + "feature_guess", + "confidence", + "slot_status", + "slot_kind", + "slot_chord_width_estimate", + "slot_sagitta_depth_estimate", + "slot_arc_length_estimate", + "slot_angular_span", + "slot_open_angle", + "slot_note", + "diameter", + "radius", + "height_estimate", + "same_domain_height_estimate", + "hole_depth_estimate", + "cylinder_end_type", + "feature_bottom_face_ids", + "feature_bottom_confidence", + "feature_bottom_note", + "feature_source_face_id", + "feature_face_ids", + "feature_side_face_ids", + "feature_end_face_ids", + "feature_slot_face_ids", + "feature_slot_boundary_face_ids", + "surface", + "axis_point", + "axis", + ] + ordered: list[tuple[str, object]] = [] + emitted: set[str] = set() + for key in priority_keys: + if key in item_by_key and key not in emitted: + ordered.append((key, item_by_key[key])) + emitted.add(key) + ordered.extend((key, value) for key, value in items if key not in emitted) + return ordered + + def _is_feature_like_info(self, info: dict[str, object]) -> bool: + if str(info.get("kind", "") or "") == "feature": + return True + if str(info.get("feature_type", "") or ""): + return True + feature_guess = str(info.get("feature_guess", "") or "") + return feature_guess in { + "hole/groove candidate", + "boss/outer-round candidate", + "round/fillet candidate", + } + def _ordered_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]: ordered: list[tuple[str, object]] = [] emitted: set[str] = set() @@ -881,6 +1078,17 @@ class WindowStateMixin: is_plane = has_face and surface == "plane" is_shell_candidate = is_plane and action_info.get("shell_region_status") == "candidate" is_cylinder = has_face and surface == "cylinder" and "diameter" in action_info + is_cone = has_face and surface == "cone" and "reference_radius" in action_info + is_sphere = has_face and surface == "sphere" and "radius" in action_info + is_torus = has_face and surface == "torus" and "major_radius" in action_info and "minor_radius" in action_info + is_generic_surface = has_face and surface in { + "bezier surface", + "b-spline surface", + "surface of revolution", + "surface of extrusion", + "offset surface", + "other surface", + } 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" @@ -893,6 +1101,10 @@ class WindowStateMixin: ) is_blind = action_info.get("cylinder_end_type") == "blind" has_bottom = bool(_int_values(action_info.get("feature_bottom_face_ids"))) + has_boss_height_cap = bool( + _int_values(action_info.get("feature_start_end_face_ids")) + or _int_values(action_info.get("feature_end_end_face_ids")) + ) has_fillet_support = len(_int_values(action_info.get("feature_existing_fillet_support_face_ids"))) >= 2 is_line_edge = has_edge and curve == "line" specs: list[dict[str, object]] = [] @@ -902,6 +1114,11 @@ class WindowStateMixin: number = _float_or_none(value) return _format_float(number) if number is not None else fallback + def vector_text(value: tuple[float, float, float] | None) -> str: + if value is None: + return "" + return ", ".join(_format_float(item) for item in value) + def relative_range_hint(current: object, caution_ratio: float, high_ratio: float) -> str: number = _float_or_none(current) if number is None or number <= 0: @@ -957,6 +1174,16 @@ class WindowStateMixin: ) return hint + def hole_radius_hint(current_radius: object, current_diameter: object) -> str: + hint = relative_range_hint(current_radius, 0.35, 1.0) + upper = hole_diameter_upper_limit(current_diameter) + if upper is not None: + hint = ( + f"{hint} 当前版本会阻止超过 {_format_float(upper * 0.5)} 的目标半径," + "避免布尔返回成功但孔壁没有真正变成目标半径。" + ) + return hint + def add_spec( *, key: str, @@ -976,12 +1203,18 @@ class WindowStateMixin: max_value: float | None = None, min_exclusive: bool = False, max_exclusive: bool = False, + target_transform: str | None = None, + transform_context: dict[str, object] | None = None, + current_text: str | None = None, + button_text: str | None = None, + choices: dict[str, object] | None = None, + positive_pair: bool = False, ) -> None: specs.append( { "key": key, "label": label, - "current_text": _format_value(current_raw), + "current_text": _format_value(current_raw) if current_text is None else current_text, "current_raw": current_raw, "target_text": target_text, "action": action, @@ -998,22 +1231,55 @@ class WindowStateMixin: "max_value": max_value, "min_exclusive": min_exclusive, "max_exclusive": max_exclusive, + "target_transform": target_transform, + "transform_context": transform_context or {}, + "button_text": button_text or "", + "choices": choices or {}, + "positive_pair": positive_pair, } ) used_keys.update(used or (key,)) if is_plane: + plane_origin = _triple_or_none(action_info.get("plane_origin")) + plane_direction = ( + _triple_or_none(action_info.get("push_pull_outward_direction")) + or _triple_or_none(action_info.get("normal")) + ) + current_plane_position = None + if plane_origin is not None and plane_direction is not None: + current_plane_position = ( + plane_origin[0] * plane_direction[0] + + plane_origin[1] * plane_direction[1] + + plane_origin[2] * plane_direction[2] + ) + add_spec( + key="face_target_normal_position", + label="Face目标法向位置", + current_raw=current_plane_position if current_plane_position is not None else "", + target_text=numeric_text(current_plane_position), + action="push_pull_face", + target_attr="offset_input", + enabled=current_plane_position is not None, + enabled_tip="输入当前平面沿推拉方向的目标位置;程序会换算成相对推拉距离执行。", + disabled_tip="当前平面缺少稳定法向或原点,不能换算目标位置。", + range_hint="这是沿当前推拉方向的一维位置坐标,单位同模型;目标值可大于或小于当前值。", + target_transform="plane_target_position_to_offset", + transform_context={"current_plane_position": current_plane_position}, + used=("plane_origin", "push_pull_outward_direction", "normal"), + ) add_spec( key="push_pull_distance", - label="Face偏移", + label="Face本次偏移量", current_raw=0.0, target_text="0", action="push_pull_face", target_attr="offset_input", enabled=True, - enabled_tip="输入平面 Face 的推拉距离,正数通常向外加料,负数通常向内切削。", + enabled_tip="输入本次沿当前平面推拉方向的相对偏移距离;执行后几何会更新,下一次本次偏移量会重新从 0 开始。", disabled_tip="当前对象不是可推拉的平面 Face。", range_hint=push_pull_hint(), + current_text="0(相对当前)", ) if is_shell_candidate: current = _float_or_none(action_info.get("shell_thickness_estimate")) @@ -1034,6 +1300,7 @@ class WindowStateMixin: ) if is_hole_or_groove: current_diameter = _float_or_none(action_info.get("diameter")) + current_radius = _float_or_none(action_info.get("radius")) diameter_upper = hole_diameter_upper_limit(current_diameter) add_spec( key="diameter", @@ -1050,6 +1317,69 @@ class WindowStateMixin: max_value=diameter_upper, **positive_minimum(), ) + add_spec( + key="hole_cylinder_radius", + label="孔/槽圆柱半径", + current_raw=current_radius if current_radius is not None else "", + target_text=numeric_text(current_radius), + action="resize_hole", + target_attr="hole_diameter_input", + enabled=current_radius is not None, + enabled_tip="输入当前孔/槽圆柱面的目标半径;程序会换算成目标直径后执行。", + disabled_tip="当前圆柱面没有稳定识别为孔/槽候选。", + value_type="positive", + range_hint=hole_radius_hint(current_radius, current_diameter), + target_transform="radius_to_diameter", + used=("radius",), + max_value=diameter_upper * 0.5 if diameter_upper is not None else None, + **positive_minimum(), + ) + axis_point = _triple_or_none(action_info.get("axis_point")) + axis_direction = _triple_or_none(action_info.get("axis")) + axis_range_value = action_info.get("same_domain_v_range") or action_info.get("v_range") + current_axis_center = None + if ( + axis_point is not None + and axis_direction is not None + and isinstance(axis_range_value, (list, tuple)) + and len(axis_range_value) >= 2 + ): + v_min = _float_or_none(axis_range_value[0]) + v_max = _float_or_none(axis_range_value[1]) + if v_min is not None and v_max is not None: + v_mid = (v_min + v_max) * 0.5 + current_axis_center = ( + axis_point[0] + axis_direction[0] * v_mid, + axis_point[1] + axis_direction[1] * v_mid, + axis_point[2] + axis_direction[2] * v_mid, + ) + add_spec( + key="hole_axis_center", + label="孔轴心坐标", + current_raw=current_axis_center if current_axis_center is not None else "", + target_text=vector_text(current_axis_center), + action="move_cylindrical_hole_axis", + target_attrs=("hole_center_x_input", "hole_center_y_input", "hole_center_z_input"), + enabled=bool(is_full_cylinder and current_axis_center is not None and current_diameter is not None), + enabled_tip="输入完整圆柱孔轴心的目标坐标 X, Y, Z;程序会先填旧孔,再按同直径切出新孔。", + disabled_tip="当前只对接近完整圆柱的孔放行轴心坐标修改;半孔/开放槽需要保留扇形开口,暂不使用整圆柱工具修改。", + value_type="vector3", + range_hint=translation_hint(), + used=("axis_point", "axis", "same_domain_v_range", "v_range"), + ) + add_spec( + key="suppress_cylindrical_hole", + label="封堵圆柱孔", + current_raw="未封堵", + target_text="无需输入", + action="suppress_hole", + enabled=bool(is_full_cylinder and current_diameter is not None), + enabled_tip="用补料体封堵当前完整圆柱孔。点击状态列按钮执行,不需要输入目标值。", + disabled_tip="只有接近完整圆柱面的孔/槽候选才能直接封堵;半孔或槽请先使用槽/孔尺寸修改。", + value_type="command", + range_hint="该操作会直接改变几何并写入历史;复杂孔失败时会回滚到操作前状态。", + button_text="封堵", + ) if is_slot_or_half_hole: current_slot = _float_or_none(action_info.get("slot_chord_width_estimate")) add_spec( @@ -1066,8 +1396,182 @@ class WindowStateMixin: range_hint=relative_range_hint(current_slot, 0.35, 1.0), **positive_minimum(), ) + current_slot_depth = _float_or_none(action_info.get("slot_sagitta_depth_estimate")) + add_spec( + key="slot_sagitta_depth_estimate", + label="槽/半孔深度", + current_raw=current_slot_depth if current_slot_depth is not None else "", + target_text=numeric_text(current_slot_depth), + action="resize_slot_depth", + target_attr="slot_depth_input", + enabled=current_slot_depth is not None, + enabled_tip="输入槽或半孔的目标凹入深度。", + disabled_tip="当前对象没有稳定的槽/半孔深度估算。", + value_type="positive", + range_hint=relative_range_hint(current_slot_depth, 0.35, 1.0), + **positive_minimum(), + ) + current_slot_arc = _float_or_none(action_info.get("slot_arc_length_estimate")) + add_spec( + key="slot_arc_length_estimate", + label="槽/半孔圆弧长度", + current_raw=current_slot_arc if current_slot_arc is not None else "", + target_text=numeric_text(current_slot_arc), + action="resize_slot_arc_length", + target_attr="slot_arc_length_input", + enabled=current_slot_arc is not None, + enabled_tip="输入槽或半孔的目标圆弧长度。", + disabled_tip="当前对象没有稳定的槽/半孔圆弧长度估算。", + value_type="positive", + range_hint=relative_range_hint(current_slot_arc, 0.35, 1.0), + **positive_minimum(), + ) + current_slot_angle = ( + _float_or_none(action_info.get("slot_angular_span")) + or _float_or_none(action_info.get("angular_span")) + ) + current_slot_angle_degrees = ( + math.degrees(current_slot_angle) if current_slot_angle is not None else None + ) + add_spec( + key="slot_angular_span_degrees", + label="槽/半孔圆弧角度(度)", + current_raw=current_slot_angle_degrees if current_slot_angle_degrees is not None else "", + target_text=numeric_text(current_slot_angle_degrees), + action="resize_slot_angular_span", + target_attr="slot_angular_span_input", + enabled=current_slot_angle is not None, + enabled_tip="输入槽或半孔的目标圆弧角度;程序会保持当前圆柱半径并重建局部扇形槽。", + disabled_tip="当前对象没有稳定的槽/半孔圆弧角度估算。", + value_type="positive", + range_hint="建议先小幅修改;当前版本允许大于 0 且小于约 331 度的局部圆柱槽角度。", + max_value=math.degrees(math.tau * 0.92), + max_exclusive=True, + target_transform="degrees_to_radians", + used=("slot_angular_span", "angular_span"), + **positive_minimum(), + ) + current_slot_open_angle_degrees = ( + math.degrees(max(math.tau - current_slot_angle, 0.0)) + if current_slot_angle is not None + else None + ) + add_spec( + key="slot_open_angle_degrees", + label="槽/半孔开口角度(度)", + current_raw=current_slot_open_angle_degrees if current_slot_open_angle_degrees is not None else "", + target_text=numeric_text(current_slot_open_angle_degrees), + action="resize_slot_angular_span", + target_attr="slot_angular_span_input", + enabled=current_slot_angle is not None, + enabled_tip="输入槽或半孔的目标开口角度;程序会换算成圆弧角度后重建局部扇形槽。", + disabled_tip="当前对象没有稳定的槽/半孔开口角度估算。", + value_type="positive", + range_hint="开口角越小,槽越接近完整圆柱;当前版本要求开口角大于约 29 度且小于 360 度。", + min_value=math.degrees(math.tau * 0.08), + min_exclusive=True, + max_value=360.0, + max_exclusive=True, + target_transform="slot_open_angle_degrees_to_angular_span", + used=("slot_open_angle",), + ) + manual_slot_pair_text = self.slot_pair_face_input.text().strip() if hasattr(self, "slot_pair_face_input") else "" + manual_slot_pair_id = None + if manual_slot_pair_text: + try: + manual_slot_pair_id = int(manual_slot_pair_text) + except ValueError: + manual_slot_pair_id = None + current_diameter_for_slot = _float_or_none(action_info.get("diameter")) + has_manual_slot_pair = bool(manual_slot_pair_text and manual_slot_pair_id is not None) + slot_pair_target_text = manual_slot_pair_text + add_spec( + key="slot_pair_face_id", + label="槽孔配对端Face ID", + current_raw=slot_pair_target_text, + target_text=slot_pair_target_text, + action="set_manual_slot_pair_face", + target_attr="slot_pair_face_input", + enabled=True, + enabled_tip="设置长圆槽/槽孔另一个半圆端的 Face ID;自动配对不准时,先填这里再修改槽孔参数。", + disabled_tip="当前对象不是槽/半孔候选,不能设置槽孔配对端。", + value_type="integer_or_empty", + range_hint="留空表示使用自动识别结果;填写时必须是当前模型里存在、且不是当前槽端自己的 Face ID。", + min_value=0.0, + button_text="设置", + ) + add_spec( + key="slot_total_length_estimate", + label="槽孔总长度", + current_raw="", + current_text="执行时计算", + target_text="", + action="resize_slot_total_length", + target_attr="slot_total_length_input", + enabled=has_manual_slot_pair, + enabled_tip="输入长圆槽/槽孔的目标总长度;程序会在点击修改时按手动配对端计算当前长度并执行。", + disabled_tip="为了避免选择时卡顿,当前不会自动扫描配对端;请先在上一行手动填写另一个半圆槽端 Face ID。", + value_type="positive", + range_hint="需要先填写槽孔配对端 Face ID。点击修改时才会做配对端几何计划,避免选中对象时卡住界面。", + **positive_minimum(), + ) + add_spec( + key="slot_center_distance_estimate", + label="槽孔中心距", + current_raw="", + current_text="执行时计算", + target_text="", + action="resize_slot_total_length", + target_attr="slot_total_length_input", + enabled=has_manual_slot_pair and current_diameter_for_slot is not None, + enabled_tip="输入长圆槽两端半圆中心距;程序会用当前槽宽换算成槽孔总长度后执行。", + disabled_tip="请先填写有效的槽孔配对端 Face ID;当前槽还需要有稳定直径才能按中心距换算。", + value_type="positive", + range_hint="目标槽孔总长度 = 目标中心距 + 当前槽宽/直径。点击修改时才会计算当前配对几何。", + target_transform="slot_center_distance_to_total_length", + transform_context={"slot_current_diameter": current_diameter_for_slot}, + **positive_minimum(), + ) + bottom_face_ids = _int_values(action_info.get("feature_bottom_face_ids")) + auto_bottom_face_id = bottom_face_ids[0] if bottom_face_ids else None + manual_bottom_text = self.hole_bottom_face_input.text().strip() if hasattr(self, "hole_bottom_face_input") else "" + manual_bottom_id = None + if manual_bottom_text: + try: + manual_bottom_id = int(manual_bottom_text) + except ValueError: + manual_bottom_id = None + bottom_target_text = manual_bottom_text or (str(auto_bottom_face_id) if auto_bottom_face_id is not None else "") + add_spec( + key="hole_bottom_face_id", + label="底面Face ID", + current_raw=bottom_target_text, + target_text=bottom_target_text, + action="set_manual_hole_bottom_face", + target_attr="hole_bottom_face_input", + enabled=True, + enabled_tip="设置盲孔/盲槽的底面 Face ID;自动识别不准时,先填这里再修改深度。", + disabled_tip="当前对象不是孔/槽候选,不能设置底面 Face ID。", + value_type="integer_or_empty", + range_hint="留空表示使用自动识别结果;填写时必须是当前模型里存在的 Face ID。", + min_value=0.0, + button_text="设置", + ) current_depth = _float_or_none(action_info.get("hole_depth_estimate")) - can_depth = bool(is_blind and has_bottom and current_depth is not None) + if self.model is not None and self.selected_face_id is not None and manual_bottom_id is not None: + probe_depth = current_depth if current_depth is not None and current_depth > 0 else 1.0 + try: + depth_plan = self.model.cylindrical_depth_plan( + self.selected_face_id, + probe_depth, + bottom_face_id=manual_bottom_id, + ) + manual_depth = _float_or_none(depth_plan.get("current_depth")) + if manual_depth is not None and manual_depth > 0: + current_depth = manual_depth + except Exception: + pass + can_depth = bool((is_blind or manual_bottom_id is not None) and (has_bottom or manual_bottom_id is not None) and current_depth is not None) if is_blind or current_depth is not None: add_spec( key="hole_depth_estimate", @@ -1085,6 +1589,7 @@ class WindowStateMixin: ) if is_boss: current_boss = _float_or_none(action_info.get("diameter")) + current_boss_radius = _float_or_none(action_info.get("radius")) add_spec( key="boss_diameter", label="凸台直径", @@ -1100,6 +1605,125 @@ class WindowStateMixin: used=("diameter",), **positive_minimum(), ) + add_spec( + key="boss_radius", + label="凸台半径", + current_raw=current_boss_radius if current_boss_radius is not None else "", + target_text=numeric_text(current_boss_radius), + action="resize_boss", + target_attr="boss_diameter_input", + enabled=bool(is_full_cylinder and current_boss_radius is not None), + enabled_tip="输入完整圆柱凸台的目标半径;程序会换算成目标直径后执行。", + disabled_tip="当前凸台候选不是接近完整圆柱,暂不放行半径修改。", + value_type="positive", + range_hint=relative_range_hint(current_boss_radius, 0.3, 0.8), + target_transform="radius_to_diameter", + used=("radius",), + **positive_minimum(), + ) + boss_axis_point = _triple_or_none(action_info.get("axis_point")) + boss_axis_direction = _triple_or_none(action_info.get("axis")) + boss_axis_range = action_info.get("same_domain_v_range") or action_info.get("v_range") + current_boss_axis_center = None + if ( + boss_axis_point is not None + and boss_axis_direction is not None + and isinstance(boss_axis_range, (list, tuple)) + and len(boss_axis_range) >= 2 + ): + v_min = _float_or_none(boss_axis_range[0]) + v_max = _float_or_none(boss_axis_range[1]) + if v_min is not None and v_max is not None: + v_mid = (v_min + v_max) * 0.5 + current_boss_axis_center = ( + boss_axis_point[0] + boss_axis_direction[0] * v_mid, + boss_axis_point[1] + boss_axis_direction[1] * v_mid, + boss_axis_point[2] + boss_axis_direction[2] * v_mid, + ) + add_spec( + key="boss_axis_center", + label="凸台轴心坐标", + current_raw=current_boss_axis_center if current_boss_axis_center is not None else "", + target_text=vector_text(current_boss_axis_center), + action="move_cylindrical_boss_axis", + target_attrs=("boss_center_x_input", "boss_center_y_input", "boss_center_z_input"), + enabled=bool(is_full_cylinder and current_boss_axis_center is not None and current_boss is not None), + enabled_tip="输入完整圆柱凸台轴心的目标坐标 X, Y, Z;程序会先移除旧凸台包络,再按同直径在目标轴心补出凸台。", + disabled_tip="当前只对接近完整圆柱的凸台候选放行轴心坐标修改。", + value_type="vector3", + range_hint=translation_hint(), + used=("axis_point", "axis", "same_domain_v_range", "v_range"), + ) + current_boss_height = _float_or_none(action_info.get("same_domain_height_estimate")) + if current_boss_height is None: + current_boss_height = _float_or_none(action_info.get("height_estimate")) + add_spec( + key="boss_height", + label="凸台高度", + current_raw=current_boss_height if current_boss_height is not None else "", + target_text=numeric_text(current_boss_height), + action="resize_boss_height", + target_attr="boss_height_input", + enabled=bool(is_full_cylinder and current_boss_height is not None and has_boss_height_cap), + enabled_tip="输入完整圆柱凸台的目标高度;程序会推拉识别到的凸台端盖 Face。", + disabled_tip="当前凸台候选缺少稳定高度或端盖信息,暂不放行高度修改。", + value_type="positive", + range_hint=relative_range_hint(current_boss_height, 0.3, 0.8), + used=("same_domain_height_estimate", "height_estimate"), + **positive_minimum(), + ) + if is_cylinder and not is_hole_or_groove and not is_boss and not is_existing_fillet: + current_generic_diameter = _float_or_none(action_info.get("diameter")) + current_generic_radius = _float_or_none(action_info.get("radius")) + add_spec( + key="generic_cylinder_diameter", + label="未明确圆柱直径", + current_raw=current_generic_diameter if current_generic_diameter is not None else "", + target_text=numeric_text(current_generic_diameter), + action="resize_hole", + target_attr="hole_diameter_input", + enabled=current_generic_diameter is not None, + enabled_tip="输入当前未明确圆柱面的目标直径;程序会按高风险圆柱切削/重切兜底路线尝试。", + disabled_tip="当前圆柱面缺少稳定直径,不能直接修改。", + value_type="positive", + range_hint="高风险兜底:如果这是凸台或圆角,请改用凸台/圆角专门入口;如果是未识别的孔或槽,可以小幅尝试。", + used=("diameter",), + **positive_minimum(), + ) + add_spec( + key="generic_cylinder_radius", + label="未明确圆柱半径", + current_raw=current_generic_radius if current_generic_radius is not None else "", + target_text=numeric_text(current_generic_radius), + action="resize_hole", + target_attr="hole_diameter_input", + enabled=current_generic_radius is not None, + enabled_tip="输入当前未明确圆柱面的目标半径;程序会换算成直径后按高风险兜底路线尝试。", + disabled_tip="当前圆柱面缺少稳定半径,不能直接修改。", + value_type="positive", + range_hint="高风险兜底:半径会先换算成直径;建议只做小幅修改并检查结果。", + target_transform="radius_to_diameter", + used=("radius",), + **positive_minimum(), + ) + current_cylinder_height = _float_or_none(action_info.get("same_domain_height_estimate")) + if current_cylinder_height is None: + current_cylinder_height = _float_or_none(action_info.get("height_estimate")) + add_spec( + key="cylinder_height", + label="圆柱高度", + current_raw=current_cylinder_height if current_cylinder_height is not None else "", + target_text=numeric_text(current_cylinder_height), + action="resize_cylinder_height", + target_attr="boss_height_input", + enabled=bool(is_full_cylinder and current_cylinder_height is not None and has_boss_height_cap), + enabled_tip="输入完整圆柱面的目标高度;程序会推拉识别到的圆柱端盖 Face。", + disabled_tip="当前圆柱面不是完整圆柱,或缺少可推拉端盖信息。", + value_type="positive", + range_hint=relative_range_hint(current_cylinder_height, 0.3, 0.8), + used=("same_domain_height_estimate", "height_estimate"), + **positive_minimum(), + ) if is_existing_fillet: current_radius = _float_or_none(action_info.get("existing_fillet_radius_estimate")) if current_radius is None: @@ -1126,8 +1750,208 @@ class WindowStateMixin: used=("existing_fillet_radius_estimate", "radius"), **positive_minimum(), ) + current_fillet_arc = _float_or_none(action_info.get("existing_fillet_arc_length_estimate")) + current_fillet_span = _float_or_none(action_info.get("existing_fillet_angular_span")) + if current_fillet_span is None: + current_fillet_span = _float_or_none(action_info.get("angular_span")) + add_spec( + key="existing_fillet_arc_length_estimate", + label="已有圆角圆弧长度", + current_raw=current_fillet_arc if current_fillet_arc is not None else "", + target_text=numeric_text(current_fillet_arc), + action="resize_existing_fillet", + target_attr="edge_fillet_radius_input", + enabled=bool( + current_fillet_arc is not None + and current_fillet_span is not None + and current_fillet_span > 1e-6 + and has_fillet_support + ), + enabled_tip="输入已有圆角/倒圆面的目标圆弧长度;程序会按当前圆弧角度换算成目标半径。", + disabled_tip="当前圆角候选缺少稳定圆弧长度、角度或支撑面,暂不放行修改。", + value_type="positive", + range_hint=relative_range_hint(current_fillet_arc, 0.35, 1.0), + target_transform="arc_length_to_radius", + transform_context={"angular_span": current_fillet_span}, + used=("existing_fillet_arc_length_estimate",), + **positive_minimum(), + ) + if is_cone: + current_reference_radius = _float_or_none(action_info.get("reference_radius")) + current_reference_diameter = ( + current_reference_radius * 2.0 if current_reference_radius is not None else None + ) + add_spec( + key="cone_reference_radius", + label="圆锥参考半径", + current_raw=current_reference_radius if current_reference_radius is not None else "", + target_text=numeric_text(current_reference_radius), + action="resize_cone_reference_radius", + target_attr="cone_reference_radius_input", + enabled=current_reference_radius is not None, + enabled_tip="输入圆锥面的目标参考半径;程序会围绕圆锥轴做径向缩放。", + disabled_tip="当前圆锥面缺少稳定参考半径,不能直接修改。", + value_type="positive", + range_hint=relative_range_hint(current_reference_radius, 0.25, 0.6), + used=("reference_radius",), + **positive_minimum(), + ) + add_spec( + key="cone_reference_diameter", + label="圆锥参考直径", + current_raw=current_reference_diameter if current_reference_diameter is not None else "", + target_text=numeric_text(current_reference_diameter), + action="resize_cone_reference_radius", + target_attr="cone_reference_radius_input", + enabled=current_reference_diameter is not None, + enabled_tip="输入圆锥面的目标参考直径;程序会换算为参考半径后围绕圆锥轴径向缩放。", + disabled_tip="当前圆锥面缺少稳定参考直径,不能直接修改。", + value_type="positive", + range_hint=relative_range_hint(current_reference_diameter, 0.25, 0.6), + target_transform="diameter_to_radius", + used=("feature_reference_diameter",), + **positive_minimum(), + ) + current_semi_angle = _float_or_none(action_info.get("semi_angle")) + current_semi_angle_degrees = ( + abs(math.degrees(current_semi_angle)) if current_semi_angle is not None else None + ) + add_spec( + key="cone_semi_angle_degrees", + label="圆锥半角(度)", + current_raw=current_semi_angle_degrees if current_semi_angle_degrees is not None else "", + target_text=numeric_text(current_semi_angle_degrees), + action="resize_cone_reference_radius", + target_attr="cone_reference_radius_input", + enabled=current_reference_radius is not None and current_semi_angle is not None, + enabled_tip="输入圆锥面的目标半角;程序会换算为目标参考半径后围绕圆锥轴径向缩放。", + disabled_tip="当前圆锥面缺少稳定参考半径或半角,不能直接修改半角。", + value_type="positive", + range_hint="建议先小幅修改;当前版本要求半角大于 0 且小于 89 度。", + max_value=89.0, + max_exclusive=True, + target_transform="cone_semi_angle_degrees_to_reference_radius", + transform_context={ + "current_reference_radius": current_reference_radius, + "current_semi_angle": current_semi_angle, + }, + used=("semi_angle",), + **positive_minimum(), + ) + if is_sphere: + current_sphere_radius = _float_or_none(action_info.get("radius")) + current_sphere_diameter = _float_or_none(action_info.get("diameter")) + add_spec( + key="sphere_radius", + label="球面半径", + current_raw=current_sphere_radius if current_sphere_radius is not None else "", + target_text=numeric_text(current_sphere_radius), + action="resize_sphere_radius", + target_attr="sphere_radius_input", + enabled=current_sphere_radius is not None, + enabled_tip="输入球面的目标半径;程序会围绕球心均匀缩放所属零件/Solid。", + disabled_tip="当前球面缺少稳定半径,不能直接修改。", + value_type="positive", + range_hint=relative_range_hint(current_sphere_radius, 0.25, 0.6), + used=("radius",), + **positive_minimum(), + ) + add_spec( + key="sphere_diameter", + label="球面直径", + current_raw=current_sphere_diameter if current_sphere_diameter is not None else "", + target_text=numeric_text(current_sphere_diameter), + action="resize_sphere_radius", + target_attr="sphere_radius_input", + enabled=current_sphere_diameter is not None, + enabled_tip="输入球面的目标直径;程序会换算为半径后围绕球心均匀缩放。", + disabled_tip="当前球面缺少稳定直径,不能直接修改。", + value_type="positive", + range_hint=relative_range_hint(current_sphere_diameter, 0.25, 0.6), + target_transform="diameter_to_radius", + used=("diameter", "feature_sphere_diameter"), + **positive_minimum(), + ) + if is_torus: + current_major_radius = _float_or_none(action_info.get("major_radius")) + current_minor_radius = _float_or_none(action_info.get("minor_radius")) + current_major_diameter = ( + current_major_radius * 2.0 if current_major_radius is not None else None + ) + current_minor_diameter = ( + current_minor_radius * 2.0 if current_minor_radius is not None else None + ) + add_spec( + key="torus_major_radius", + label="环面主半径", + current_raw=current_major_radius if current_major_radius is not None else "", + target_text=numeric_text(current_major_radius), + action="resize_torus_major_radius", + target_attr="torus_radius_input", + enabled=current_major_radius is not None, + enabled_tip="输入环面的目标主半径;当前版本会围绕环面中心均匀缩放,主半径和小半径会等比例变化。", + disabled_tip="当前环面缺少稳定主半径,不能直接修改。", + value_type="positive", + range_hint=relative_range_hint(current_major_radius, 0.25, 0.6), + used=("major_radius", "feature_torus_major_radius"), + **positive_minimum(), + ) + add_spec( + key="torus_major_diameter", + label="环面主直径", + current_raw=current_major_diameter if current_major_diameter is not None else "", + target_text=numeric_text(current_major_diameter), + action="resize_torus_major_radius", + target_attr="torus_radius_input", + enabled=current_major_diameter is not None, + enabled_tip="输入环面的目标主直径;程序会换算为主半径后执行环面修改。", + disabled_tip="当前环面缺少稳定主直径,不能直接修改。", + value_type="positive", + range_hint=relative_range_hint(current_major_diameter, 0.25, 0.6), + target_transform="diameter_to_radius", + used=("feature_torus_major_radius",), + **positive_minimum(), + ) + add_spec( + key="torus_minor_radius", + label="环面小半径", + current_raw=current_minor_radius if current_minor_radius is not None else "", + target_text=numeric_text(current_minor_radius), + action="resize_torus_minor_radius", + target_attr="torus_radius_input", + enabled=current_minor_radius is not None, + enabled_tip="输入环面的目标小半径;当前版本会围绕环面中心均匀缩放,主半径和小半径会等比例变化。", + disabled_tip="当前环面缺少稳定小半径,不能直接修改。", + value_type="positive", + range_hint=relative_range_hint(current_minor_radius, 0.25, 0.6), + used=("minor_radius", "feature_torus_minor_radius"), + **positive_minimum(), + ) + add_spec( + key="torus_minor_diameter", + label="环面小直径", + current_raw=current_minor_diameter if current_minor_diameter is not None else "", + target_text=numeric_text(current_minor_diameter), + action="resize_torus_minor_radius", + target_attr="torus_radius_input", + enabled=current_minor_diameter is not None, + enabled_tip="输入环面的目标小直径;程序会换算为小半径后执行环面修改。", + disabled_tip="当前环面缺少稳定小直径,不能直接修改。", + value_type="positive", + range_hint=relative_range_hint(current_minor_diameter, 0.25, 0.6), + target_transform="diameter_to_radius", + used=("feature_torus_minor_radius",), + **positive_minimum(), + ) if has_edge: current_length = _float_or_none(action_info.get("length")) + is_circle_edge = curve == "circle" + is_ellipse_edge = curve == "ellipse" + current_anchor_mode = "auto" + current_anchor_label = "自动" + if hasattr(self, "edge_length_anchor_combo"): + current_anchor_mode = str(self.edge_length_anchor_combo.currentData() or "auto") + current_anchor_label = self.edge_length_anchor_combo.currentText() add_spec( key="length", label="Edge长度", @@ -1142,6 +1966,166 @@ class WindowStateMixin: range_hint=relative_range_hint(current_length, 0.25, 0.5), **positive_minimum(), ) + add_spec( + key="edge_length_anchor_mode", + label="Edge长度基准", + current_raw=current_anchor_mode, + target_text=current_anchor_label, + action="set_edge_length_anchor_mode", + target_attr="edge_length_anchor_combo", + enabled=True, + enabled_tip="设置修改Edge长度时固定哪里:自动、中心、固定起点或固定终点。", + disabled_tip="请先选择一条Edge。", + value_type="choice", + range_hint="可输入:自动、中心、固定起点、固定终点,也可以输入 auto、center、start、end。", + button_text="设置", + choices={ + "自动": "auto", + "auto": "auto", + "中心": "center", + "固定中心": "center", + "center": "center", + "centre": "center", + "起点": "keep-start", + "固定起点": "keep-start", + "start": "keep-start", + "keep-start": "keep-start", + "终点": "keep-end", + "固定终点": "keep-end", + "end": "keep-end", + "keep-end": "keep-end", + }, + ) + edge_start_point = _triple_or_none(action_info.get("start_point")) + edge_end_point = _triple_or_none(action_info.get("end_point")) + edge_center_point = _triple_or_none(action_info.get("length_center")) + can_move_line_endpoint = bool(is_line_edge and edge_start_point is not None and edge_end_point is not None) + add_spec( + key="edge_start_point", + label="Edge起点坐标", + current_raw=edge_start_point if edge_start_point is not None else "", + target_text=vector_text(edge_start_point), + action="move_edge_start_point", + target_attrs=("edge_start_x_input", "edge_start_y_input", "edge_start_z_input"), + enabled=can_move_line_endpoint, + enabled_tip="输入目标 X, Y, Z 坐标,只移动当前直线 Edge 的起点,并重建周边平面。", + disabled_tip="只有带稳定起点/终点的直线 Edge 才能直接移动端点坐标。", + value_type="vector3", + range_hint="格式为 X, Y, Z。建议先小幅移动;当前版本仅对简单全平面多面体开放。", + used=("start_point",), + ) + add_spec( + key="edge_center_point", + label="Edge中心坐标", + current_raw=edge_center_point if edge_center_point is not None else "", + target_text=vector_text(edge_center_point), + action="move_edge_center_point", + target_attrs=("edge_center_x_input", "edge_center_y_input", "edge_center_z_input"), + enabled=bool(can_move_line_endpoint and edge_center_point is not None), + enabled_tip="输入目标 X, Y, Z 坐标,保持当前直线Edge长度不变并移动整条Edge。", + disabled_tip="只有带稳定起点/终点/中心的直线 Edge 才能直接移动中心坐标。", + value_type="vector3", + range_hint="格式为 X, Y, Z。建议先小幅移动;当前版本仅对简单全平面多面体开放。", + used=("length_center",), + ) + add_spec( + key="edge_end_point", + label="Edge终点坐标", + current_raw=edge_end_point if edge_end_point is not None else "", + target_text=vector_text(edge_end_point), + action="move_edge_end_point", + target_attrs=("edge_end_x_input", "edge_end_y_input", "edge_end_z_input"), + enabled=can_move_line_endpoint, + enabled_tip="输入目标 X, Y, Z 坐标,只移动当前直线 Edge 的终点,并重建周边平面。", + disabled_tip="只有带稳定起点/终点的直线 Edge 才能直接移动端点坐标。", + value_type="vector3", + range_hint="格式为 X, Y, Z。建议先小幅移动;当前版本仅对简单全平面多面体开放。", + used=("end_point",), + ) + if is_circle_edge: + current_radius = _float_or_none(action_info.get("radius")) + current_diameter = _float_or_none(action_info.get("diameter")) + can_resize_circle = bool(current_length is not None and current_length > 0 and current_radius is not None and current_radius > 0) + circle_context = { + "edge_current_length": current_length, + "edge_current_radius": current_radius, + } + add_spec( + key="circle_edge_radius", + label="圆Edge半径", + current_raw=current_radius if current_radius is not None else "", + target_text=numeric_text(current_radius), + action="resize_any_edge_length", + target_attr="edge_target_length_input", + enabled=can_resize_circle, + enabled_tip="输入圆形/圆弧Edge的目标半径;程序会换算成等比例Edge目标长度后执行。", + disabled_tip="当前圆Edge缺少稳定半径或长度信息。", + value_type="positive", + range_hint=relative_range_hint(current_radius, 0.25, 0.5), + target_transform="circle_edge_radius_to_length", + transform_context=circle_context, + used=("radius",), + **positive_minimum(), + ) + add_spec( + key="circle_edge_diameter", + label="圆Edge直径", + current_raw=current_diameter if current_diameter is not None else "", + target_text=numeric_text(current_diameter), + action="resize_any_edge_length", + target_attr="edge_target_length_input", + enabled=bool(can_resize_circle and current_diameter is not None and current_diameter > 0), + enabled_tip="输入圆形/圆弧Edge的目标直径;程序会换算成等比例Edge目标长度后执行。", + disabled_tip="当前圆Edge缺少稳定直径或长度信息。", + value_type="positive", + range_hint=relative_range_hint(current_diameter, 0.25, 0.5), + target_transform="circle_edge_diameter_to_length", + transform_context=circle_context, + used=("diameter",), + **positive_minimum(), + ) + if is_ellipse_edge: + current_major = _float_or_none(action_info.get("major_radius")) + current_minor = _float_or_none(action_info.get("minor_radius")) + ellipse_context = { + "edge_current_length": current_length, + "edge_current_major_radius": current_major, + "edge_current_minor_radius": current_minor, + } + add_spec( + key="ellipse_edge_major_radius", + label="椭圆Edge主半径", + current_raw=current_major if current_major is not None else "", + target_text=numeric_text(current_major), + action="resize_any_edge_length", + target_attr="edge_target_length_input", + enabled=bool(current_length is not None and current_length > 0 and current_major is not None and current_major > 0), + enabled_tip="输入椭圆Edge的目标主半径;程序会换算成目标Edge长度并执行平面曲线缩放。", + disabled_tip="当前椭圆Edge缺少稳定主半径或长度信息。", + value_type="positive", + range_hint=relative_range_hint(current_major, 0.25, 0.5), + target_transform="ellipse_edge_major_radius_to_length", + transform_context=ellipse_context, + used=("major_radius",), + **positive_minimum(), + ) + add_spec( + key="ellipse_edge_minor_radius", + label="椭圆Edge小半径", + current_raw=current_minor if current_minor is not None else "", + target_text=numeric_text(current_minor), + action="resize_any_edge_length", + target_attr="edge_target_length_input", + enabled=bool(current_length is not None and current_length > 0 and current_minor is not None and current_minor > 0), + enabled_tip="输入椭圆Edge的目标小半径;程序会换算成目标Edge长度并执行平面曲线缩放。", + disabled_tip="当前椭圆Edge缺少稳定小半径或长度信息。", + value_type="positive", + range_hint=relative_range_hint(current_minor, 0.25, 0.5), + target_transform="ellipse_edge_minor_radius_to_length", + transform_context=ellipse_context, + used=("minor_radius",), + **positive_minimum(), + ) if is_line_edge: edge_limit = current_length * 0.45 if current_length is not None and current_length > 0 else None edge_soft = current_length * 0.12 if current_length is not None and current_length > 0 else None @@ -1189,6 +2173,65 @@ class WindowStateMixin: max_exclusive=edge_limit is not None, **positive_minimum(), ) + adjacent_face_ids = _int_values(action_info.get("adjacent_face_ids")) + default_chamfer_reference = adjacent_face_ids[0] if adjacent_face_ids else None + current_chamfer_reference = default_chamfer_reference + if hasattr(self, "edge_chamfer_reference_face_input"): + hidden_reference = self.edge_chamfer_reference_face_input.text().strip() + if hidden_reference: + try: + hidden_reference_id = int(hidden_reference) + if not adjacent_face_ids or hidden_reference_id in adjacent_face_ids: + current_chamfer_reference = hidden_reference_id + else: + self.edge_chamfer_reference_face_input.setText("") + except ValueError: + self.edge_chamfer_reference_face_input.setText("") + current_chamfer_reference = default_chamfer_reference + add_spec( + key="new_asymmetric_chamfer_distances", + label="新增不等距倒角D1/D2", + current_raw="未添加", + target_text="", + action="chamfer_edge_asymmetric", + target_attrs=("edge_chamfer_distance1_input", "edge_chamfer_distance2_input"), + enabled=bool(len(adjacent_face_ids) >= 2), + enabled_tip="输入两个距离,例如 1, 2,给当前直线Edge添加两侧距离不同的倒角。", + disabled_tip="不等距倒角需要当前直线Edge至少有两个相邻Face。", + value_type="number_pair", + range_hint=f"{chamfer_edge_hint} D1/D2 会以倒角参考Face决定方向。", + max_value=edge_limit, + max_exclusive=edge_limit is not None, + positive_pair=True, + ) + add_spec( + key="new_distance_angle_chamfer", + label="新增距离+角度倒角D/角度", + current_raw="未添加", + target_text="", + action="chamfer_edge_distance_angle", + target_attrs=("edge_chamfer_angle_distance_input", "edge_chamfer_angle_degrees_input"), + enabled=bool(len(adjacent_face_ids) >= 2), + enabled_tip="输入距离和角度,例如 1, 45,给当前直线Edge添加距离+角度倒角。", + disabled_tip="距离+角度倒角需要当前直线Edge至少有两个相邻Face。", + value_type="number_pair", + range_hint=f"{chamfer_edge_hint} 角度单位是度,建议先使用 20 到 70 度。", + positive_pair=True, + ) + add_spec( + key="chamfer_reference_face_id", + label="倒角参考Face ID", + current_raw=current_chamfer_reference if current_chamfer_reference is not None else "", + target_text=str(current_chamfer_reference) if current_chamfer_reference is not None else "", + action="set_chamfer_reference_face", + target_attr="edge_chamfer_reference_face_input", + enabled=bool(adjacent_face_ids), + enabled_tip=f"设置不等距倒角参考Face;可用相邻Face:{tuple(adjacent_face_ids)}。", + disabled_tip="当前Edge没有稳定的相邻Face,不能设置不等距倒角参考Face。", + value_type="integer_or_empty", + range_hint=f"可用相邻Face:{tuple(adjacent_face_ids)}。留空则使用第一个相邻Face。", + button_text="设置", + ) if self.selected_kind in {"part", "solid"}: target_is_part = self.selected_kind == "part" translate_action = "translate_selected_part" if target_is_part else "translate_selected_solid" @@ -1206,7 +2249,141 @@ class WindowStateMixin: value_type="vector3", range_hint=translation_hint(), ) + current_center = _triple_or_none(action_info.get("center_of_mass")) + bbox_min = _triple_or_none(action_info.get("bbox_min")) + bbox_max = _triple_or_none(action_info.get("bbox_max")) + if current_center is None and bbox_min is not None and bbox_max is not None: + current_center = ( + (bbox_min[0] + bbox_max[0]) * 0.5, + (bbox_min[1] + bbox_max[1]) * 0.5, + (bbox_min[2] + bbox_max[2]) * 0.5, + ) + add_spec( + key="target_center_position", + label="目标中心坐标", + current_raw=current_center if current_center is not None else "", + target_text=vector_text(current_center), + action=translate_action, + target_attrs=("translate_x_input", "translate_y_input", "translate_z_input"), + enabled=current_center is not None, + enabled_tip="输入目标中心坐标;程序会自动换算成平移 X/Y/Z 后移动当前对象。", + disabled_tip="当前对象缺少稳定中心坐标,不能按目标中心移动。", + value_type="vector3", + range_hint="格式为 X, Y, Z。这里输入的是目标绝对坐标,不是平移距离。", + target_transform="target_center_to_translation", + transform_context={"current_center": current_center}, + used=("center_of_mass",), + ) + current_diagonal = _float_or_none(action_info.get("bbox_diagonal")) + scale_action = "scale_selected_part" if target_is_part else "scale_selected_solid" + add_spec( + key="bbox_diagonal_scale", + label="目标整体尺寸", + current_raw=current_diagonal if current_diagonal is not None else "", + target_text=numeric_text(current_diagonal), + action=scale_action, + target_attr="scale_target_diagonal_input", + enabled=current_diagonal is not None and current_diagonal > 0, + enabled_tip="输入目标包围盒对角线,按当前对象中心等比缩放零件或Solid。", + disabled_tip="当前对象缺少有效包围盒尺寸,不能等比缩放。", + value_type="positive", + range_hint=relative_range_hint(current_diagonal, 0.35, 1.0), + used=("bbox_diagonal",), + **positive_minimum(), + ) + current_volume = _float_or_none(action_info.get("volume")) + add_spec( + key="target_volume_scale", + label="目标体积", + current_raw=current_volume if current_volume is not None else "", + target_text=numeric_text(current_volume), + action=scale_action, + target_attr="scale_target_diagonal_input", + enabled=bool(current_volume is not None and current_volume > 0 and current_diagonal is not None and current_diagonal > 0), + enabled_tip="输入目标体积;程序会按体积比例换算为等比缩放后的整体尺寸。", + disabled_tip="当前对象缺少有效体积或包围盒尺寸,不能按体积缩放。", + value_type="positive", + range_hint=relative_range_hint(current_volume, 0.5, 2.0), + target_transform="volume_to_bbox_diagonal", + transform_context={ + "current_volume": current_volume, + "current_bbox_diagonal": current_diagonal, + }, + used=("volume",), + **positive_minimum(), + ) + current_surface_area = _float_or_none(action_info.get("surface_area")) + add_spec( + key="target_surface_area_scale", + label="目标表面积", + current_raw=current_surface_area if current_surface_area is not None else "", + target_text=numeric_text(current_surface_area), + action=scale_action, + target_attr="scale_target_diagonal_input", + enabled=bool( + current_surface_area is not None + and current_surface_area > 0 + and current_diagonal is not None + and current_diagonal > 0 + ), + enabled_tip="输入目标表面积;程序会按面积比例换算为等比缩放后的整体尺寸。", + disabled_tip="当前对象缺少有效表面积或包围盒尺寸,不能按表面积缩放。", + value_type="positive", + range_hint=relative_range_hint(current_surface_area, 0.5, 2.0), + target_transform="surface_area_to_bbox_diagonal", + transform_context={ + "current_surface_area": current_surface_area, + "current_bbox_diagonal": current_diagonal, + }, + used=("surface_area",), + **positive_minimum(), + ) + bbox_size = _triple_or_none(action_info.get("bbox_size")) + axis_scale_action_prefix = "scale_selected_part" if target_is_part else "scale_selected_solid" + if bbox_size is not None: + for axis_name, axis_index, target_attr in ( + ("X", 0, "scale_x_size_input"), + ("Y", 1, "scale_y_size_input"), + ("Z", 2, "scale_z_size_input"), + ): + current_axis_size = bbox_size[axis_index] + add_spec( + key=f"bbox_{axis_name.lower()}_size_scale", + label=f"目标{axis_name}向尺寸", + current_raw=current_axis_size, + target_text=numeric_text(current_axis_size), + action=f"{axis_scale_action_prefix}_{axis_name.lower()}_size", + target_attr=target_attr, + enabled=current_axis_size > 0, + enabled_tip=f"输入目标 {axis_name} 向包围盒尺寸,只沿 {axis_name} 轴缩放当前对象。", + disabled_tip=f"当前对象缺少有效 {axis_name} 向包围盒尺寸。", + value_type="positive", + range_hint=relative_range_hint(current_axis_size, 0.25, 1.0), + **positive_minimum(), + ) axis = self.rotate_axis_combo.currentText() if hasattr(self, "rotate_axis_combo") else "Z" + add_spec( + key="rotation_axis", + label="旋转轴", + current_raw=axis, + target_text=axis, + action="set_rotate_axis", + target_attr="rotate_axis_combo", + enabled=True, + enabled_tip="设置零件或 Solid 旋转时使用的轴:X、Y 或 Z。", + disabled_tip="请先选择零件或 Solid。", + value_type="choice", + range_hint="可输入:X、Y、Z。", + button_text="设置", + choices={ + "X": "X", + "x": "X", + "Y": "Y", + "y": "Y", + "Z": "Z", + "z": "Z", + }, + ) add_spec( key="rotation_angle_degrees", label=f"旋转角度({axis}轴)", @@ -1236,7 +2413,29 @@ class WindowStateMixin: for row, spec in enumerate(getattr(self, "property_editor_specs", [])): widget = self.property_table.cellWidget(row, 3) if isinstance(widget, QPushButton): - widget.setEnabled(bool(has_model and spec.get("enabled") and spec.get("action"))) + if str(spec.get("value_type", "number")) == "command": + row_changed = True + widget.setText(str(spec.get("button_text") or "执行")) + enabled = bool(has_model and spec.get("enabled") and spec.get("action")) + tooltip = f"执行“{spec.get('label', '当前操作')}”。" + else: + text = self._property_target_text(row) + row_changed = self._property_target_changed(spec, text) + widget.setText("应用" if row_changed else "未改动") + enabled = bool(has_model and spec.get("enabled") and spec.get("action") and row_changed) + if row_changed: + tooltip = f"应用“{spec.get('label', '当前属性')}”这一行的目标值。" + else: + tooltip = f"“{spec.get('label', '当前属性')}”的目标值和当前值相同,无需执行。修改目标值后再应用。" + range_hint = self._property_range_hint(spec) + if range_hint: + tooltip = f"{tooltip}\n\n{range_hint}" + widget.setProperty("changed", bool(row_changed)) + widget.setToolTip(tooltip) + widget.setCursor(Qt.CursorShape.PointingHandCursor if enabled else Qt.CursorShape.ArrowCursor) + widget.style().unpolish(widget) + widget.style().polish(widget) + widget.setEnabled(enabled) if not hasattr(self, "apply_property_button"): return changed = self._changed_property_rows() @@ -1258,24 +2457,124 @@ class WindowStateMixin: for row, spec in enumerate(getattr(self, "property_editor_specs", [])): if not spec.get("action") or not spec.get("enabled"): continue - item = self.property_table.item(row, 2) - if item is None: + if str(spec.get("value_type", "number")) == "command": continue - text = item.text().strip() + if spec.get("action") in { + "set_manual_hole_bottom_face", + "set_manual_slot_pair_face", + "set_edge_length_anchor_mode", + "set_chamfer_reference_face", + "set_rotate_axis", + }: + continue + text = self._property_target_text(row) if self._property_target_changed(spec, text): changed.append((row, spec, text)) return changed + def _property_target_text(self, row: int) -> str: + if not hasattr(self, "property_table"): + return "" + widget = self.property_table.cellWidget(row, 2) + if isinstance(widget, QLineEdit): + return widget.text().strip() + item = self.property_table.item(row, 2) + return item.text().strip() if item is not None else "" + + def _display_property_target_changed( + self, + spec: dict[str, object], + text: str, + value_type: str, + ) -> bool | None: + current_text = str(spec.get("current_text", "")).strip() + if not current_text: + return None + if value_type == "vector3": + try: + values = self._parse_property_vector3(text) + current = self._parse_property_vector3(current_text) + except ValueError: + return None + return any(abs(values[index] - current[index]) > PROPERTY_VALUE_TOLERANCE for index in range(3)) + if value_type == "number_pair": + try: + values = self._parse_property_number_pair(text) + current = self._parse_property_number_pair(current_text) + except ValueError: + return None + return any(abs(values[index] - current[index]) > PROPERTY_VALUE_TOLERANCE for index in range(2)) + if value_type in {"integer", "integer_or_empty"}: + try: + return int(text) != int(current_text) + except ValueError: + return None + if value_type in {"number", "positive"}: + value = _float_or_none(text) + current = _float_or_none(current_text) + if value is None or current is None: + return None + return abs(value - current) > PROPERTY_VALUE_TOLERANCE + return None + def _property_target_changed(self, spec: dict[str, object], text: str) -> bool: - if not text: - return False value_type = str(spec.get("value_type", "number")) + if not text: + if value_type == "integer_or_empty": + return str(spec.get("current_raw", "")).strip() != "" + return False + display_changed = self._display_property_target_changed(spec, text, value_type) + if display_changed is not None: + return display_changed if value_type == "vector3": try: values = self._parse_property_vector3(text) except ValueError: return True + if str(spec.get("target_transform") or "") == "target_center_to_translation": + current = _triple_or_none(spec.get("current_raw")) + if current is not None: + return any(abs(values[index] - current[index]) > PROPERTY_VALUE_TOLERANCE for index in range(3)) + current = _triple_or_none(spec.get("current_raw")) + if current is not None: + return any(abs(values[index] - current[index]) > PROPERTY_VALUE_TOLERANCE for index in range(3)) return any(abs(value) > PROPERTY_VALUE_TOLERANCE for value in values) + if value_type == "choice": + try: + value = self._property_choice_value(spec, text) + except ValueError: + return True + return str(value) != str(spec.get("current_raw", "")) + if value_type == "number_pair": + try: + values = self._parse_property_number_pair(text) + except ValueError: + return True + if spec.get("positive_pair") and any(value <= 0 for value in values): + return True + if any(self._property_scalar_range_error(spec, value) for value in values): + return True + current = spec.get("current_raw") + if isinstance(current, (tuple, list)) and len(current) == 2: + try: + current_pair = (float(current[0]), float(current[1])) + except (TypeError, ValueError): + return True + return any(abs(values[index] - current_pair[index]) > PROPERTY_VALUE_TOLERANCE for index in range(2)) + return True + if value_type in {"integer", "integer_or_empty"}: + try: + value = int(text) + except ValueError: + return True + if self._property_scalar_range_error(spec, float(value)): + return True + current_text = str(spec.get("current_raw", "")).strip() + try: + current = int(current_text) + except ValueError: + return True + return value != current try: value = float(text) except ValueError: @@ -1309,13 +2608,14 @@ class WindowStateMixin: if not spec.get("action") or not spec.get("enabled"): self.statusBar().showMessage("这一行当前不能直接修改。") return - item = self.property_table.item(row, 2) if hasattr(self, "property_table") else None - text = item.text().strip() if item is not None else "" - if not self._property_target_changed(spec, text): + text = self._property_target_text(row) + is_command = str(spec.get("value_type", "number")) == "command" + if not is_command and not self._property_target_changed(spec, text): self.statusBar().showMessage("请先在这一行的目标值列输入一个不同的新值。") return try: - self._sync_property_edit_target(spec, text) + if not is_command: + self._sync_property_edit_target(spec, text) except ValueError as exc: QMessageBox.information(self, "目标值无效", str(exc)) return @@ -1326,16 +2626,123 @@ class WindowStateMixin: return action() + def set_manual_hole_bottom_face(self) -> None: + self._update_action_states() + self._refresh_property_editor() + text = self.hole_bottom_face_input.text().strip() if hasattr(self, "hole_bottom_face_input") else "" + if text: + self.statusBar().showMessage(f"已设置盲孔/盲槽底面 Face ID:{text},现在可以继续修改深度。") + else: + self.statusBar().showMessage("已清除手动底面 Face ID,将使用自动识别结果。") + + def set_manual_slot_pair_face(self) -> None: + self._update_action_states() + self._refresh_property_editor() + text = self.slot_pair_face_input.text().strip() if hasattr(self, "slot_pair_face_input") else "" + if text: + self.statusBar().showMessage(f"已设置槽孔配对端 Face ID:{text},后续槽孔修改会优先使用这个配对端。") + else: + self.statusBar().showMessage("已清除手动槽孔配对端 Face ID,将使用自动识别结果。") + + def set_edge_length_anchor_mode(self) -> None: + self._update_action_states() + self._refresh_property_editor() + label = self.edge_length_anchor_combo.currentText() if hasattr(self, "edge_length_anchor_combo") else "" + self.statusBar().showMessage(f"已设置Edge长度基准:{label or '自动'}。") + + def set_chamfer_reference_face(self) -> None: + self._update_action_states() + self._refresh_property_editor() + text = ( + self.edge_chamfer_reference_face_input.text().strip() + if hasattr(self, "edge_chamfer_reference_face_input") + else "" + ) + if text: + self.statusBar().showMessage(f"已设置不等距倒角参考 Face ID:{text}。") + else: + self.statusBar().showMessage("已清除不等距倒角参考 Face ID,将使用当前Edge的第一个相邻Face。") + + def set_rotate_axis(self) -> None: + self._update_action_states() + self._refresh_property_editor() + axis = self.rotate_axis_combo.currentText() if hasattr(self, "rotate_axis_combo") else "Z" + self.statusBar().showMessage(f"已设置旋转轴:{axis}。") + def _sync_property_edit_target(self, spec: dict[str, object], text: str) -> None: value_type = str(spec.get("value_type", "number")) + if value_type == "command": + return if value_type == "vector3": values = self._parse_property_vector3(text) + values = self._transform_property_vector3_target(spec, values) target_attrs = tuple(spec.get("target_attrs") or ()) if len(target_attrs) != 3: raise ValueError("这个属性缺少 X/Y/Z 输入绑定。") for attr, value in zip(target_attrs, values): getattr(self, str(attr)).setText(_format_float(value)) return + if value_type == "choice": + value = self._property_choice_value(spec, text) + target_attr = spec.get("target_attr") + if not target_attr: + raise ValueError("这个属性缺少目标输入绑定。") + widget = getattr(self, str(target_attr)) + index = widget.findData(value) if hasattr(widget, "findData") else -1 + if index < 0 and hasattr(widget, "findText"): + index = widget.findText(str(text)) + if index < 0: + raise ValueError(f"找不到可用选项:{text}") + widget.setCurrentIndex(index) + return + if value_type == "number_pair": + values = self._parse_property_number_pair(text) + if spec.get("positive_pair") and any(value <= 0 for value in values): + raise ValueError("请输入两个大于 0 的目标值。") + for value in values: + range_error = self._property_scalar_range_error(spec, value) + if range_error: + raise ValueError(range_error) + target_attrs = tuple(spec.get("target_attrs") or ()) + if len(target_attrs) != 2: + raise ValueError("这个属性缺少两个目标输入绑定。") + for attr, value in zip(target_attrs, values): + getattr(self, str(attr)).setText(_format_float(value)) + return + if value_type in {"integer", "integer_or_empty"}: + target_attr = spec.get("target_attr") + if not target_attr: + raise ValueError("这个属性缺少目标输入绑定。") + if not text and value_type == "integer_or_empty": + getattr(self, str(target_attr)).setText("") + return + try: + value = int(text) + except ValueError as exc: + raise ValueError("请输入整数形式的 Face ID。") from exc + range_error = self._property_scalar_range_error(spec, float(value)) + if range_error: + raise ValueError(range_error) + key = str(spec.get("key", "")) + if self.model is not None and key in {"hole_bottom_face_id", "slot_pair_face_id", "chamfer_reference_face_id"}: + if value < 0 or value >= len(self.model.faces): + label = { + "hole_bottom_face_id": "底面", + "slot_pair_face_id": "槽孔配对端", + "chamfer_reference_face_id": "倒角参考", + }.get(key, "Face") + raise ValueError(f"{label} Face ID {value} 不存在。") + if key == "slot_pair_face_id" and self.selected_face_id is not None and value == self.selected_face_id: + raise ValueError("槽孔配对端不能和当前槽端使用同一个 Face ID。") + if key == "chamfer_reference_face_id" and self.selected_edge_id is not None: + try: + adjacent_face_ids = _int_values(self.model.edge_info(self.selected_edge_id).get("adjacent_face_ids")) + except Exception: + adjacent_face_ids = [] + if adjacent_face_ids and value not in adjacent_face_ids: + raise ValueError(f"倒角参考 Face ID 必须是当前 Edge 的相邻 Face,可用值:{tuple(adjacent_face_ids)}。") + getattr(self, str(target_attr)).setText(str(value)) + return try: value = float(text) except ValueError as exc: @@ -1345,11 +2752,140 @@ class WindowStateMixin: range_error = self._property_scalar_range_error(spec, value) if range_error: raise ValueError(range_error) + value = self._transform_property_scalar_target(spec, value) target_attr = spec.get("target_attr") if not target_attr: raise ValueError("这个属性缺少目标输入绑定。") getattr(self, str(target_attr)).setText(_format_float(value)) + def _transform_property_scalar_target(self, spec: dict[str, object], value: float) -> float: + transform = str(spec.get("target_transform") or "") + if not transform: + return value + context = spec.get("transform_context") + if not isinstance(context, dict): + context = {} + if transform == "radius_to_diameter": + if value <= 0: + raise ValueError("目标半径必须大于 0。") + return value * 2.0 + if transform == "diameter_to_radius": + if value <= 0: + raise ValueError("目标直径必须大于 0。") + return value * 0.5 + if transform == "plane_target_position_to_offset": + current_position = _float_or_none(context.get("current_plane_position")) + if current_position is None: + raise ValueError("当前平面缺少稳定法向位置,不能换算推拉距离。") + return value - current_position + if transform == "degrees_to_radians": + if value <= 0: + raise ValueError("目标角度必须大于 0。") + return math.radians(value) + if transform == "slot_open_angle_degrees_to_angular_span": + if value <= 0 or value >= 360.0: + raise ValueError("槽/半孔开口角度必须大于 0 且小于 360 度。") + target_span = math.tau - math.radians(value) + if target_span <= 1e-6 or target_span >= math.tau * 0.92: + raise ValueError("换算后的槽/半孔圆弧角度必须大于 0 且小于接近完整圆柱的范围。") + return target_span + if transform == "cone_semi_angle_degrees_to_reference_radius": + current_radius = _float_or_none(context.get("current_reference_radius")) + current_angle = _float_or_none(context.get("current_semi_angle")) + if current_radius is None or current_radius <= 0 or current_angle is None: + raise ValueError("当前圆锥面缺少稳定参考半径或半角,不能换算目标半角。") + if value <= 0 or value >= 89.0: + raise ValueError("圆锥目标半角必须大于 0 且小于 89 度。") + current_tangent = abs(math.tan(current_angle)) + target_tangent = math.tan(math.radians(value)) + if current_tangent <= 1e-9 or target_tangent <= 1e-9: + raise ValueError("圆锥半角过小,不能稳定换算参考半径。") + return current_radius * target_tangent / current_tangent + if transform == "arc_length_to_radius": + angular_span = _float_or_none(context.get("angular_span")) + if angular_span is None or angular_span <= 1e-6: + raise ValueError("当前对象缺少稳定圆弧角度,不能把圆弧长度换算为半径。") + return value / angular_span + if transform == "slot_center_distance_to_total_length": + diameter = _float_or_none(context.get("slot_current_diameter")) + if diameter is None or diameter <= 0: + raise ValueError("当前槽孔缺少稳定宽度/直径,不能把中心距换算为总长度。") + if value <= 0: + raise ValueError("槽孔中心距必须大于 0。") + return value + diameter + if transform == "volume_to_bbox_diagonal": + current_volume = _float_or_none(context.get("current_volume")) + current_diagonal = _float_or_none(context.get("current_bbox_diagonal")) + if current_volume is None or current_volume <= 0 or current_diagonal is None or current_diagonal <= 0: + raise ValueError("当前对象缺少稳定体积或整体尺寸,不能换算目标体积。") + if value <= 0: + raise ValueError("目标体积必须大于 0。") + return current_diagonal * ((value / current_volume) ** (1.0 / 3.0)) + if transform == "surface_area_to_bbox_diagonal": + current_surface_area = _float_or_none(context.get("current_surface_area")) + current_diagonal = _float_or_none(context.get("current_bbox_diagonal")) + if current_surface_area is None or current_surface_area <= 0 or current_diagonal is None or current_diagonal <= 0: + raise ValueError("当前对象缺少稳定表面积或整体尺寸,不能换算目标表面积。") + if value <= 0: + raise ValueError("目标表面积必须大于 0。") + return current_diagonal * math.sqrt(value / current_surface_area) + if transform in {"circle_edge_radius_to_length", "circle_edge_diameter_to_length"}: + current_length = _float_or_none(context.get("edge_current_length")) + current_radius = _float_or_none(context.get("edge_current_radius")) + if current_length is None or current_length <= 0 or current_radius is None or current_radius <= 0: + raise ValueError("当前圆Edge缺少稳定的长度或半径,不能换算目标长度。") + target_radius = value * 0.5 if transform == "circle_edge_diameter_to_length" else value + if target_radius <= 0: + raise ValueError("圆Edge目标半径必须大于 0。") + return current_length * target_radius / current_radius + if transform in {"ellipse_edge_major_radius_to_length", "ellipse_edge_minor_radius_to_length"}: + current_length = _float_or_none(context.get("edge_current_length")) + radius_key = "edge_current_major_radius" if transform == "ellipse_edge_major_radius_to_length" else "edge_current_minor_radius" + current_radius = _float_or_none(context.get(radius_key)) + if current_length is None or current_length <= 0 or current_radius is None or current_radius <= 0: + raise ValueError("当前椭圆Edge缺少稳定的长度或半径,不能换算目标长度。") + if value <= 0: + raise ValueError("椭圆Edge目标半径必须大于 0。") + return current_length * value / current_radius + raise ValueError(f"这个属性使用了未知的目标换算方式:{transform}") + + def _transform_property_vector3_target( + self, + spec: dict[str, object], + values: tuple[float, float, float], + ) -> tuple[float, float, float]: + transform = str(spec.get("target_transform") or "") + if not transform: + return values + context = spec.get("transform_context") + if not isinstance(context, dict): + context = {} + if transform == "target_center_to_translation": + current_center = _triple_or_none(context.get("current_center")) or _triple_or_none(spec.get("current_raw")) + if current_center is None: + raise ValueError("当前对象缺少稳定中心坐标,不能换算平移量。") + return ( + values[0] - current_center[0], + values[1] - current_center[1], + values[2] - current_center[2], + ) + raise ValueError(f"这个属性使用了未知的向量换算方式:{transform}") + + def _property_choice_value(self, spec: dict[str, object], text: str) -> object: + choices = spec.get("choices") + if not isinstance(choices, dict) or not choices: + raise ValueError("这个属性缺少可用选项。") + normalized = str(text).strip() + if not normalized: + raise ValueError("请输入一个选项。") + if normalized in choices: + return choices[normalized] + lowered = normalized.lower() + for label, value in choices.items(): + if str(label).strip().lower() == lowered: + return value + raise ValueError(f"不支持的选项:{text}") + def _property_scalar_range_error(self, spec: dict[str, object], value: float) -> str: label = str(spec.get("label", "目标值")) min_value = _float_or_none(spec.get("min_value")) @@ -1366,7 +2902,19 @@ class WindowStateMixin: return f"{label} 必须{operator} {_format_float(max_value)}。" return "" + def _parse_property_number_pair(self, text: str) -> tuple[float, float]: + text = text.strip().strip("()[]") + chunks = text.replace(";", ",").replace(";", ",").replace(",", ",").replace(" ", ",").split(",") + values = [chunk for chunk in chunks if chunk.strip()] + if len(values) != 2: + raise ValueError("请输入两个数字,例如 1, 2。") + try: + return (float(values[0]), float(values[1])) + except ValueError as exc: + raise ValueError("这两个目标值都必须是数字。") from exc + def _parse_property_vector3(self, text: str) -> tuple[float, float, float]: + text = text.strip().strip("()[]") chunks = text.replace(",", ",").replace(";", ",").replace(";", ",").replace(" ", ",").split(",") values = [chunk for chunk in chunks if chunk.strip()] if len(values) != 3: @@ -1379,6 +2927,16 @@ class WindowStateMixin: def _selected_action_info(self) -> dict[str, object]: if self.model is None: return {} + current_info = dict(getattr(self, "current_info_values", {}) or {}) + current_face_id = _int_or_none( + current_info.get("feature_source_face_id", current_info.get("topological_face_id", current_info.get("face_id"))) + ) + if ( + self.selected_face_id is not None + and current_face_id == self.selected_face_id + and str(current_info.get("surface", "") or "") + ): + return current_info try: if self.selected_kind == "feature" and self.selected_face_id is not None: return self.model.feature_info(self.selected_face_id) @@ -1498,11 +3056,11 @@ class WindowStateMixin: if record.pick_position is not None: self._show_pick_marker(record.pick_position) if not locator_note: - locator_note = "定位: 已显示当时记录的拾取点。" + locator_note = "定位: 已记录当时的拾取点。" elif located: locator_note += f"\n拾取点: {_format_value(record.pick_position)}" else: - locator_note += f"\n已显示当时记录的拾取点: {_format_value(record.pick_position)}" + locator_note += f"\n已记录当时拾取点: {_format_value(record.pick_position)}" if not locator_note: locator_note = "定位: 这条历史记录没有可定位的目标或拾取点。" @@ -1513,17 +3071,221 @@ class WindowStateMixin: def _resolve_record_face_id(self, record: OperationRecord) -> int | None: if self.model is None: return None - if record.pick_position is not None: - nearest = self.model.nearest_face_id_to_point(self._record_candidate_face_ids(record), record.pick_position) - if nearest is not None: - return nearest - lookup_id = record.target_logical_id if record.target_logical_id is not None else record.target_id - if lookup_id is None: + verified_face_id = self._record_verified_face_id(record) + if verified_face_id is not None: + return verified_face_id + + moved_face_id = self._record_moved_face_id(record) + if moved_face_id is not None: + return moved_face_id + + plane_position_face_id = self._record_plane_position_face_id(record) + if plane_position_face_id is not None: + return plane_position_face_id + + if not self._record_face_count_changed(record): + stable_face_id = self._record_stable_face_id(record) + if stable_face_id is not None: + return stable_face_id + + nearest_face_id = self._record_nearest_semantic_face_id(record) + if nearest_face_id is not None: + return nearest_face_id + + stable_face_id = self._record_stable_face_id(record) + if stable_face_id is not None: + return stable_face_id + + return None + + def _record_verified_face_id(self, record: OperationRecord) -> int | None: + if self.model is None: + return None + value = _record_message_field(record.result_message, "verified_face") + if value is None: return None try: - return self.model.resolve_face_selection_id(int(lookup_id)) - except Exception: + face_id = int(value) + except (TypeError, ValueError): return None + if not (0 <= face_id < len(self.model.faces)): + return None + return face_id if self._record_face_semantic_matches(record, face_id) else None + + def _record_moved_face_id(self, record: OperationRecord) -> int | None: + if self.model is None or record.pick_position is None: + return None + moved_point = self._record_moved_pick_position(record) + if moved_point is None: + return None + candidates = self._record_candidate_face_ids(record) + if not candidates: + return None + nearest = self.model.nearest_face_id_to_point(candidates, moved_point) + if nearest is None: + return None + return nearest if self._record_face_semantic_matches(record, nearest) else None + + def _record_moved_pick_position(self, record: OperationRecord) -> tuple[float, float, float] | None: + parameters = record.parameters if isinstance(record.parameters, dict) else {} + distance = ( + self._record_float_parameter(record, "semantic_distance") + if parameters.get("semantic_distance") not in {"", None} + else self._record_float_parameter(record, "push_pull_distance") + ) + direction = ( + _triple_or_none(parameters.get("outward_direction")) + or _triple_or_none(parameters.get("push_pull_outward_direction")) + or _triple_or_none(parameters.get("shell_desired_movement_vector")) + ) + if distance is None or direction is None: + return None + length = math.sqrt(direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2]) + if length <= 1e-12: + return None + unit = (direction[0] / length, direction[1] / length, direction[2] / length) + px, py, pz = record.pick_position + return ( + float(px) + unit[0] * float(distance), + float(py) + unit[1] * float(distance), + float(pz) + unit[2] * float(distance), + ) + + def _record_nearest_semantic_face_id(self, record: OperationRecord) -> int | None: + if self.model is None or record.pick_position is None: + return None + candidates = self._record_candidate_face_ids(record) + if not candidates: + return None + nearest = self.model.nearest_face_id_to_point(candidates, record.pick_position) + if nearest is None: + return None + return nearest if self._record_face_semantic_matches(record, nearest) else None + + def _record_stable_face_id(self, record: OperationRecord) -> int | None: + if self.model is None: + return None + if record.target_logical_id is not None: + try: + logical_matches = self.model.face_ids_for_logical_id(int(record.target_logical_id)) + except Exception: + logical_matches = [] + logical_matches = [ + face_id for face_id in logical_matches if self._record_face_semantic_matches(record, face_id) + ] + if logical_matches: + return self.model.nearest_face_id_to_point(logical_matches, record.pick_position) + + if record.target_id is None: + return None + try: + face_id = int(record.target_id) + except (TypeError, ValueError): + return None + if 0 <= face_id < len(self.model.faces) and self._record_face_semantic_matches(record, face_id): + return face_id + return None + + def _record_face_count_changed(self, record: OperationRecord) -> bool: + before = record.before_snapshot if isinstance(record.before_snapshot, dict) else {} + after = record.after_snapshot if isinstance(record.after_snapshot, dict) else {} + before_ids = before.get(SNAPSHOT_FACE_LOGICAL_IDS_KEY) + after_ids = after.get(SNAPSHOT_FACE_LOGICAL_IDS_KEY) + if isinstance(before_ids, (list, tuple)) and isinstance(after_ids, (list, tuple)): + return len(before_ids) != len(after_ids) + return True + + def _record_face_semantic_matches(self, record: OperationRecord, face_id: int) -> bool: + if self.model is None or not (0 <= int(face_id) < len(self.model.faces)): + return False + part_id = self._record_int_parameter(record, "part_id") + if part_id is not None and self.model.face_part_ids[int(face_id)] != part_id: + return False + surface_hint = self._record_surface_hint(record) + surface_kind = self.model.face_surface_kind(int(face_id)) + if surface_hint and surface_kind != surface_hint: + return False + target_diameter = self._record_target_diameter(record) + if target_diameter is not None: + current_diameter = self.model.face_cylinder_diameter(int(face_id)) + if current_diameter is None: + return False + diameter_tolerance = max(abs(target_diameter) * 0.02, 1e-5) + if abs(current_diameter - target_diameter) > diameter_tolerance: + return False + return True + + def _record_surface_hint(self, record: OperationRecord) -> str: + parameters = record.parameters if isinstance(record.parameters, dict) else {} + surface = str(parameters.get("surface", "") or "") + if surface in { + "plane", + "cylinder", + "cone", + "sphere", + "torus", + "bezier surface", + "b-spline surface", + "surface of revolution", + "surface of extrusion", + "offset surface", + "other surface", + }: + return surface + operation_name = str(record.operation_name or "") + if any(token in operation_name for token in ("孔", "圆柱", "槽", "凸台", "圆角", "倒圆")): + return "cylinder" + if any(token in operation_name for token in ("推拉", "薄壁", "壳体")): + return "plane" + if self._record_target_diameter(record) is not None: + return "cylinder" + return "" + + def _record_plane_position_face_id(self, record: OperationRecord) -> int | None: + if self.model is None: + return None + target_position = self._record_float_parameter(record, "target_plane_position") + direction = _triple_or_none((record.parameters or {}).get("outward_direction")) + if target_position is None or direction is None: + return None + direction_length = math.sqrt(direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2]) + if direction_length <= 1e-12: + return None + unit = (direction[0] / direction_length, direction[1] / direction_length, direction[2] / direction_length) + tolerance = max((self._record_float_parameter(record, "bbox_diagonal") or 1.0) * 1e-5, 1e-4) + best_face_id: int | None = None + best_error = math.inf + for face_id in self._record_candidate_face_ids(record): + if not (0 <= int(face_id) < len(self.model.faces)): + continue + try: + info = self.model.face_info(int(face_id)) + except Exception: + continue + if info.get("surface") != "plane": + continue + normal = _triple_or_none(info.get("normal")) or _triple_or_none(info.get("oriented_normal")) + if normal is not None and abs(normal[0] * unit[0] + normal[1] * unit[1] + normal[2] * unit[2]) < 0.85: + continue + origin = _triple_or_none(info.get("plane_origin")) + if origin is None: + continue + position = origin[0] * unit[0] + origin[1] * unit[1] + origin[2] * unit[2] + error = abs(position - target_position) + if error < best_error: + best_error = error + best_face_id = int(face_id) + if best_face_id is not None and best_error <= tolerance: + return best_face_id + return None + + def _record_target_diameter(self, record: OperationRecord) -> float | None: + return ( + self._record_float_parameter(record, "new_diameter") + or self._record_float_parameter(record, "derived_new_diameter") + or self._record_float_parameter(record, "target_diameter") + or self._record_float_parameter(record, "diameter") + ) def _resolve_record_edge_id(self, record: OperationRecord) -> int | None: if self.model is None: @@ -1549,27 +3311,31 @@ class WindowStateMixin: if part_id is not None: face_ids = [face_id for face_id in face_ids if self.model.face_part_ids[face_id] == part_id] if solid_id is not None and solid_id >= 0: - face_ids = [face_id for face_id in face_ids if self.model.face_solid_ids[face_id] == solid_id] - target_diameter = ( - self._record_float_parameter(record, "new_diameter") - or self._record_float_parameter(record, "derived_new_diameter") - or self._record_float_parameter(record, "target_diameter") - ) + same_solid = [face_id for face_id in face_ids if self.model.face_solid_ids[face_id] == solid_id] + if same_solid: + face_ids = same_solid + surface_hint = self._record_surface_hint(record) + if surface_hint: + surface_face_ids: list[int] = [] + for face_id in face_ids: + if self.model.face_surface_kind(face_id) == surface_hint: + surface_face_ids.append(face_id) + if not surface_face_ids: + return [] + face_ids = surface_face_ids + target_diameter = self._record_target_diameter(record) if target_diameter is not None and target_diameter > 0: diameter_tolerance = max(target_diameter * 0.02, 1e-5) cylindrical_face_ids: list[int] = [] for face_id in face_ids: - try: - info = self.model.face_info(face_id) - except Exception: - continue - current_diameter = _float_or_none(info.get("diameter")) - if info.get("surface") == "cylinder" and current_diameter is not None: + current_diameter = self.model.face_cylinder_diameter(face_id) + if current_diameter is not None: if abs(current_diameter - target_diameter) <= diameter_tolerance: cylindrical_face_ids.append(face_id) - if cylindrical_face_ids: - face_ids = cylindrical_face_ids - return face_ids or list(range(len(self.model.faces))) + if not cylindrical_face_ids: + return [] + face_ids = cylindrical_face_ids + return face_ids def _record_candidate_edge_ids(self, record: OperationRecord) -> list[int]: if self.model is None: