from __future__ import annotations import math from pathlib import Path from typing import Callable, Iterable from OCC.Core.BRep import BRep_Tool from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse from OCC.Core.BRepBndLib import brepbndlib from OCC.Core.BOPAlgo import BOPAlgo_GlueFull from OCC.Core.BRepBuilderAPI import ( BRepBuilderAPI_GTransform, BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeSolid, BRepBuilderAPI_Sewing, BRepBuilderAPI_Transform, ) from OCC.Core.BRepCheck import BRepCheck_Analyzer from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet from OCC.Core.BRepGProp import brepgprop from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism from OCC.Core.Bnd import Bnd_Box from OCC.Core.GeomAbs import ( GeomAbs_BSplineCurve, GeomAbs_BSplineSurface, GeomAbs_BezierCurve, GeomAbs_BezierSurface, GeomAbs_Circle, GeomAbs_Cone, GeomAbs_Cylinder, GeomAbs_Ellipse, GeomAbs_Hyperbola, GeomAbs_Line, GeomAbs_OffsetSurface, GeomAbs_OtherCurve, GeomAbs_OtherSurface, GeomAbs_Parabola, GeomAbs_Plane, GeomAbs_Sphere, GeomAbs_SurfaceOfExtrusion, GeomAbs_SurfaceOfRevolution, GeomAbs_Torus, ) from OCC.Core.GProp import GProp_GProps from OCC.Core.ShapeFix import ShapeFix_Shape from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain from OCC.Core.TopAbs import ( TopAbs_EDGE, TopAbs_EXTERNAL, TopAbs_FACE, TopAbs_FORWARD, TopAbs_IN, TopAbs_INTERNAL, TopAbs_OUT, TopAbs_REVERSED, TopAbs_SHELL, TopAbs_SOLID, TopAbs_VERTEX, TopAbs_WIRE, ) from OCC.Core.TopExp import TopExp_Explorer, topexp from OCC.Core.TopLoc import TopLoc_Location from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_IndexedMapOfShape from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_GTrsf, gp_Pnt, gp_Trsf, gp_Vec, gp_XYZ from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES from .geometry_utils import * # noqa: F403 from .step_io import _prepare_shape_for_step_export class OperationMixin: def repair_model(self) -> str: before_stats = self.stats() repaired_parts = 0 skipped_parts = 0 for part in self.display_parts(): if part.shape.IsNull(): skipped_parts += 1 continue repaired = _prepare_shape_for_step_export(part.shape) if repaired.IsNull(): skipped_parts += 1 continue part.shape = repaired repaired_parts += 1 if repaired_parts == 0: raise RuntimeError("No valid part shape was available for repair.") self.refresh_topology() after_stats = self.stats() return ( "Model repair completed: " f"parts repaired={repaired_parts}, skipped={skipped_parts}, " f"solids {before_stats.solids}->{after_stats.solids}, " f"faces {before_stats.faces}->{after_stats.faces}, " f"edges {before_stats.edges}->{after_stats.edges}." ) def repair_part(self, part_id: int) -> str: part = self.part_by_id(part_id) if part is None: raise ValueError(f"未知零件 ID {part_id}") if part.shape.IsNull(): raise RuntimeError(f"零件 {part_id} 为空 shape,无法修复。") before_stats = self.part_topology_stats(part_id) repaired = _prepare_shape_for_step_export(part.shape) if repaired.IsNull(): raise RuntimeError(f"零件 {part_id} 修复结果为空 shape。") part.shape = repaired self.refresh_topology() after_stats = self.part_topology_stats(part_id) return ( f"零件修复完成: 零件 {part_id}, " f"solids {before_stats.solids}->{after_stats.solids}, " f"faces {before_stats.faces}->{after_stats.faces}, " f"edges {before_stats.edges}->{after_stats.edges}." ) def repair_solid(self, solid_id: int) -> str: 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) if part is None: raise ValueError(f"Unknown part id {part_id}") before_part_stats = self.part_topology_stats(part_id) repaired = _prepare_shape_for_step_export(solid) if repaired.IsNull(): raise RuntimeError(f"Solid {solid_id} repair returned a null shape.") part_solids = _explore(part.shape, TopAbs_SOLID) if len(part_solids) <= 1: part.shape = repaired else: replaced = False shapes: list[TopoDS_Shape] = [] for item in part_solids: if not replaced and _same_shape(item, solid): shapes.append(repaired) 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() after_part_stats = self.part_topology_stats(part_id) return ( f"Solid repair completed: solid {solid_id}, part {part_id}, " f"part solids {before_part_stats.solids}->{after_part_stats.solids}, " f"faces {before_part_stats.faces}->{after_part_stats.faces}, " f"edges {before_part_stats.edges}->{after_part_stats.edges}." ) def edge_fillet_plan(self, edge_id: int, radius: 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) readiness = _edge_fillet_readiness(info, radius) part_id = int(info["part_id"]) part_stats = None try: part_stats = self.part_topology_stats(part_id) except Exception: part_stats = None if part_stats is not None and part_stats.solids != 1: readiness = dict(readiness) if readiness["fillet_status"] != "blocked": readiness["fillet_status"] = "caution" readiness["fillet_risk"] = _max_risk(str(readiness["fillet_risk"]), "high") readiness["fillet_warnings"] = _join_nonempty( readiness["fillet_warnings"], f"当前零件包含 {part_stats.solids} 个Solid,Edge倒圆会作用在整个零件 shape 上,请导出前检查结果。", ) readiness["fillet_note"] = _join_nonempty(readiness["fillet_note"], readiness["fillet_warnings"]) length = float(info.get("length", 0.0)) radius_to_length_ratio = radius / max(length, 1e-9) return { "status": readiness["fillet_status"], "risk": readiness["fillet_risk"], "message": readiness["fillet_note"], "warnings": readiness["fillet_warnings"], "blockers": readiness["fillet_blockers"], "edge_id": edge_id, "part_id": info["part_id"], "solid_id": info.get("solid_id", -1), "curve": info.get("curve"), "edge_length": length, "target_radius": radius, "radius_to_length_ratio": radius_to_length_ratio, "adjacent_face_ids": info.get("adjacent_face_ids", ()), "adjacent_face_count": info.get("adjacent_face_count", 0), "start_point": info.get("start_point"), "end_point": info.get("end_point"), "direction": info.get("direction"), } def edge_chamfer_plan(self, edge_id: int, distance: 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) readiness = _edge_chamfer_readiness(info, distance) part_id = int(info["part_id"]) part_stats = None try: part_stats = self.part_topology_stats(part_id) except Exception: part_stats = None if part_stats is not None and part_stats.solids != 1: readiness = dict(readiness) if readiness["chamfer_status"] != "blocked": readiness["chamfer_status"] = "caution" readiness["chamfer_risk"] = _max_risk(str(readiness["chamfer_risk"]), "high") readiness["chamfer_warnings"] = _join_nonempty( readiness["chamfer_warnings"], f"当前零件包含 {part_stats.solids} 个Solid,Edge倒角会作用在整个零件 shape 上,请导出前检查结果。", ) readiness["chamfer_note"] = _join_nonempty(readiness["chamfer_note"], readiness["chamfer_warnings"]) length = float(info.get("length", 0.0)) distance_to_length_ratio = distance / max(length, 1e-9) return { "status": readiness["chamfer_status"], "risk": readiness["chamfer_risk"], "message": readiness["chamfer_note"], "warnings": readiness["chamfer_warnings"], "blockers": readiness["chamfer_blockers"], "edge_id": edge_id, "part_id": info["part_id"], "solid_id": info.get("solid_id", -1), "curve": info.get("curve"), "edge_length": length, "target_distance": distance, "distance_to_length_ratio": distance_to_length_ratio, "adjacent_face_ids": info.get("adjacent_face_ids", ()), "adjacent_face_count": info.get("adjacent_face_count", 0), "start_point": info.get("start_point"), "end_point": info.get("end_point"), "direction": info.get("direction"), } def general_edge_length_plan( self, edge_id: int, target_length: float, anchor_mode: str = "auto", ) -> 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)) target_length = float(target_length) delta_length = target_length - current_length curve = str(info.get("curve", "")) anchor_mode = self._edge_length_anchor_mode(anchor_mode) base: dict[str, object] = { "edge_id": edge_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id", -1), "curve": curve, "edge_length_anchor_mode": anchor_mode, "edge_length_anchor_label": self._edge_length_anchor_label(anchor_mode), "current_length": current_length, "target_length": target_length, "delta_length": delta_length, "length_change_ratio": abs(delta_length) / max(current_length, 1e-9), "start_point": info.get("start_point"), "end_point": info.get("end_point"), "length_center": info.get("length_center"), } warnings: list[str] = [ "Edge长度修改基于当前 STEP/B-Rep 结果几何,不是 CAD 建模历史里的参数编辑。" ] blockers: list[str] = [] risk = "low" status = "ready" if current_length <= 1e-9: blockers.append("当前Edge长度无效。") if target_length <= 1e-9: blockers.append("Edge目标长度必须大于 0。") if abs(delta_length) <= max(current_length * 1e-7, 1e-7): blockers.append("Edge目标长度与当前Edge长度几乎相同,不需要修改。") if not blockers: ratio = abs(delta_length) / max(current_length, 1e-9) if ratio > 0.5: risk = _max_risk(risk, "high") warnings.append("长度变化超过当前Edge长度的 50%,形状异常或修复失败的概率较高。") elif ratio > 0.25: risk = _max_risk(risk, "medium") warnings.append("长度变化超过当前Edge长度的 25%,请确认预览范围。") if not blockers and curve == "line": local_candidate, local_skip_note = self._local_edge_length_deform_candidate( info, target_length, anchor_mode=anchor_mode, ) if local_candidate is not None: risk = _max_risk(risk, str(local_candidate.get("local_edge_deform_risk", "medium"))) status = "caution" if risk != "low" else status warnings.append( "将优先只移动当前 Edge 的端点并重建相邻平面;非共面的四边面会拆成三角面。" ) base.update(local_candidate) elif local_skip_note: warnings.append(local_skip_note) if not blockers and "resize_strategy" not in base and curve == "line" and anchor_mode != "center": candidate = self._straight_edge_length_end_face_candidate(info, delta_length, anchor_mode=anchor_mode) if candidate is not None: push_plan = self.push_pull_plan(int(candidate["end_face_id"]), float(candidate["push_pull_distance"])) if push_plan["status"] != "blocked": risk = _max_risk(risk, str(push_plan["risk"])) push_warnings = str(push_plan.get("warnings", "")) if push_warnings: warnings.append(push_warnings) base.update(candidate) base.update( { "resize_strategy": "move-edge-end-plane-by-push-pull", "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", ()), "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", ""), } ) else: warnings.append(f"端面推拉路径不可用,将尝试通用仿射缩放:{push_plan['message']}") elif anchor_mode in {"keep-start", "keep-end"}: warnings.append( f"未找到可用于{self._edge_length_anchor_label(anchor_mode)}的端面推拉路径,将尝试按该基准缩放所属对象。" ) elif not blockers and curve == "line" and anchor_mode == "center": warnings.append("Edge长度基准为固定中心;将使用轴向仿射缩放,让Edge中心尽量保持不动。") if not blockers and "resize_strategy" not in base and curve == "circle": cylinder_candidate, cylinder_notes = self._circular_edge_length_cylinder_candidate(info, target_length) if cylinder_candidate is not None: risk = _max_risk(risk, str(cylinder_candidate["cylinder_resize_risk"])) status = "caution" if risk != "low" else status warnings.append("识别到相邻圆柱面;将优先把Edge目标长度换算成圆柱直径做局部编辑。") cylinder_warnings = str(cylinder_candidate.get("cylinder_resize_warnings", "")) if cylinder_warnings: warnings.append(cylinder_warnings) base.update(cylinder_candidate) elif cylinder_notes: warnings.extend(cylinder_notes[:3]) if not blockers and "resize_strategy" not in base and curve != "line": planar_candidate, planar_note = self._planar_edge_length_scale_candidate(info, target_length) if planar_candidate is not None: risk = _max_risk(risk, str(planar_candidate.get("affine_transform_risk", "medium"))) status = "caution" warnings.append(str(planar_candidate.get("affine_transform_warning", ""))) note = str(planar_candidate.get("affine_transform_note", "")) if note: warnings.append(note) base.update(planar_candidate) elif planar_note: warnings.append(planar_note) if not blockers and "resize_strategy" not in base: axis = self._edge_length_affine_axis(info, anchor_mode=anchor_mode) if axis is None: blockers.append("无法为当前 Edge 推断可靠的缩放方向。") else: 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 part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0 target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" scale = target_length / max(current_length, 1e-9) transform_kind = "axis-affine" if curve == "line" else "uniform" transform_label = "沿Edge方向仿射缩放" if transform_kind == "axis-affine" else "以Edge中心整体缩放" risk = _max_risk(risk, "medium") if curve != "line" or abs(scale - 1.0) > 0.25: risk = _max_risk(risk, "high") status = "caution" if transform_kind == "axis-affine": warnings.append( "未找到可推拉端面;将沿该Edge的几何方向对所属 " f"{target_kind} 做仿射缩放。该 fallback 会影响同一 {target_kind} 上的其他尺寸。" ) else: warnings.append( "当前 Edge 不是直线;将以 Edge 中心为基准对所属 " f"{target_kind} 做均匀缩放。该 fallback 会影响同一 {target_kind} 上的其他尺寸。" ) if curve != "line": warnings.append("非直线Edge的目标长度通过整体比例缩放实现,执行后请复查周边尺寸。") base.update( { "resize_strategy": "scale-owning-shape-from-edge", "affine_scale": scale, "affine_transform_kind": transform_kind, "affine_transform_label": transform_label, "affine_transform_note": "", "affine_axis_point": axis["axis_point"], "affine_axis_direction": axis["axis_direction"], "affine_axis_source": axis["axis_source"], "affine_anchor_source": axis["anchor_source"], "affine_target_kind": target_kind, "part_solid_count": part_solid_count, } ) if not blockers and base.get("resize_strategy") == "scale-owning-shape-from-edge": refine_note = self._refine_affine_edge_length_scale(base) if refine_note: warnings.append(refine_note) if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers) elif risk != "low": status = "caution" message = " ".join(warnings) else: strategy = str(base.get("resize_strategy", "")) if strategy == "local-edge-only-deform": message = "可以通过局部边形变只调整这条直线Edge,并重建周边平面。" elif strategy == "move-edge-end-plane-by-push-pull": message = "可以通过端面推拉调整这条直线Edge长度。" elif strategy == "resize-adjacent-cylinder-from-circular-edge-length": message = "可以通过相邻圆柱直径编辑调整这条圆形/圆弧Edge长度。" elif strategy == "scale-owning-shape-from-edge": message = "可以通过几何缩放 fallback 尝试调整该Edge长度。" else: message = "可以尝试直接修改该Edge长度。" base.update( { "status": status, "risk": risk, "message": message, "warnings": ";".join(warnings), "blockers": ";".join(blockers), } ) return base def _local_edge_length_deform_candidate( self, edge_info: dict[str, object], target_length: float, anchor_mode: str = "auto", ) -> tuple[dict[str, object] | None, str]: if str(edge_info.get("curve", "")) != "line": return None, "" start = _tuple_or_none(edge_info.get("start_point")) end = _tuple_or_none(edge_info.get("end_point")) if start is None or end is None: return None, "局部边形变不可用:当前 Edge 缺少稳定起点或终点。" current_length = float(edge_info.get("length", 0.0)) if current_length <= 1e-9: return None, "局部边形变不可用:当前Edge长度无效。" axis = _tuple_normalized(_tuple_sub(end, start)) if axis is None: return None, "局部边形变不可用:当前 Edge 方向无效。" solid_id = int(edge_info.get("solid_id", -1)) if solid_id < 0 or solid_id >= len(self.solids): return None, "局部边形变不可用:当前 Edge 没有关联到稳定 Solid。" part_id = int(edge_info.get("part_id", -1)) part = self.part_by_id(part_id) if part is None: return None, "局部边形变不可用:找不到所属零件。" solid = self.solids[solid_id][1] solid_faces = _explore(solid, TopAbs_FACE) if not solid_faces: return None, "局部边形变不可用:所属Solid没有可重建Face。" if len(solid_faces) > 128: return None, "局部边形变暂只对较简单的平面多面体开放,复杂模型将使用后备策略。" for face in solid_faces: surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: return None, "局部边形变暂只支持全平面多面体;含曲面的模型将使用后备策略。" if len(_explore(face, TopAbs_WIRE)) != 1: return None, "局部边形变暂不处理带内孔的Face;将使用后备策略。" if len(self._local_deform_face_vertex_points(face, max(_shape_diagonal(solid) * 1e-7, 1e-6))) < 3: return None, "局部边形变不可用:部分Face顶点环无法稳定读取。" anchor_mode = self._edge_length_anchor_mode(anchor_mode) target_length = float(target_length) delta_length = target_length - current_length if anchor_mode == "keep-end": start_move = _tuple_scale(axis, -delta_length) end_move = (0.0, 0.0, 0.0) moved_label = "移动起点,固定终点" elif anchor_mode == "center": start_move = _tuple_scale(axis, -delta_length * 0.5) end_move = _tuple_scale(axis, delta_length * 0.5) moved_label = "两端各移动一半,保持中心" else: start_move = (0.0, 0.0, 0.0) end_move = _tuple_scale(axis, delta_length) moved_label = "固定起点,移动终点" part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if part_solid_count > 1 else "part" ratio = abs(delta_length) / max(current_length, 1e-9) local_risk = "high" if ratio > 0.5 else "medium" return ( { "resize_strategy": "local-edge-only-deform", "local_edge_deform_target_kind": target_kind, "local_edge_deform_face_count": len(solid_faces), "local_edge_deform_anchor": moved_label, "local_edge_deform_start_move": start_move, "local_edge_deform_end_move": end_move, "local_edge_deform_moved_endpoint_count": 2 if anchor_mode == "center" else 1, "local_edge_deform_risk": local_risk, "local_edge_deform_note": ( "只移动当前 Edge 的端点并重建所属平面多面体;非共面 Face 会被拆成三角面。" ), "part_solid_count": part_solid_count, }, "", ) def _edge_length_anchor_mode(self, anchor_mode: str | None) -> str: normalized = str(anchor_mode or "auto").strip().lower() aliases = { "自动": "auto", "auto": "auto", "center": "center", "centre": "center", "固定中心": "center", "keep-center": "center", "start": "keep-start", "起点": "keep-start", "固定起点": "keep-start", "keep-start": "keep-start", "end": "keep-end", "终点": "keep-end", "固定终点": "keep-end", "keep-end": "keep-end", } return aliases.get(normalized, "auto") def _edge_length_anchor_label(self, anchor_mode: str) -> str: return { "auto": "自动选择局部端面", "center": "固定中心", "keep-start": "固定起点", "keep-end": "固定终点", }.get(anchor_mode, "自动选择局部端面") def _circular_edge_length_cylinder_candidate( self, edge_info: dict[str, object], target_length: float, ) -> tuple[dict[str, object] | None, list[str]]: current_length = float(edge_info.get("length", 0.0)) current_radius = float(edge_info.get("radius", 0.0)) if current_length <= 1e-9 or current_radius <= 1e-9: return None, [] length_scale = float(target_length) / current_length target_radius = current_radius * length_scale target_diameter = target_radius * 2.0 if target_diameter <= 1e-9: return None, [] notes: list[str] = [] candidates: list[tuple[tuple[int, int, int, int], dict[str, object]]] = [] risk_rank = {"low": 0, "medium": 1, "high": 2, "blocked": 3} status_rank = {"ready": 0, "caution": 1, "blocked": 2} adjacent_face_ids = _int_values(edge_info.get("adjacent_face_ids")) if not adjacent_face_ids: return None, [] for face_id in adjacent_face_ids: if face_id < 0 or face_id >= len(self.faces): continue face_info = self.face_info(face_id) if face_info.get("surface") != "cylinder" or "diameter" not in face_info: continue face_radius = float(face_info.get("radius", 0.0)) if face_radius <= 1e-9: continue radius_tolerance = max(current_radius * 0.06, face_radius * 0.06, _shape_diagonal(self.faces[face_id]) * 1e-5, 1e-4) if abs(face_radius - current_radius) > radius_tolerance: continue feature_guess = str(face_info.get("feature_guess", "cylindrical face")) if feature_guess == "round/fillet candidate": notes.append(f"相邻圆柱Face {face_id} 更像已有圆角,未自动按孔/凸台直径改边长。") continue if feature_guess == "hole/groove candidate": mode_order = ("hole",) elif feature_guess == "boss/outer-round candidate": mode_order = ("boss",) else: notes.append(f"相邻圆柱Face {face_id} 尚未明确识别为孔/槽或凸台,未自动按圆柱直径改边长。") continue for mode_index, mode in enumerate(mode_order): mode_label = "圆柱凸台直径" if mode == "boss" else "圆柱孔/槽直径" try: cylinder_plan = ( self.cylindrical_boss_resize_plan(face_id, target_diameter) if mode == "boss" else self.cylindrical_resize_plan(face_id, target_diameter) ) except Exception as exc: notes.append(f"相邻圆柱Face {face_id} 的{mode_label}计划生成失败:{exc}") continue plan_status = str(cylinder_plan.get("status", "blocked")) plan_risk = str(cylinder_plan.get("risk", "blocked")) if plan_status == "blocked": notes.append(f"相邻圆柱Face {face_id} 的{mode_label}不可用:{cylinder_plan.get('message', '')}") continue candidate = { "resize_strategy": "resize-adjacent-cylinder-from-circular-edge-length", "circular_edge_current_radius": current_radius, "circular_edge_target_radius": target_radius, "circular_edge_length_scale": length_scale, "circular_edge_cylinder_face_id": face_id, "circular_edge_cylinder_mode": mode, "circular_edge_cylinder_mode_label": mode_label, "cylinder_resize_face_id": face_id, "cylinder_resize_operation": "resize_cylindrical_boss" if mode == "boss" else "resize_cylindrical_hole", "cylinder_resize_current_diameter": cylinder_plan.get("current_diameter"), "cylinder_resize_target_diameter": target_diameter, "cylinder_resize_delta_diameter": cylinder_plan.get("delta_diameter"), "cylinder_resize_delta_ratio": cylinder_plan.get("diameter_delta_ratio"), "cylinder_resize_status": plan_status, "cylinder_resize_risk": plan_risk, "cylinder_resize_message": cylinder_plan.get("message"), "cylinder_resize_warnings": cylinder_plan.get("warnings", ""), "cylinder_resize_blockers": cylinder_plan.get("blockers", ""), "cylinder_resize_feature_guess": cylinder_plan.get("feature_guess", feature_guess), "cylinder_resize_confidence": cylinder_plan.get("confidence", face_info.get("confidence", "")), "cylinder_resize_same_domain_face_ids": cylinder_plan.get("same_domain_face_ids", ()), "cylinder_resize_same_domain_face_count": cylinder_plan.get("same_domain_face_count", ""), } score = ( status_rank.get(plan_status, 9), risk_rank.get(plan_risk, 9), mode_index, face_id, ) candidates.append((score, candidate)) if not candidates: if notes: notes.insert(0, "圆边没有找到可直接复用的相邻圆柱直径编辑路径,将回退到几何缩放。") return None, notes candidates.sort(key=lambda item: item[0]) return candidates[0][1], notes def _edge_length_affine_axis( self, edge_info: dict[str, object], anchor_mode: str = "auto", ) -> dict[str, object] | None: start = _tuple_or_none(edge_info.get("start_point")) end = _tuple_or_none(edge_info.get("end_point")) center = _tuple_or_none(edge_info.get("length_center")) anchor_mode = self._edge_length_anchor_mode(anchor_mode) if start is not None and end is not None: direction = _tuple_normalized(_tuple_sub(end, start)) if direction is not None: midpoint = ( (start[0] + end[0]) * 0.5, (start[1] + end[1]) * 0.5, (start[2] + end[2]) * 0.5, ) if anchor_mode == "keep-start": axis_point = start anchor_source = "edge start point" elif anchor_mode == "keep-end": axis_point = end anchor_source = "edge end point" else: axis_point = center or midpoint anchor_source = "edge center" return { "axis_point": axis_point, "axis_direction": direction, "axis_source": "edge start/end chord", "anchor_source": anchor_source, } bbox_min = _tuple_or_none(edge_info.get("bbox_min")) bbox_max = _tuple_or_none(edge_info.get("bbox_max")) if bbox_min is not None and bbox_max is not None: sizes = [abs(bbox_max[index] - bbox_min[index]) for index in range(3)] axis_index = max(range(3), key=lambda index: sizes[index]) if sizes[axis_index] > 1e-9: direction = [0.0, 0.0, 0.0] direction[axis_index] = 1.0 return { "axis_point": center or ( (bbox_min[0] + bbox_max[0]) * 0.5, (bbox_min[1] + bbox_max[1]) * 0.5, (bbox_min[2] + bbox_max[2]) * 0.5, ), "axis_direction": tuple(direction), "axis_source": "edge bounding-box longest axis", "anchor_source": "edge bounding-box center", } return None def _planar_edge_length_scale_candidate( self, edge_info: dict[str, object], target_length: float, ) -> tuple[dict[str, object] | None, str]: curve = str(edge_info.get("curve", "")) if curve == "line": return None, "" current_length = float(edge_info.get("length", 0.0)) if current_length <= 1e-9: return None, "平面曲线径向缩放不可用:当前Edge长度无效。" center = _tuple_or_none(edge_info.get("center")) axis = _tuple_normalized(_tuple_or_none(edge_info.get("axis"))) axis_source = "" anchor_source = "" label = "围绕平面曲线法向径向缩放" sample_count: int | str = "" plane_deviation: float | str = "" if center is not None and axis is not None and curve in {"circle", "ellipse"}: axis_source = f"{curve} center/axis" anchor_source = f"{curve} center" label = "围绕圆边轴线径向缩放" if curve == "circle" else "围绕椭圆边法向径向缩放" else: frame, note = self._sampled_planar_edge_frame(edge_info) if frame is None: return None, note center = frame["center"] axis = frame["axis"] axis_source = "sampled edge best-fit plane" anchor_source = "sampled edge center" sample_count = int(frame["sample_count"]) plane_deviation = float(frame["plane_deviation"]) if center is None or axis is None: return None, "平面曲线径向缩放不可用:无法确定曲线中心或平面法向。" part_id = int(edge_info.get("part_id", -1)) solid_id = int(edge_info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None if part is None: return None, "平面曲线径向缩放不可用:找不到所属零件。" part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" scale = float(target_length) / max(current_length, 1e-9) transform_risk = "high" if abs(scale - 1.0) > 0.25 or axis_source.startswith("sampled") else "medium" if curve == "circle": warning = ( "圆边没有可复用的相邻圆柱直径编辑路径;将围绕圆边轴线对所属 " f"{target_kind} 做径向缩放,尽量保留轴向尺寸,但仍会影响同一 {target_kind} 上的其他径向尺寸。" ) elif curve == "ellipse": warning = ( "椭圆边将围绕自身平面法向对所属 " f"{target_kind} 做径向缩放,尽量保留法向尺寸,但会影响同一 {target_kind} 上的其他平面内尺寸。" ) else: warning = ( "当前非直线Edge可近似为平面曲线;将按采样平面法向对所属 " f"{target_kind} 做径向缩放。该路径依赖采样估算,执行后请复查周边尺寸。" ) return ( { "resize_strategy": "scale-owning-shape-from-edge", "affine_scale": scale, "affine_transform_kind": "radial-affine", "affine_transform_label": label, "affine_transform_note": "可能把解析圆/圆锥/圆柱/椭圆边面转换为 B-spline 几何。", "affine_transform_warning": warning, "affine_transform_risk": transform_risk, "affine_axis_point": center, "affine_axis_direction": axis, "affine_axis_source": axis_source, "affine_anchor_source": anchor_source, "affine_target_kind": target_kind, "part_solid_count": part_solid_count, "planar_edge_scale_sample_count": sample_count, "planar_edge_scale_plane_deviation": plane_deviation, }, "", ) def _sampled_planar_edge_frame(self, edge_info: dict[str, object]) -> tuple[dict[str, object] | None, str]: edge_id = int(edge_info.get("edge_id", -1)) if edge_id < 0 or edge_id >= len(self.edges): return None, "平面曲线径向缩放不可用:Edge ID无效。" curve = BRepAdaptor_Curve(self.edges[edge_id]) first = float(curve.FirstParameter()) last = float(curve.LastParameter()) if not math.isfinite(first) or not math.isfinite(last) or abs(last - first) <= 1e-12: return None, "平面曲线径向缩放不可用:Edge参数范围无效。" sample_count = 17 points = [ _point_tuple(curve.Value(first + (last - first) * index / (sample_count - 1))) for index in range(sample_count) ] distinct: list[tuple[float, float, float]] = [] tolerance = max(float(edge_info.get("length", 0.0)) * 1e-7, _shape_diagonal(self.edges[edge_id]) * 1e-7, 1e-7) for point in points: if not any(_vector_length(_tuple_sub(point, existing)) <= tolerance for existing in distinct): distinct.append(point) if len(distinct) < 3: return None, "平面曲线径向缩放不可用:采样点不足以确定平面。" center = _tuple_or_none(edge_info.get("length_center")) if center is None: center = ( sum(point[0] for point in distinct) / len(distinct), sum(point[1] for point in distinct) / len(distinct), sum(point[2] for point in distinct) / len(distinct), ) normal = self._sampled_edge_plane_normal(distinct, center) if normal is None: return None, "平面曲线径向缩放不可用:采样点近似共线,无法确定平面法向。" plane_deviation = max(abs(_tuple_dot(_tuple_sub(point, center), normal)) for point in distinct) max_radius = max(_vector_length(_tuple_sub(point, center)) for point in distinct) allowed_deviation = max(max_radius * 1e-4, float(edge_info.get("length", 0.0)) * 1e-5, 1e-6) if plane_deviation > allowed_deviation: return None, ( "平面曲线径向缩放不可用:Edge采样点不在稳定平面内,将使用更保守的缩放 fallback。" ) return ( { "center": center, "axis": normal, "sample_count": len(distinct), "plane_deviation": plane_deviation, }, "", ) def _sampled_edge_plane_normal( self, points: list[tuple[float, float, float]], center: tuple[float, float, float], ) -> tuple[float, float, float] | None: normal = (0.0, 0.0, 0.0) for index, point in enumerate(points): next_point = points[(index + 1) % len(points)] cross = _tuple_cross(_tuple_sub(point, center), _tuple_sub(next_point, center)) normal = ( normal[0] + cross[0], normal[1] + cross[1], normal[2] + cross[2], ) normalized = _tuple_normalized(normal) if normalized is not None: return normalized best: tuple[float, float, float] | None = None best_length = 0.0 count = len(points) for first_index in range(count - 2): for second_index in range(first_index + 1, count - 1): for third_index in range(second_index + 1, count): candidate = _tuple_cross( _tuple_sub(points[second_index], points[first_index]), _tuple_sub(points[third_index], points[first_index]), ) candidate_length = _vector_length(candidate) if candidate_length > best_length: best = candidate best_length = candidate_length return _tuple_normalized(best) def straight_edge_length_plan(self, edge_id: int, target_length: float) -> dict[str, object]: return self.general_edge_length_plan(edge_id, target_length, anchor_mode="auto") def _straight_edge_length_end_face_candidate( self, edge_info: dict[str, object], delta_length: float, anchor_mode: str = "auto", ) -> dict[str, object] | None: start = _tuple_or_none(edge_info.get("start_point")) end = _tuple_or_none(edge_info.get("end_point")) if start is None or end is None: return None axis = _tuple_normalized(_tuple_sub(end, start)) if axis is None: return None solid_id = int(edge_info.get("solid_id", -1)) if solid_id < 0 or solid_id >= len(self.solids): return None solid = self.solids[solid_id][1] tolerance = max(_shape_diagonal(solid) * 1e-5, abs(delta_length) * 1e-5, 1e-4) candidates: list[tuple[float, dict[str, object]]] = [] anchor_mode = self._edge_length_anchor_mode(anchor_mode) if anchor_mode == "keep-start": endpoint_specs = [("end", "终点端", end, _tuple_scale(axis, delta_length))] elif anchor_mode == "keep-end": endpoint_specs = [("start", "起点端", start, _tuple_scale(axis, -delta_length))] elif anchor_mode == "center": endpoint_specs = [] else: endpoint_specs = [ ("start", "起点端", start, _tuple_scale(axis, -delta_length)), ("end", "终点端", end, _tuple_scale(axis, delta_length)), ] for endpoint_role, endpoint_label, endpoint, desired_vector in endpoint_specs: desired_unit = _tuple_normalized(desired_vector) if desired_unit is None: continue for face_id, face in enumerate(self.faces): if self.face_solid_ids[face_id] != solid_id: continue surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: continue plane = surf.Plane() plane_origin = _point_tuple(plane.Location()) plane_normal = _tuple_normalized(_dir_tuple(plane.Axis().Direction())) if plane_normal is None: continue plane_distance = abs(_tuple_dot(_tuple_sub(endpoint, plane_origin), plane_normal)) if plane_distance > tolerance: continue axis_alignment = abs(_tuple_dot(plane_normal, axis)) if axis_alignment < 0.82: continue face_info = self.face_info(face_id) outward = _tuple_normalized(_tuple_or_none(face_info.get("push_pull_outward_direction"))) if outward is None: continue movement_alignment = abs(_tuple_dot(outward, desired_unit)) if movement_alignment < 0.82: continue push_pull_distance = _tuple_dot(desired_vector, outward) if abs(push_pull_distance) <= 1e-9: continue confidence_bonus = 0.0 if face_info.get("push_pull_confidence") == "high" else 0.2 score = plane_distance / max(tolerance, 1e-9) + (1.0 - movement_alignment) + confidence_bonus candidates.append( ( score, { "end_face_id": face_id, "end_face_endpoint_role": endpoint_role, "end_face_label": endpoint_label, "end_face_plane_distance": plane_distance, "end_face_axis_alignment": axis_alignment, "end_face_movement_alignment": movement_alignment, "end_face_outward_direction": outward, "end_face_push_pull_confidence": face_info.get("push_pull_confidence", ""), "push_pull_distance": push_pull_distance, "desired_movement_vector": desired_vector, }, ) ) if not candidates: return None candidates.sort(key=lambda item: item[0]) return candidates[0][1] def existing_fillet_resize_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) if info.get("surface") != "cylinder" or "radius" not in info: return { "status": "blocked", "risk": "blocked", "message": "当前选中的 Face 不是圆柱圆角面,不能修改已有圆角半径。", "blockers": "当前选中的 Face 不是圆柱圆角面。", "warnings": "", "face_id": face_id, } feature = self.feature_info(face_id) feature_guess = str(info.get("feature_guess", "")) current_radius = float(feature.get("existing_fillet_radius_estimate", info["radius"])) support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ())) warnings: list[str] = [] blockers: list[str] = [] risk = "medium" status = "caution" if feature_guess != "round/fillet candidate": blockers.append("当前圆柱面没有被识别为已有圆角/倒圆候选。") if target_radius <= 0: blockers.append("目标圆角半径必须大于 0。") if current_radius <= 0: blockers.append("当前圆角半径估算无效。") if current_radius > 0 and abs(target_radius - current_radius) <= max(current_radius * 1e-5, 1e-6): blockers.append("目标圆角半径与当前估算半径几乎相同,不需要修改。") if len(support_face_ids) < 2: blockers.append("当前版本只对识别到至少两个支撑Face的已有圆角候选开放。") part_id = int(info.get("part_id", -1)) part_stats = None try: part_stats = self.part_topology_stats(part_id) except Exception: part_stats = None if part_stats is not None and part_stats.solids != 1: blockers.append( f"当前零件包含 {part_stats.solids} 个Solid;已有圆角半径修改当前版本只对单Solid零件开放。" ) height_estimate = float(info.get("height_estimate", 0.0)) angular_span = float(info.get("angular_span", 0.0)) radius_delta = target_radius - current_radius radius_delta_ratio = abs(radius_delta) / max(current_radius, 1e-9) if not blockers: if radius_delta_ratio > 1.0: risk = "high" warnings.append("目标半径变化超过当前半径的 100%,defeature/refillet 很可能失败。") elif radius_delta_ratio > 0.35: risk = _max_risk(risk, "high") warnings.append("目标半径变化超过当前半径的 35%,请谨慎检查结果。") if height_estimate > 0 and target_radius > height_estimate * 0.5: risk = _max_risk(risk, "high") warnings.append("目标半径超过圆角长度估算的一半,几何比例异常。") if angular_span > math.pi * 1.25: risk = _max_risk(risk, "high") warnings.append("当前圆角圆弧跨度较大,可能不是普通边圆角。") if str(info.get("confidence", "low")) != "high": warnings.append("已有圆角识别置信度不是 high,执行结果需要重点检查。") if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers + warnings) else: message = "将尝试先移除已有圆角面,再在恢复出的锐边上按目标半径重新倒圆。" if warnings: message += " " + " ".join(warnings) return { "status": status, "risk": risk, "message": message, "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id"), "feature_type": feature.get("feature_type"), "feature_guess": feature_guess, "confidence": info.get("confidence"), "current_radius": current_radius, "target_radius": target_radius, "delta_radius": radius_delta, "radius_delta_ratio": radius_delta_ratio, "height_estimate": info.get("height_estimate"), "angular_span": info.get("angular_span"), "axis_point": info.get("axis_point"), "axis": info.get("axis"), "feature_existing_fillet_support_face_ids": support_face_ids, "feature_boundary_edge_ids": feature.get("feature_boundary_edge_ids"), "resize_strategy": "defeature-existing-fillet-face-then-refillet-axis-edge", "resize_note": ( "当前版本的已有圆角半径修改只支持由圆柱面表示的直线边圆角。" "执行后 Face/Edge ID 会重建,请重新选择对象确认结果。" ), } def push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: return { "status": "blocked", "risk": "blocked", "message": "当前选中的 Face 不是平面,不能执行推拉平面。", "blockers": "当前选中的 Face 不是平面。", "warnings": "", "face_id": face_id, "part_id": self.face_part_ids[face_id], "solid_id": self.face_solid_ids[face_id], "distance": distance, } info = self.face_info(face_id) scope_face_ids = self._connected_coplanar_planar_face_ids(face_id) if len(scope_face_ids) > 1: scope_note = f"将一起推拉 {len(scope_face_ids)} 个共面且相接/重叠的Face,减少 STEP 碎面导致的贴块缝。" else: scope_note = "只推拉当前Face。" direction_confidence = str(info.get("push_pull_confidence", "low")) bbox_diagonal = float(info.get("bbox_diagonal", 0.0)) distance_abs = abs(distance) warnings: list[str] = [] blockers: list[str] = [] risk = "low" status = "ready" if distance_abs <= 1e-9: status = "blocked" risk = "blocked" blockers.append("推拉距离为 0,不需要修改。") if direction_confidence != "high": risk = _max_risk(risk, "medium") warnings.append("推拉方向判断置信度较低,可能不是期望的内外方向。") if bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.2: risk = _max_risk(risk, "high") warnings.append("推拉距离超过当前Face包围盒对角线的 20%,容易导致布尔失败或大范围变形。") elif bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.08: risk = _max_risk(risk, "medium") warnings.append("推拉距离相对当前Face尺寸偏大,请确认预览范围。") if risk in {"medium", "high"} and status != "blocked": status = "caution" if blockers: message = " ".join(blockers + warnings) elif warnings: message = " ".join(warnings) else: message = "可以尝试推拉该平面。" return { "status": status, "risk": risk, "message": message, "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": info["part_id"], "solid_id": info["solid_id"], "distance": distance, "surface": info.get("surface"), "area": info.get("area"), "bbox_diagonal": info.get("bbox_diagonal"), "outward_direction": info.get("push_pull_outward_direction"), "direction_confidence": direction_confidence, "direction_note": info.get("push_pull_note"), "push_pull_scope_face_ids": tuple(scope_face_ids), "push_pull_scope_face_count": len(scope_face_ids), "push_pull_scope_note": scope_note, } def shell_thickness_plan(self, face_id: int, target_thickness: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.feature_info(face_id) blockers: list[str] = [] warnings: list[str] = [ "薄壁/壳体厚度调整基于相对平面几何估算,会移动当前选中平面区域,保留相对平面不动。" ] risk = "low" status = "ready" target_thickness = float(target_thickness) current_thickness = float(info.get("shell_thickness_estimate", 0.0)) signed_thickness = float(info.get("shell_signed_thickness", 0.0)) delta_thickness = target_thickness - current_thickness confidence = str(info.get("shell_confidence", "low")) overlap_ratio = float(info.get("shell_overlap_ratio_estimate", 0.0)) source_face_ids = tuple(_int_values(info.get("shell_source_face_ids")) or [face_id]) opposite_face_id = int(info.get("shell_opposite_face_id", -1)) if info.get("surface") != "plane": blockers.append("当前选中 Face 不是平面,不能调整薄壁/壳体厚度。") if info.get("shell_region_status") != "candidate": blockers.append(str(info.get("shell_region_note", "当前平面没有识别到相对薄壁/壳体平面。"))) if current_thickness <= 1e-9 or abs(signed_thickness) <= 1e-9: blockers.append("当前薄壁厚度估算无效。") if target_thickness <= 1e-9: blockers.append("目标薄壁厚度必须大于 0。") if abs(delta_thickness) <= max(current_thickness * 1e-5, 1e-6): blockers.append("目标厚度与当前估算厚度几乎相同,不需要修改。") normal = _tuple_normalized(_tuple_or_none(info.get("normal"))) outward = _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction"))) desired_movement = None push_pull_distance = 0.0 movement_alignment = 0.0 if not blockers: if normal is None or outward is None: blockers.append("当前平面缺少稳定法向或推拉方向,不能换算厚度修改。") else: sign = 1.0 if signed_thickness >= 0.0 else -1.0 toward_opposite = _tuple_scale(normal, sign) desired_movement = _tuple_scale(toward_opposite, -delta_thickness) desired_unit = _tuple_normalized(desired_movement) if desired_unit is None: blockers.append("目标厚度变化量无效。") else: movement_alignment = abs(_tuple_dot(desired_unit, outward)) if movement_alignment < 0.82: blockers.append("当前平面推拉方向与薄壁厚度方向不匹配,暂不执行自动厚度修改。") else: push_pull_distance = _tuple_dot(desired_movement, outward) if not blockers: delta_ratio = abs(delta_thickness) / max(current_thickness, 1e-9) if confidence == "low": risk = _max_risk(risk, "high") warnings.append("薄壁/壳体相对面识别置信度较低。") elif confidence == "medium": risk = _max_risk(risk, "medium") warnings.append("薄壁/壳体相对面识别置信度为 medium,执行后请检查周边。") if overlap_ratio < 0.25: risk = _max_risk(risk, "high") warnings.append("相对平面投影重叠率较低,可能不是稳定薄壁区域。") elif overlap_ratio < 0.55: risk = _max_risk(risk, "medium") warnings.append("相对平面投影重叠率一般,厚度估算可能偏局部。") if delta_ratio > 0.8: risk = _max_risk(risk, "high") warnings.append("目标厚度变化超过当前厚度的 80%,容易导致布尔失败或周边变形。") elif delta_ratio > 0.35: risk = _max_risk(risk, "medium") warnings.append("目标厚度变化超过当前厚度的 35%,请确认预览。") push_plan = self.push_pull_plan(face_id, push_pull_distance) if push_plan["status"] == "blocked": blockers.append(str(push_plan["message"])) else: risk = _max_risk(risk, str(push_plan["risk"])) push_warnings = str(push_plan.get("warnings", "")) if push_warnings: warnings.append(push_warnings) if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers) push_plan = {} elif risk != "low": status = "caution" message = " ".join(warnings) else: message = "可以通过推拉当前平面区域调整薄壁/壳体厚度。" 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"), "surface": info.get("surface"), "shell_region_kind": info.get("shell_region_kind"), "shell_confidence": confidence, "shell_source_face_ids": source_face_ids, "shell_opposite_face_id": opposite_face_id, "shell_current_thickness": current_thickness, "shell_target_thickness": target_thickness, "shell_delta_thickness": delta_thickness, "shell_delta_ratio": abs(delta_thickness) / max(current_thickness, 1e-9), "shell_signed_thickness": signed_thickness, "shell_overlap_ratio_estimate": overlap_ratio, "shell_opposite_normal_dot": info.get("shell_opposite_normal_dot"), "shell_desired_movement_vector": desired_movement, "shell_movement_alignment": movement_alignment, "push_pull_distance": push_pull_distance, "outward_direction": info.get("push_pull_outward_direction"), "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", source_face_ids), "push_pull_scope_face_count": push_plan.get("push_pull_scope_face_count", len(source_face_ids)), "push_pull_scope_note": push_plan.get("push_pull_scope_note", ""), "resize_strategy": "push-pull-shell-source-plane-to-target-thickness", } def shell_thickness_preview_polydata( self, face_id: int, target_thickness: float, deflection: float = 0.8, ): plan = self.shell_thickness_plan(face_id, target_thickness) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) return self.push_pull_preview_polydata(face_id, float(plan["push_pull_distance"]), deflection) def resize_shell_thickness(self, face_id: int, target_thickness: float) -> str: plan = self.shell_thickness_plan(face_id, target_thickness) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) push_result = self.push_pull_face(face_id, float(plan["push_pull_distance"])) return ( "Shell thickness resize completed by planar push/pull: " f"face {face_id}, current_thickness={float(plan['shell_current_thickness']):g}, " f"target_thickness={float(plan['shell_target_thickness']):g}, " f"delta={float(plan['shell_delta_thickness']):g}, " f"opposite_face={plan.get('shell_opposite_face_id')}, " f"push_pull_distance={float(plan['push_pull_distance']):g}, " f"risk={plan['risk']}. {push_result}" ) def cylindrical_resize_preview_polydata( self, face_id: int, new_diameter: float, deflection: float = 0.8, ) -> list[dict[str, object]]: plan = self.cylindrical_resize_plan(face_id, new_diameter) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: raise ValueError("Cylinder resize preview currently supports cylindrical faces only.") direction = surf.Cylinder().Axis().Direction() previews: list[dict[str, object]] = [] if plan["resize_mode"] == "shrink" and "fill_start_point" in plan: fill_start = gp_Pnt(*plan["fill_start_point"]) fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) filler = BRepPrimAPI_MakeCylinder( fill_axis, float(plan["fill_radius"]), float(plan["fill_height"]), ).Shape() BRepMesh_IncrementalMesh(filler, deflection) previews.append( { "role": "fill", "label": "补料预览", "polydata": _shape_faces_polydata(filler), } ) cutter_start = gp_Pnt(*plan["cutter_start_point"]) cutter_axis = gp_Ax2(cutter_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) cutter = BRepPrimAPI_MakeCylinder( cutter_axis, float(plan["cutter_radius"]), float(plan["cutter_height"]), ).Shape() BRepMesh_IncrementalMesh(cutter, deflection) previews.append( { "role": "cutter", "label": "切削预览", "polydata": _shape_faces_polydata(cutter), } ) return previews def cylindrical_boss_resize_preview_polydata( self, face_id: int, new_diameter: float, deflection: float = 0.8, ) -> list[dict[str, object]]: plan = self.cylindrical_boss_resize_plan(face_id, new_diameter) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) start = gp_Pnt(*plan["boss_tool_start_point"]) direction = gp_Dir(*plan["boss_tool_axis_direction"]) axis = gp_Ax2(start, direction) height = float(plan["boss_tool_height"]) if plan["resize_mode"] == "enlarge": tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape() BRepMesh_IncrementalMesh(tool, deflection) return [ { "role": "fill", "label": "凸台扩大补料预览", "polydata": _shape_faces_polydata(tool), } ] removal = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape() replacement = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape() BRepMesh_IncrementalMesh(removal, deflection) BRepMesh_IncrementalMesh(replacement, deflection) return [ { "role": "cutter", "label": "凸台缩小移除范围预览", "polydata": _shape_faces_polydata(removal), }, { "role": "fill", "label": "凸台缩小重建目标预览", "polydata": _shape_faces_polydata(replacement), }, ] def cylindrical_suppress_preview_polydata( self, face_id: int, deflection: float = 0.8, ) -> list[dict[str, object]]: plan = self.cylindrical_suppress_plan(face_id) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: raise ValueError("Cylinder suppress preview currently supports cylindrical faces only.") direction = surf.Cylinder().Axis().Direction() fill_start = gp_Pnt(*plan["fill_start_point"]) fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) filler = BRepPrimAPI_MakeCylinder( fill_axis, float(plan["fill_radius"]), float(plan["fill_height"]), ).Shape() BRepMesh_IncrementalMesh(filler, deflection) return [ { "role": "fill", "label": "封堵补料预览", "polydata": _shape_faces_polydata(filler), } ] def cylindrical_depth_preview_polydata( self, face_id: int, target_depth: float, bottom_face_id: int | None = None, deflection: float = 0.8, ) -> list[dict[str, object]]: plan = self.cylindrical_depth_plan( face_id, target_depth, bottom_face_id=bottom_face_id, ) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) start = gp_Pnt(*plan["depth_tool_start_point"]) direction = gp_Dir(*plan["depth_axis_direction"]) axis = gp_Ax2(start, direction) tool = BRepPrimAPI_MakeCylinder( axis, float(plan["depth_tool_radius"]), float(plan["depth_tool_height"]), ).Shape() BRepMesh_IncrementalMesh(tool, deflection) role = str(plan["depth_tool_role"]) return [ { "role": role, "label": "切削预览" if role == "cutter" else "补料预览", "polydata": _shape_faces_polydata(tool), } ] def existing_fillet_resize_preview_polydata( self, face_id: int, target_radius: float, deflection: float = 0.8, ) -> list[dict[str, object]]: plan = self.existing_fillet_resize_plan(face_id, target_radius) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) return [ { "role": "remove", "label": "将移除并重建的已有圆角面", "polydata": self.build_face_polydata(face_ids=[face_id], deflection=deflection), } ] def push_pull_preview_polydata(self, face_id: int, distance: float, deflection: float = 0.8): plan = self.push_pull_plan(face_id, distance) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) face = self.faces[face_id] scope_face_ids = _int_values(plan.get("push_pull_scope_face_ids")) or [face_id] profile_shape = self._push_pull_profile_shape(scope_face_ids) surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: raise ValueError("Push/pull preview currently supports planar faces only.") outward = plan["outward_direction"] vec = gp_Vec( float(outward[0]) * distance, float(outward[1]) * distance, float(outward[2]) * distance, ) preview_shape = BRepPrimAPI_MakePrism(profile_shape, vec).Shape() BRepMesh_IncrementalMesh(preview_shape, deflection) return _shape_faces_polydata(preview_shape) def straight_edge_length_preview_polydata( self, edge_id: int, target_length: float, deflection: float = 0.8, anchor_mode: str = "auto", ): plan = self.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) if plan.get("resize_strategy") == "local-edge-only-deform": preview_shape = self._local_edge_deform_shape(plan) BRepMesh_IncrementalMesh(preview_shape, deflection) return _shape_faces_polydata(preview_shape) if plan.get("resize_strategy") == "move-edge-end-plane-by-push-pull": return self.push_pull_preview_polydata(int(plan["end_face_id"]), float(plan["push_pull_distance"]), deflection) if plan.get("resize_strategy") == "resize-adjacent-cylinder-from-circular-edge-length": face_id = int(plan["cylinder_resize_face_id"]) target_diameter = float(plan["cylinder_resize_target_diameter"]) if plan.get("circular_edge_cylinder_mode") == "boss": return self.cylindrical_boss_resize_preview_polydata(face_id, target_diameter, deflection) return self.cylindrical_resize_preview_polydata(face_id, target_diameter, deflection) preview_shape = self._edge_length_affine_preview_shape(plan) BRepMesh_IncrementalMesh(preview_shape, deflection) return _shape_faces_polydata(preview_shape) def resize_straight_edge_length(self, edge_id: int, target_length: float, anchor_mode: str = "auto") -> str: return self.resize_general_edge_length(edge_id, target_length, anchor_mode=anchor_mode) def resize_general_edge_length(self, edge_id: int, target_length: float, anchor_mode: str = "auto") -> str: plan = self.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) if plan.get("resize_strategy") == "local-edge-only-deform": self._apply_local_edge_deform(plan) result_check = self._edge_length_result_summary(plan) return ( "Edge length resize completed by local edge-only deformation: " f"edge {edge_id}, current_length={float(plan['current_length']):g}, " f"target_length={float(plan['target_length']):g}, " f"delta={float(plan['delta_length']):g}, " f"anchor={plan.get('local_edge_deform_anchor')}, " 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}" ) if plan.get("resize_strategy") == "move-edge-end-plane-by-push-pull": push_result = self.push_pull_face(int(plan["end_face_id"]), float(plan["push_pull_distance"])) result_check = self._edge_length_result_summary(plan) return ( "Edge length resize completed by end-face push/pull: " f"edge {edge_id}, current_length={float(plan['current_length']):g}, " f"target_length={float(plan['target_length']):g}, " f"delta={float(plan['delta_length']):g}, " f"end_face={int(plan['end_face_id'])}, " f"push_pull_distance={float(plan['push_pull_distance']):g}, " f"anchor={plan.get('edge_length_anchor_label')}, " f"risk={plan['risk']}. {result_check} {push_result}" ) if plan.get("resize_strategy") == "resize-adjacent-cylinder-from-circular-edge-length": face_id = int(plan["cylinder_resize_face_id"]) target_diameter = float(plan["cylinder_resize_target_diameter"]) if plan.get("circular_edge_cylinder_mode") == "boss": resize_result = self.resize_cylindrical_boss(face_id, target_diameter) else: resize_result = self.resize_cylindrical_hole(face_id, target_diameter) result_check = self._edge_length_result_summary(plan) return ( "Edge length resize completed by adjacent cylinder diameter edit: " f"edge {edge_id}, current_length={float(plan['current_length']):g}, " f"target_length={float(plan['target_length']):g}, " f"delta={float(plan['delta_length']):g}, " f"cylinder_face={face_id}, " f"target_diameter={target_diameter:g}, " f"mode={plan.get('circular_edge_cylinder_mode_label')}, " f"risk={plan['risk']}. {result_check} {resize_result}" ) self._apply_edge_length_affine_transform(plan) result_check = self._edge_length_result_summary(plan) return ( "Edge length resize completed by geometric scale fallback: " f"edge {edge_id}, current_length={float(plan['current_length']):g}, " f"target_length={float(plan['target_length']):g}, " f"delta={float(plan['delta_length']):g}, " f"scale={float(plan['affine_scale']):g}, " f"transform={plan.get('affine_transform_label') or plan.get('affine_transform_kind')}, " f"predicted_edge_length={float(plan.get('affine_predicted_edge_length') or 0.0):g}, " f"axis_source={plan.get('affine_axis_source')}, " f"anchor={plan.get('edge_length_anchor_label')}, " f"target={plan.get('affine_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: return "Result check unavailable." return ( "Result check: " f"match={check['match_method']}, " f"nearest_edge={check['edge_id']}, " f"nearest_length={float(check['nearest_length']):g}, " f"target_error={float(check['target_error']):g}, " f"relative_error={float(check['relative_error']):g}, " f"endpoint_error={float(check['endpoint_error']):g}, " f"scope={check['scope']}." ) def _edge_length_result_check(self, plan: dict[str, object]) -> dict[str, object] | None: try: target_length = float(plan.get("target_length", 0.0)) except (TypeError, ValueError): return None if target_length <= 1e-9 or not self.edges: return None try: part_id = int(plan.get("part_id", -1)) except (TypeError, ValueError): part_id = -1 try: solid_id = int(plan.get("solid_id", -1)) except (TypeError, ValueError): solid_id = -1 target_kind = str( plan.get("local_edge_deform_target_kind") or plan.get("affine_target_kind") or ("solid" if solid_id >= 0 else "part") ) edge_ids = [edge_id for edge_id in range(len(self.edges)) if part_id < 0 or self.edge_part_ids[edge_id] == part_id] scope = f"零件 {part_id}" if part_id >= 0 else "model" if target_kind == "solid" and solid_id >= 0: solid_edge_ids = [edge_id for edge_id in edge_ids if self.edge_solid_ids[edge_id] == solid_id] if solid_edge_ids: edge_ids = solid_edge_ids scope = f"solid {solid_id}" if not edge_ids: edge_ids = list(range(len(self.edges))) scope = "model" expected = self._edge_length_expected_endpoints(plan) best_length: tuple[float, int, float, float | None] | None = None best_endpoint: tuple[float, float, int, float] | None = None for edge_id in edge_ids: try: length = float(self.edge_info(edge_id).get("length", 0.0)) except Exception: continue if length <= 1e-9: continue error = abs(length - target_length) endpoint_error = None if expected is not None: endpoint_error = self._edge_endpoint_pair_error(edge_id, expected[0], expected[1]) if endpoint_error is not None and ( best_endpoint is None or endpoint_error < best_endpoint[0] or ( abs(endpoint_error - best_endpoint[0]) <= 1e-9 and (error < best_endpoint[1] or (abs(error - best_endpoint[1]) <= 1e-9 and edge_id < best_endpoint[2])) ) ): best_endpoint = (endpoint_error, error, edge_id, length) if best_length is None or error < best_length[0] or ( abs(error - best_length[0]) <= 1e-9 and edge_id < best_length[1] ): best_length = (error, edge_id, length, endpoint_error) if best_length is None: return None match_method = "length" endpoint_error_value = best_length[3] error, edge_id, length = best_length[0], best_length[1], best_length[2] if expected is not None and best_endpoint is not None: endpoint_tolerance = max(_shape_diagonal(self.shape) * 1e-4, target_length * 1e-3, 1e-4) if best_endpoint[0] <= endpoint_tolerance: endpoint_error_value, error, edge_id, length = best_endpoint match_method = str(expected[2]) return { "edge_id": edge_id, "nearest_length": length, "target_error": error, "relative_error": error / max(target_length, 1e-9), "endpoint_error": endpoint_error_value if endpoint_error_value is not None else -1.0, "match_method": match_method, "scope": scope, } def _edge_length_expected_endpoints( self, plan: dict[str, object], ) -> tuple[tuple[float, float, float], tuple[float, float, float], str] | None: start = _tuple_or_none(plan.get("start_point")) end = _tuple_or_none(plan.get("end_point")) if start is None or end is None: return None strategy = str(plan.get("resize_strategy", "")) if strategy == "local-edge-only-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) 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", ) if strategy == "move-edge-end-plane-by-push-pull": movement = _tuple_or_none(plan.get("desired_movement_vector")) endpoint_role = str(plan.get("end_face_endpoint_role", "")) if movement is None or endpoint_role not in {"start", "end"}: return None if endpoint_role == "start": start = (start[0] + movement[0], start[1] + movement[1], start[2] + movement[2]) else: end = (end[0] + movement[0], end[1] + movement[1], end[2] + movement[2]) return start, end, "endpoint-push-pull" if strategy == "scale-owning-shape-from-edge": transformed_start = self._edge_length_affine_point(start, plan) transformed_end = self._edge_length_affine_point(end, plan) if transformed_start is None or transformed_end is None: return None return transformed_start, transformed_end, "endpoint-affine" return None def _edge_endpoint_pair_error( self, edge_id: int, expected_start: tuple[float, float, float], expected_end: tuple[float, float, float], ) -> float | None: info = self.edge_info(edge_id) start = _tuple_or_none(info.get("start_point")) end = _tuple_or_none(info.get("end_point")) if start is None or end is None: return None direct = max(_vector_length(_tuple_sub(start, expected_start)), _vector_length(_tuple_sub(end, expected_end))) reversed_order = max( _vector_length(_tuple_sub(start, expected_end)), _vector_length(_tuple_sub(end, expected_start)), ) return min(direct, reversed_order) def _edge_length_affine_point( self, point: tuple[float, float, float], plan: dict[str, object], ) -> tuple[float, float, float] | None: axis_point = _tuple_or_none(plan.get("affine_axis_point")) if axis_point is None: return None try: scale = float(plan.get("affine_scale", 1.0)) except (TypeError, ValueError): return None relative = _tuple_sub(point, axis_point) transform_kind = str(plan.get("affine_transform_kind", "axis-affine")) if transform_kind == "uniform": moved = _tuple_scale(relative, scale) else: axis_direction = _tuple_normalized(_tuple_or_none(plan.get("affine_axis_direction"))) if axis_direction is None: return None axial = _tuple_scale(axis_direction, _tuple_dot(relative, axis_direction)) radial = _tuple_sub(relative, axial) if transform_kind == "radial-affine": moved = ( axial[0] + radial[0] * scale, axial[1] + radial[1] * scale, axial[2] + radial[2] * scale, ) else: moved = ( radial[0] + axial[0] * scale, radial[1] + axial[1] * scale, radial[2] + axial[2] * scale, ) return (axis_point[0] + moved[0], axis_point[1] + moved[1], axis_point[2] + moved[2]) def _local_edge_deform_shape(self, plan: dict[str, object]) -> TopoDS_Shape: _target_kind, solid, _part, _source_solid = self._local_edge_deform_target(plan) tolerance = max(_shape_diagonal(solid) * 1e-7, abs(float(plan.get("delta_length", 0.0))) * 1e-7, 1e-6) faces = _explore(solid, TopAbs_FACE) moved_faces: list[TopoDS_Shape] = [] moved_points: dict[tuple[int, int, int], tuple[float, float, float]] = {} for face in faces: points = self._local_deform_face_vertex_points(face, tolerance) if len(points) < 3: raise RuntimeError("Local edge deformation could not read a stable face vertex loop.") for point in points: moved = self._local_edge_deform_moved_point(point, plan, tolerance) moved_points[self._local_point_key(moved, tolerance)] = moved if not moved_points: raise RuntimeError("Local edge deformation produced no moved vertices.") center = ( sum(point[0] for point in moved_points.values()) / len(moved_points), sum(point[1] for point in moved_points.values()) / len(moved_points), sum(point[2] for point in moved_points.values()) / len(moved_points), ) for face in faces: points = [ self._local_edge_deform_moved_point(point, plan, tolerance) for point in self._local_deform_face_vertex_points(face, tolerance) ] points = self._dedupe_local_points(points, tolerance) if len(points) < 3: raise RuntimeError("Local edge deformation collapsed a face.") points = self._orient_local_polygon_outward(points, center) if self._local_points_are_planar(points, tolerance): moved_faces.append(self._make_local_polygon_face(points)) else: for index in range(1, len(points) - 1): triangle = [points[0], points[index], points[index + 1]] triangle = self._orient_local_polygon_outward(triangle, center) moved_faces.append(self._make_local_polygon_face(triangle)) sewing = BRepBuilderAPI_Sewing(tolerance) for face in moved_faces: sewing.Add(face) sewing.Perform() sewed = sewing.SewedShape() if sewed.IsNull(): raise RuntimeError("Local edge deformation sewing produced an empty shape.") if sewed.ShapeType() == TopAbs_SHELL: shell = topods.Shell(sewed) else: shells = _explore(sewed, TopAbs_SHELL) if not shells: raise RuntimeError("Local edge deformation did not produce a sewable shell.") shell = topods.Shell(shells[0]) solid_builder = BRepBuilderAPI_MakeSolid(shell) solid = solid_builder.Solid() if solid.IsNull(): raise RuntimeError("Local edge deformation could not create a solid from the rebuilt shell.") return _ensure_valid_or_repaired_shape(solid, "local edge deformation") def _apply_local_edge_deform(self, plan: dict[str, object]) -> None: target_kind, _source_shape, part, source_solid = self._local_edge_deform_target(plan) transformed = self._local_edge_deform_shape(plan) if target_kind == "part": part.shape = transformed else: part_solids = _explore(part.shape, TopAbs_SOLID) replaced = False shapes: list[TopoDS_Shape] = [] for item in part_solids: if not replaced and source_solid is not None and _same_shape(item, source_solid): shapes.append(transformed) replaced = True else: shapes.append(item) if not replaced: raise RuntimeError(f"Could not locate solid {plan.get('solid_id')} inside part {plan.get('part_id')}.") part.shape = _compound_from_shapes(shapes) _ensure_valid_shape(part.shape) self.refresh_topology() def _local_edge_deform_target( self, plan: dict[str, object], ) -> tuple[str, TopoDS_Shape, object, TopoDS_Shape | None]: part_id = int(plan.get("part_id", -1)) solid_id = int(plan.get("solid_id", -1)) part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") if solid_id < 0 or solid_id >= len(self.solids): raise ValueError(f"Unknown solid id {solid_id}") target_kind = str(plan.get("local_edge_deform_target_kind", "part")) solid = self.solids[solid_id][1] return ("solid" if target_kind == "solid" else "part"), solid, part, solid def _local_deform_face_vertex_points( self, face: TopoDS_Shape, tolerance: float, ) -> list[tuple[float, float, float]]: points: list[tuple[float, float, float]] = [] explorer = TopExp_Explorer(face, TopAbs_VERTEX) while explorer.More(): vertex = topods.Vertex(explorer.Current()) point = _point_tuple(BRep_Tool.Pnt(vertex)) if not any(_vector_length(_tuple_sub(point, existing)) <= tolerance for existing in points): points.append(point) explorer.Next() if len(points) < 3: return points surf = BRepAdaptor_Surface(face) normal = _tuple_normalized(_dir_tuple(surf.Plane().Axis().Direction())) if normal is None: return points if face.Orientation() == TopAbs_REVERSED: normal = _tuple_scale(normal, -1.0) return self._order_local_polygon_points(points, normal) def _order_local_polygon_points( self, points: list[tuple[float, float, float]], normal: tuple[float, float, float], ) -> list[tuple[float, float, float]]: center = ( sum(point[0] for point in points) / len(points), sum(point[1] for point in points) / len(points), sum(point[2] for point in points) / len(points), ) reference = (1.0, 0.0, 0.0) if abs(normal[0]) < 0.9 else (0.0, 1.0, 0.0) u_axis = _tuple_normalized(_tuple_cross(normal, reference)) if u_axis is None: return points v_axis = _tuple_normalized(_tuple_cross(normal, u_axis)) if v_axis is None: return points ordered = sorted( points, key=lambda point: math.atan2( _tuple_dot(_tuple_sub(point, center), v_axis), _tuple_dot(_tuple_sub(point, center), u_axis), ), ) polygon_normal = self._local_polygon_normal(ordered) if polygon_normal is not None and _tuple_dot(polygon_normal, normal) < 0: ordered.reverse() return ordered def _local_edge_deform_moved_point( self, point: tuple[float, float, float], plan: dict[str, object], tolerance: float, ) -> tuple[float, float, float]: start = _tuple_or_none(plan.get("start_point")) end = _tuple_or_none(plan.get("end_point")) 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 start is not None and _vector_length(_tuple_sub(point, start)) <= tolerance: return (point[0] + start_move[0], point[1] + start_move[1], point[2] + start_move[2]) if end is not None and _vector_length(_tuple_sub(point, end)) <= tolerance: return (point[0] + end_move[0], point[1] + end_move[1], point[2] + end_move[2]) return point def _make_local_polygon_face(self, points: list[tuple[float, float, float]]) -> TopoDS_Shape: polygon = BRepBuilderAPI_MakePolygon() for point in points: polygon.Add(gp_Pnt(*point)) polygon.Close() if hasattr(polygon, "IsDone") and not polygon.IsDone(): raise RuntimeError("Local edge deformation could not create a polygon wire.") maker = BRepBuilderAPI_MakeFace(polygon.Wire()) if hasattr(maker, "IsDone") and not maker.IsDone(): raise RuntimeError("Local edge deformation could not create a face from a polygon wire.") face = maker.Face() if face.IsNull(): raise RuntimeError("Local edge deformation created an empty face.") return face def _dedupe_local_points( self, points: list[tuple[float, float, float]], tolerance: float, ) -> list[tuple[float, float, float]]: result: list[tuple[float, float, float]] = [] for point in points: if not any(_vector_length(_tuple_sub(point, existing)) <= tolerance for existing in result): result.append(point) if len(result) > 1 and _vector_length(_tuple_sub(result[0], result[-1])) <= tolerance: result.pop() return result def _local_points_are_planar( self, points: list[tuple[float, float, float]], tolerance: float, ) -> bool: if len(points) <= 3: return True normal = self._local_polygon_normal(points) if normal is None: return False origin = points[0] return all(abs(_tuple_dot(_tuple_sub(point, origin), normal)) <= tolerance * 20.0 for point in points[3:]) def _local_polygon_normal( self, points: list[tuple[float, float, float]], ) -> tuple[float, float, float] | None: normal = (0.0, 0.0, 0.0) count = len(points) for index, point in enumerate(points): next_point = points[(index + 1) % count] normal = ( normal[0] + (point[1] - next_point[1]) * (point[2] + next_point[2]), normal[1] + (point[2] - next_point[2]) * (point[0] + next_point[0]), normal[2] + (point[0] - next_point[0]) * (point[1] + next_point[1]), ) return _tuple_normalized(normal) def _orient_local_polygon_outward( self, points: list[tuple[float, float, float]], shape_center: tuple[float, float, float], ) -> list[tuple[float, float, float]]: normal = self._local_polygon_normal(points) if normal is None: return points center = ( sum(point[0] for point in points) / len(points), sum(point[1] for point in points) / len(points), sum(point[2] for point in points) / len(points), ) if _tuple_dot(normal, _tuple_sub(center, shape_center)) < 0: return list(reversed(points)) return points def _local_point_key(self, point: tuple[float, float, float], tolerance: float) -> tuple[int, int, int]: scale = max(float(tolerance), 1e-9) return (round(point[0] / scale), round(point[1] / scale), round(point[2] / scale)) def _edge_length_affine_preview_shape(self, plan: dict[str, object]) -> TopoDS_Shape: target_kind, source_shape, _part, _solid = self._edge_length_affine_target(plan) return self._affine_scaled_shape_along_edge(source_shape, plan) def _refine_affine_edge_length_scale(self, plan: dict[str, object]) -> str: edge_id = int(plan.get("edge_id", -1)) if edge_id < 0 or edge_id >= len(self.edges): return "" target_length = float(plan.get("target_length", 0.0)) if target_length <= 1e-9: return "" initial_scale = float(plan.get("affine_scale", 1.0)) plan["affine_initial_scale"] = initial_scale refined_scale = initial_scale measured_length = 0.0 iterations = 0 tolerance = max(target_length * 5e-4, 1e-5) for _index in range(3): iterations += 1 plan["affine_scale"] = refined_scale try: transformed_edge = self._affine_scaled_shape_along_edge(self.edges[edge_id], plan) props = GProp_GProps() brepgprop.LinearProperties(transformed_edge, props) measured_length = float(props.Mass()) except Exception: plan["affine_scale"] = initial_scale return "几何缩放比例预校正失败,将使用原始目标比例。" if measured_length <= 1e-9: plan["affine_scale"] = initial_scale return "几何缩放比例预校正失败:预估Edge长度无效。" if abs(measured_length - target_length) <= tolerance: break refined_scale *= target_length / measured_length plan["affine_scale"] = refined_scale try: transformed_edge = self._affine_scaled_shape_along_edge(self.edges[edge_id], plan) props = GProp_GProps() brepgprop.LinearProperties(transformed_edge, props) final_measured_length = float(props.Mass()) if final_measured_length > 1e-9: measured_length = final_measured_length except Exception: pass plan["affine_predicted_edge_length"] = measured_length plan["affine_scale_correction"] = refined_scale / initial_scale if abs(initial_scale) > 1e-12 else 1.0 plan["affine_scale_refine_iterations"] = iterations if abs(refined_scale - initial_scale) > max(abs(initial_scale) * 1e-4, 1e-6): plan["affine_scale_refine_note"] = "已用预变换Edge长度微调几何缩放比例。" return "已用预变换Edge长度微调几何缩放比例,使目标Edge长度更接近输入值。" plan["affine_scale_refine_note"] = "" return "" def _apply_edge_length_affine_transform(self, plan: dict[str, object]) -> None: target_kind, source_shape, part, solid = self._edge_length_affine_target(plan) transformed = self._affine_scaled_shape_along_edge(source_shape, plan) _ensure_valid_shape(transformed) if target_kind == "part": part.shape = transformed else: part_solids = _explore(part.shape, TopAbs_SOLID) replaced = False shapes: list[TopoDS_Shape] = [] for item in part_solids: if not replaced and _same_shape(item, solid): shapes.append(transformed) replaced = True else: shapes.append(item) if not replaced: raise RuntimeError(f"Could not locate solid {plan.get('solid_id')} inside part {plan.get('part_id')}.") part.shape = _compound_from_shapes(shapes) _ensure_valid_shape(part.shape) self.refresh_topology() def _edge_length_affine_target( self, plan: dict[str, object], ) -> tuple[str, TopoDS_Shape, object, TopoDS_Shape | None]: part_id = int(plan.get("part_id", -1)) solid_id = int(plan.get("solid_id", -1)) part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") target_kind = str(plan.get("affine_target_kind", "part")) if target_kind == "solid" and 0 <= solid_id < len(self.solids): return target_kind, self.solids[solid_id][1], part, self.solids[solid_id][1] return "part", part.shape, part, None def _affine_scaled_shape_along_edge(self, shape: TopoDS_Shape, plan: dict[str, object]) -> TopoDS_Shape: axis_point = _tuple_or_none(plan.get("affine_axis_point")) axis_direction = _tuple_normalized(_tuple_or_none(plan.get("affine_axis_direction"))) scale = float(plan.get("affine_scale", 1.0)) transform_kind = str(plan.get("affine_transform_kind", "axis-affine")) if axis_point is None: raise ValueError("Missing affine edge-length axis.") if transform_kind == "uniform": transform = gp_Trsf() transform.SetScale(gp_Pnt(*axis_point), scale) builder = BRepBuilderAPI_Transform(shape, transform, True) builder.Build() if not builder.IsDone(): raise RuntimeError("Uniform edge-length scale transform failed.") result = builder.Shape() if result.IsNull(): raise RuntimeError("Uniform edge-length scale transform produced an empty shape.") return result if axis_direction is None: raise ValueError("Missing affine edge-length axis direction.") ux, uy, uz = axis_direction if transform_kind == "radial-affine": matrix = [ [scale + (1.0 - scale) * ux * ux, (1.0 - scale) * ux * uy, (1.0 - scale) * ux * uz], [(1.0 - scale) * uy * ux, scale + (1.0 - scale) * uy * uy, (1.0 - scale) * uy * uz], [(1.0 - scale) * uz * ux, (1.0 - scale) * uz * uy, scale + (1.0 - scale) * uz * uz], ] else: matrix = [ [1.0 + (scale - 1.0) * ux * ux, (scale - 1.0) * ux * uy, (scale - 1.0) * ux * uz], [(scale - 1.0) * uy * ux, 1.0 + (scale - 1.0) * uy * uy, (scale - 1.0) * uy * uz], [(scale - 1.0) * uz * ux, (scale - 1.0) * uz * uy, 1.0 + (scale - 1.0) * uz * uz], ] cx, cy, cz = axis_point 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(f"{transform_kind} edge-length transform failed.") result = builder.Shape() if result.IsNull(): raise RuntimeError(f"{transform_kind} edge-length transform produced an empty shape.") return result def push_pull_face(self, face_id: int, distance: float) -> str: plan = self.push_pull_plan(face_id, distance) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: raise ValueError("Push/pull currently supports planar faces only.") 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}") outward = plan["outward_direction"] scope_face_ids = _int_values(plan.get("push_pull_scope_face_ids")) or [face_id] profile_shape = self._push_pull_profile_shape(scope_face_ids) boundary_edge_ids = self._region_boundary_edge_ids(scope_face_ids) side_face_ids = sorted( set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - set(scope_face_ids) ) side_region_mapping_specs = self._face_region_mapping_specs(side_face_ids) cap_extension = self._cylindrical_cap_extension_plan(face_id, distance, outward) if cap_extension is not None: op = BRepAlgoAPI_Fuse(part.shape, cap_extension["tool_shape"]) result = _finalize_boolean_result(op, "cylindrical cap push/pull") result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance) part.shape = result self.refresh_topology() self._apply_face_region_mapping_specs(side_region_mapping_specs) return ( "Planar face push/pull completed: cylindrical cap extension, " f"semantic_distance={distance:g}, " f"radius={float(cap_extension['radius']):g}, " f"old_height={float(cap_extension['old_height']):g}, " f"new_height={float(cap_extension['new_height']):g}, " f"side_faces={cap_extension['side_face_ids']}, " f"outward_direction={_format_tuple(outward)}, " f"direction_confidence={plan['direction_confidence']}, " f"risk={plan['risk']}." ) overlap = _boolean_overlap_distance(part.shape, distance) start_offset = -overlap if distance >= 0 else overlap tool_distance = distance + overlap if distance >= 0 else distance - overlap tool_face = _translated_shape(profile_shape, outward, start_offset) vec = gp_Vec( float(outward[0]) * tool_distance, float(outward[1]) * tool_distance, float(outward[2]) * tool_distance, ) tool_shape = BRepPrimAPI_MakePrism(tool_face, vec).Shape() op = BRepAlgoAPI_Fuse(part.shape, tool_shape) if distance >= 0 else BRepAlgoAPI_Cut(part.shape, tool_shape) result = _finalize_boolean_result(op, "push/pull") result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance) part.shape = result self.refresh_topology() self._apply_face_region_mapping_specs(side_region_mapping_specs) action = "fused outward prism" if distance >= 0 else "cut inward prism" return ( "Planar face push/pull completed: " f"{action}, semantic_distance={distance:g}, " f"tool_overlap={overlap:g}, " f"scope_faces={len(scope_face_ids)}, " f"outward_direction={_format_tuple(outward)}, " f"direction_confidence={plan['direction_confidence']}, " f"risk={plan['risk']}." ) def _cylindrical_cap_extension_plan( self, face_id: int, distance: float, outward: tuple[float, float, float], ) -> dict[str, object] | None: if distance <= 0: return None if face_id < 0 or face_id >= len(self.faces): return None if len(_explore(self.faces[face_id], TopAbs_WIRE)) > 1: return None cap_center = _surface_center(self.faces[face_id]) boundary_edge_ids = self._face_boundary_edge_ids(face_id) adjacent_face_ids = self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id) if not adjacent_face_ids: return None diagonal = _shape_diagonal(self.shape) tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) outward_dir = gp_Dir(float(outward[0]), float(outward[1]), float(outward[2])) candidates: list[tuple[float, dict[str, object]]] = [] for adjacent_id in adjacent_face_ids: side_surf = BRepAdaptor_Surface(self.faces[adjacent_id]) if side_surf.GetType() != GeomAbs_Cylinder: continue angular_span = abs(side_surf.LastUParameter() - side_surf.FirstUParameter()) if angular_span < math.tau * 0.92: continue cylinder = side_surf.Cylinder() radius = float(cylinder.Radius()) axis = cylinder.Axis() axis_point = axis.Location() axis_dir = axis.Direction() axis_alignment = _direction_dot(outward_dir, axis_dir) if abs(axis_alignment) < 0.92: continue if _point_axis_distance(axis_point, axis_dir, cap_center) > max(radius * 0.08, tolerance * 10.0): continue axis_range = self._cylindrical_axis_range(adjacent_id, side_surf) v_min = float(axis_range["v_min"]) v_max = float(axis_range["v_max"]) old_height = max(v_max - v_min, 1e-9) cap_parameter = _axis_parameter(axis_point, axis_dir, cap_center) start_distance = abs(cap_parameter - v_min) end_distance = abs(cap_parameter - v_max) end_tolerance = max(old_height * 0.05, radius * 0.2, tolerance * 10.0, 0.05) if axis_alignment > 0 and end_distance <= end_tolerance: new_min = v_min new_max = v_max + distance end_score = end_distance elif axis_alignment < 0 and start_distance <= end_tolerance: new_min = v_min - distance new_max = v_max end_score = start_distance else: continue height = max(new_max - new_min, 1e-6) start = _point_on_axis(axis_point, axis_dir, new_min) tool_shape = BRepPrimAPI_MakeCylinder(gp_Ax2(start, gp_Dir(axis_dir.X(), axis_dir.Y(), axis_dir.Z())), radius, height).Shape() score = end_score + _point_axis_distance(axis_point, axis_dir, cap_center) candidate = { "tool_shape": tool_shape, "side_face_ids": axis_range["same_domain_face_ids"], "radius": radius, "old_height": old_height, "new_height": height, "axis_alignment": axis_alignment, "cap_axis_parameter": cap_parameter, "start_parameter": new_min, "end_parameter": new_max, } candidates.append((score, candidate)) if not candidates: return None distinct_radii: list[float] = [] for _score, candidate in candidates: radius = float(candidate["radius"]) if not any(abs(radius - existing) <= max(radius, existing, 1.0) * 1e-5 for existing in distinct_radii): distinct_radii.append(radius) if len(distinct_radii) > 1: return None return min(candidates, key=lambda item: item[0])[1] def resize_existing_fillet(self, face_id: int, target_radius: float) -> str: plan = self.existing_fillet_resize_plan(face_id, target_radius) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = int(plan["part_id"]) part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") source_face = topods.Face(self.faces[face_id]) defeatured = _defeature_faces(part.shape, [source_face]) axis_point = gp_Pnt(*plan["axis_point"]) axis_dir = gp_Dir(*plan["axis"]) root_edges = _axis_aligned_edge_candidates( defeatured, axis_point, axis_dir, expected_length=float(plan.get("height_estimate") or 0.0), reference_radius=float(plan["current_radius"]), ) if not root_edges: raise RuntimeError( "已尝试移除已有圆角面,但没有找到可重新倒圆的轴向锐边;" "该圆角可能不是简单直线边圆角。" ) result = None failures: list[str] = [] for index, root_edge in enumerate(root_edges[:16], start=1): try: maker = BRepFilletAPI_MakeFillet(defeatured) maker.Add(float(target_radius), topods.Edge(root_edge)) result = _finalize_builder_result(maker, f"existing fillet resize candidate {index}") break except Exception as exc: failures.append(str(exc)) if result is None: detail = failures[-1] if failures else "没有可用的候选边。" raise RuntimeError( "已移除已有圆角面,但所有候选锐边都无法重新倒圆;" f"该圆角可能是复杂 blend 或支撑面不适合重建。最后错误:{detail}" ) part.shape = result self.refresh_topology() return ( "Existing fillet radius resize completed: " f"face {face_id}, current_radius={float(plan['current_radius']):g}, " f"target_radius={target_radius:g}, " f"delta_radius={float(plan['delta_radius']):g}, " f"support_faces={plan.get('feature_existing_fillet_support_face_ids')}, " f"risk={plan['risk']}." ) def fillet_edge(self, edge_id: int, radius: float) -> str: plan = self.edge_fillet_plan(edge_id, radius) 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}") maker = BRepFilletAPI_MakeFillet(part.shape) maker.Add(radius, topods.Edge(self.edges[edge_id])) result = _finalize_builder_result(maker, "edge fillet") part.shape = result self.refresh_topology() return ( f"Edge fillet completed: edge {edge_id}, radius={radius:g}, " f"edge_length={float(plan['edge_length']):g}, " f"radius_to_length_ratio={float(plan['radius_to_length_ratio']):g}, " f"risk={plan['risk']}." ) def chamfer_edge(self, edge_id: int, distance: float) -> str: plan = self.edge_chamfer_plan(edge_id, distance) 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}") maker = BRepFilletAPI_MakeChamfer(part.shape) maker.Add(distance, topods.Edge(self.edges[edge_id])) result = _finalize_builder_result(maker, "edge chamfer") part.shape = result self.refresh_topology() return ( f"Edge chamfer completed: edge {edge_id}, distance={distance:g}, " 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_hole(self, face_id: int, new_diameter: float) -> str: plan = self.cylindrical_resize_plan(face_id, new_diameter) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: raise ValueError("Hole resize currently supports cylindrical faces only.") cyl = surf.Cylinder() old_radius = cyl.Radius() new_radius = new_diameter / 2.0 if new_radius <= 0: raise ValueError("Target 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}") direction = cyl.Axis().Direction() old_part_shape = part.shape source_shape = part.shape if plan["resize_mode"] == "shrink": fill_start = gp_Pnt(*plan["fill_start_point"]) fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) filler = BRepPrimAPI_MakeCylinder( fill_axis, float(plan["fill_radius"]), float(plan["fill_height"]), ).Shape() fuse = BRepAlgoAPI_Fuse(part.shape, filler) source_shape = _finalize_boolean_result(fuse, "cylinder fill/fuse") cutter_start = gp_Pnt(*plan["cutter_start_point"]) cutter_axis = gp_Ax2(cutter_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) cutter = BRepPrimAPI_MakeCylinder(cutter_axis, new_radius, float(plan["cutter_height"])).Shape() op = BRepAlgoAPI_Cut(source_shape, cutter) result = _finalize_boolean_result(op, "cylinder cut") part.shape = result self.refresh_topology() verification = self._verify_cylindrical_resize_result(plan, part_id) if not verification["matched"]: part.shape = old_part_shape self.refresh_topology() detail = str(verification.get("detail", "")) raise RuntimeError( "孔/圆柱直径布尔计算返回了结果,但结果里没有检测到目标直径的圆柱面," "已回滚到修改前状态。" f"{detail}" ) action = "enlarged by bounded cut" if plan["resize_mode"] == "enlarge" else "shrunk by fill and recut" return ( f"Cylindrical resize completed: diameter {old_radius * 2.0:g} -> {new_diameter:g}, " f"mode={plan['resize_mode']}, action={action}, " f"risk={plan['risk']}, feature={plan['feature_guess']}, " f"cutter={plan['cutter_strategy']}, height={float(plan['cutter_height']):g}, " 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 if target_radius <= 1e-9: return {"matched": False, "detail": " 目标直径无效。"} try: axis_point = gp_Pnt(*plan["cutter_axis_point"]) axis_direction = gp_Dir(*plan["cutter_axis_direction"]) except Exception: return {"matched": False, "detail": " 缺少原孔轴信息,无法确认结果。"} 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.02, diagonal * 1e-6, 1e-5) axis_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4) 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()) except Exception: continue score = radius_error / max(radius_tolerance, 1e-9) + axis_distance / max(axis_tolerance, 1e-9) candidate = { "matched": radius_error <= radius_tolerance and axis_distance <= axis_tolerance, "face_id": face_id, "diameter": candidate_radius * 2.0, "radius_error": radius_error, "axis_distance": axis_distance, "radius_tolerance": radius_tolerance, "axis_tolerance": axis_tolerance, } if candidate["matched"]: return candidate if score < best_score: best_score = score best = candidate if best is None: return {"matched": False, "detail": " 未找到同零件内与原孔轴平行的圆柱面。"} return { "matched": False, "detail": ( f" 最近候选 Face {best['face_id']} 的直径约 {float(best['diameter']):.6g}," f"目标直径 {target_diameter:.6g}," f"半径误差 {float(best['radius_error']):.6g}," f"轴距 {float(best['axis_distance']):.6g}。" ), **best, } def resize_cylindrical_boss(self, face_id: int, new_diameter: float) -> str: plan = self.cylindrical_boss_resize_plan(face_id, new_diameter) 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}") start = gp_Pnt(*plan["boss_tool_start_point"]) direction = gp_Dir(*plan["boss_tool_axis_direction"]) axis = gp_Ax2(start, direction) height = float(plan["boss_tool_height"]) if plan["resize_mode"] == "enlarge": tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape() op = BRepAlgoAPI_Fuse(part.shape, tool) result = _finalize_boolean_result(op, "cylindrical boss fuse") action = "enlarged by bounded fuse" else: removal = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape() replacement = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape() remove_op = BRepAlgoAPI_Cut(part.shape, removal) removed = _finalize_boolean_result(remove_op, "cylindrical boss shrink remove envelope") if _topology_shape_count(removed, TopAbs_FACE) == 0: exact_start = gp_Pnt(*plan["boss_tool_exact_start_point"]) exact_axis = gp_Ax2(exact_start, direction) exact_replacement = BRepPrimAPI_MakeCylinder( exact_axis, float(plan["boss_tool_inner_radius"]), float(plan["boss_tool_exact_height"]), ).Shape() result = _ensure_valid_or_repaired_shape(exact_replacement, "cylindrical boss shrink replacement") else: fuse_op = BRepAlgoAPI_Fuse(removed, replacement) result = _finalize_boolean_result(fuse_op, "cylindrical boss shrink rebuild") action = "shrunk by removing old envelope and fusing target cylinder" part.shape = result self.refresh_topology() return ( f"Cylindrical boss resize completed: diameter {float(plan['current_diameter']):g} -> {new_diameter:g}, " f"mode={plan['resize_mode']}, action={action}, risk={plan['risk']}, " f"feature={plan['feature_guess']}, tool={plan['boss_tool_strategy']}, " f"height={float(plan['boss_tool_height']):g}." ) def suppress_cylindrical_hole(self, face_id: int) -> str: plan = self.cylindrical_suppress_plan(face_id) 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}") face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: raise ValueError("Cylinder suppress currently supports cylindrical faces only.") direction = surf.Cylinder().Axis().Direction() fill_start = gp_Pnt(*plan["fill_start_point"]) fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) filler = BRepPrimAPI_MakeCylinder( fill_axis, float(plan["fill_radius"]), float(plan["fill_height"]), ).Shape() fuse = BRepAlgoAPI_Fuse(part.shape, filler) result = _finalize_boolean_result(fuse, "cylinder suppress/fill") part.shape = result self.refresh_topology() return ( f"Cylindrical hole suppress completed: face {face_id}, " f"diameter={float(plan['diameter']):g}, " f"height={float(plan['fill_height']):g}, " f"risk={plan['risk']}, feature={plan['feature_guess']}." ) def resize_cylindrical_depth( self, face_id: int, target_depth: float, bottom_face_id: int | None = None, ) -> str: plan = self.cylindrical_depth_plan( face_id, target_depth, bottom_face_id=bottom_face_id, ) 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}") start = gp_Pnt(*plan["depth_tool_start_point"]) direction = gp_Dir(*plan["depth_axis_direction"]) axis = gp_Ax2(start, direction) tool = BRepPrimAPI_MakeCylinder( axis, float(plan["depth_tool_radius"]), float(plan["depth_tool_height"]), ).Shape() if plan["depth_mode"] == "deepen": op = BRepAlgoAPI_Cut(part.shape, tool) result = _finalize_boolean_result(op, "blind depth cut") action = "deepened by bounded cut" else: op = BRepAlgoAPI_Fuse(part.shape, tool) result = _finalize_boolean_result(op, "blind depth fill/fuse") action = "made shallower by bounded fill" part.shape = result self.refresh_topology() return ( f"Blind cylindrical depth completed: depth {float(plan['current_depth']):g} -> {target_depth:g}, " f"mode={plan['depth_mode']}, action={action}, " f"risk={plan['risk']}, feature={plan['feature_guess']}, " f"tool={plan['depth_tool_strategy']}, height={float(plan['depth_tool_height']):g}." )