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_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_SOLID, ) 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_Pnt, gp_Trsf, gp_Vec 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 class FeatureMixin: def editable_feature_candidates( self, limit: int = 160, detailed: bool = False, max_scan_faces: int | None = None, max_scan_edges: int | None = None, progress_callback: Callable[[], None] | None = None, ) -> list[dict[str, object]]: per_type_limit = max(1, limit // 5) candidates: list[dict[str, object]] = [] diameter_count = 0 slot_width_count = 0 slot_depth_count = 0 slot_arc_length_count = 0 slot_angular_span_count = 0 boss_diameter_count = 0 boss_height_count = 0 boss_axis_count = 0 depth_count = 0 suppress_count = 0 existing_fillet_count = 0 depth_limit = max(2, min(per_type_limit, limit // 12)) suppress_limit = max(2, min(per_type_limit, limit // 12)) existing_fillet_limit = max(2, min(per_type_limit, limit // 12)) slot_width_limit = max(2, min(per_type_limit, limit // 10)) slot_depth_limit = max(2, min(per_type_limit, limit // 10)) slot_arc_length_limit = max(2, min(per_type_limit, limit // 10)) slot_angular_span_limit = max(2, min(per_type_limit, limit // 10)) cylinder_scan_limit = max(per_type_limit * 4, 24) for item in self.cylindrical_feature_candidates( limit=cylinder_scan_limit, include_end_info=True, max_scan_faces=max_scan_faces, progress_callback=progress_callback, ): feature_guess = str(item["feature_guess"]) if existing_fillet_count < existing_fillet_limit and feature_guess == "round/fillet candidate": feature = self.feature_info(int(item["face_id"])) support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ())) support_note = ( f"支撑 Face: {support_face_ids}。" if support_face_ids else "暂未识别出稳定支撑 Face。" ) candidates.append( { "operation_key": "inspect_existing_fillet", "operation": "修改已有圆角半径", "target_kind": "face", "target_id": item["face_id"], "face_id": item["face_id"], "part_id": item["part_id"], "solid_id": item["solid_id"], "surface": "cylinder", "feature_guess": feature_guess, "current_value": feature.get("existing_fillet_radius_estimate", item["radius"]), "current_value_label": "radius", "status": "caution", "risk": "medium" if len(support_face_ids) >= 2 else "high", "confidence": item["confidence"], "note": ( "这是已有圆角/倒圆候选;点击后会选中并预填目标半径," "再点击“修改已有圆角半径”会尝试 defeature 后重新倒圆。" f" {support_note}" ), } ) existing_fillet_count += 1 if diameter_count < per_type_limit and feature_guess != "round/fillet candidate": candidates.append( { "operation_key": "resize_cylinder", "operation": "调整圆柱孔径", "target_kind": "face", "target_id": item["face_id"], "face_id": item["face_id"], "part_id": item["part_id"], "solid_id": item["solid_id"], "surface": "cylinder", "feature_guess": feature_guess, "current_value": item["diameter"], "current_value_label": "diameter", "status": item["resize_status"], "risk": item["resize_risk"], "confidence": item["confidence"], "note": item["resize_note"], } ) diameter_count += 1 if ( ( slot_width_count < slot_width_limit or slot_depth_count < slot_depth_limit or slot_arc_length_count < slot_arc_length_limit or slot_angular_span_count < slot_angular_span_limit ) and feature_guess == "hole/groove candidate" and float(item.get("angular_span", 0.0)) < math.tau * 0.92 ): feature = self.feature_info(int(item["face_id"])) slot_width = feature.get("slot_chord_width_estimate") if slot_width_count < slot_width_limit and isinstance(slot_width, (int, float)) and float(slot_width) > 0: candidates.append( { "operation_key": "resize_slot_width", "operation": "调整槽/半孔宽度", "target_kind": "face", "target_id": item["face_id"], "face_id": item["face_id"], "part_id": item["part_id"], "solid_id": item["solid_id"], "surface": "cylinder", "feature_guess": feature_guess, "current_value": float(slot_width), "current_value_label": "slot_width", "status": item["resize_status"], "risk": item["resize_risk"], "confidence": item["confidence"], "note": ( "这是槽/半孔候选;点击后会选中该 face,并把槽/半孔宽度输入框预填为参考目标值。" "执行时会把槽宽换算为圆柱直径后重建。" ), } ) slot_width_count += 1 slot_depth = feature.get("slot_sagitta_depth_estimate") if slot_depth_count < slot_depth_limit and isinstance(slot_depth, (int, float)) and float(slot_depth) > 0: candidates.append( { "operation_key": "resize_slot_depth", "operation": "调整槽/半孔深度", "target_kind": "face", "target_id": item["face_id"], "face_id": item["face_id"], "part_id": item["part_id"], "solid_id": item["solid_id"], "surface": "cylinder", "feature_guess": feature_guess, "current_value": float(slot_depth), "current_value_label": "slot_depth", "status": item["resize_status"], "risk": item["resize_risk"], "confidence": item["confidence"], "note": ( "这是槽/半孔候选;点击后会选中该 face,并把槽/半孔深度输入框预填为参考目标值。" "执行时会把槽深换算为圆柱直径后重建。" ), } ) slot_depth_count += 1 slot_arc_length = feature.get("slot_arc_length_estimate") if ( slot_arc_length_count < slot_arc_length_limit and isinstance(slot_arc_length, (int, float)) and float(slot_arc_length) > 0 ): candidates.append( { "operation_key": "resize_slot_arc_length", "operation": "调整槽/半孔圆弧长度", "target_kind": "face", "target_id": item["face_id"], "face_id": item["face_id"], "part_id": item["part_id"], "solid_id": item["solid_id"], "surface": "cylinder", "feature_guess": feature_guess, "current_value": float(slot_arc_length), "current_value_label": "slot_arc_length", "status": item["resize_status"], "risk": item["resize_risk"], "confidence": item["confidence"], "note": ( "这是槽/半孔候选;点击后会选中该 face,可在当前选中对象里修改槽/半孔圆弧长度。" "执行时会把圆弧长度换算为圆柱直径后重建。" ), } ) slot_arc_length_count += 1 slot_angular_span = feature.get("slot_angular_span", item.get("angular_span")) if ( slot_angular_span_count < slot_angular_span_limit and isinstance(slot_angular_span, (int, float)) and 1e-6 < float(slot_angular_span) < math.tau * 0.92 ): candidates.append( { "operation_key": "resize_slot_angular_span", "operation": "调整槽/半孔圆弧角度", "target_kind": "face", "target_id": item["face_id"], "face_id": item["face_id"], "part_id": item["part_id"], "solid_id": item["solid_id"], "surface": "cylinder", "feature_guess": feature_guess, "current_value": math.degrees(float(slot_angular_span)), "current_value_label": "slot_angle_degrees", "status": item["resize_status"], "risk": item["resize_risk"], "confidence": item["confidence"], "note": ( "这是槽/半孔候选;点击后会选中该 face,可在当前选中对象里修改槽/半孔圆弧角度。" "执行时会保持当前半径并重建局部扇形槽。" ), } ) slot_angular_span_count += 1 boss_info = _cylinder_boss_resize_readiness(item) if boss_diameter_count < per_type_limit and boss_info["boss_resize_status"] != "blocked": candidates.append( { "operation_key": "resize_boss", "operation": "调整圆柱凸台直径", "target_kind": "face", "target_id": item["face_id"], "face_id": item["face_id"], "part_id": item["part_id"], "solid_id": item["solid_id"], "surface": "cylinder", "feature_guess": item["feature_guess"], "current_value": item["diameter"], "current_value_label": "diameter", "status": boss_info["boss_resize_status"], "risk": boss_info["boss_resize_risk"], "confidence": item["confidence"], "note": boss_info["boss_resize_note"], } ) boss_diameter_count += 1 if boss_height_count < per_type_limit and boss_info["boss_resize_status"] != "blocked": feature = self.feature_info(int(item["face_id"])) height = feature.get("same_domain_height_estimate", item.get("height_estimate")) cap_face_ids = _int_values(feature.get("feature_start_end_face_ids")) + _int_values( feature.get("feature_end_end_face_ids") ) if isinstance(height, (int, float)) and float(height) > 0 and cap_face_ids: candidates.append( { "operation_key": "resize_boss_height", "operation": "调整圆柱凸台高度", "target_kind": "face", "target_id": item["face_id"], "face_id": item["face_id"], "part_id": item["part_id"], "solid_id": item["solid_id"], "surface": "cylinder", "feature_guess": item["feature_guess"], "current_value": float(height), "current_value_label": "height", "status": "caution", "risk": "medium" if item["confidence"] == "high" else "high", "confidence": item["confidence"], "note": ( "这是完整圆柱凸台候选;点击后会选中该 face,可在当前选中对象里修改凸台高度。" "执行时会推拉识别到的端盖 Face。" ), } ) boss_height_count += 1 if boss_axis_count < per_type_limit and boss_info["boss_resize_status"] != "blocked": feature = self.feature_info(int(item["face_id"])) axis_point = _tuple_or_none(item.get("axis_point")) axis_direction = _tuple_or_none(item.get("axis")) axis_range = feature.get("same_domain_v_range") or item.get("v_range") current_axis_center = None if ( axis_point is not None and axis_direction is not None and isinstance(axis_range, (list, tuple)) and len(axis_range) >= 2 ): v_min = _float_or_none(axis_range[0]) v_max = _float_or_none(axis_range[1]) if v_min is not None and v_max is not None: v_mid = (v_min + v_max) * 0.5 current_axis_center = ( axis_point[0] + axis_direction[0] * v_mid, axis_point[1] + axis_direction[1] * v_mid, axis_point[2] + axis_direction[2] * v_mid, ) if current_axis_center is not None: candidates.append( { "operation_key": "move_boss_axis", "operation": "修改圆柱凸台轴心坐标", "target_kind": "face", "target_id": item["face_id"], "face_id": item["face_id"], "part_id": item["part_id"], "solid_id": item["solid_id"], "surface": "cylinder", "feature_guess": item["feature_guess"], "current_value": current_axis_center, "current_value_label": "axis_center", "status": "caution", "risk": "medium" if item["confidence"] == "high" else "high", "confidence": item["confidence"], "note": ( "这是完整圆柱凸台候选;点击后会选中该 face,可在当前选中对象里修改凸台轴心坐标。" "执行时会移除旧凸台包络,再按同直径在目标轴心补出凸台。" ), } ) boss_axis_count += 1 suppress_info = _cylinder_suppress_readiness(item) if suppress_count < suppress_limit and suppress_info["suppress_status"] != "blocked": candidates.append( { "operation_key": "suppress_cylinder", "operation": "封堵圆柱孔", "target_kind": "face", "target_id": item["face_id"], "face_id": item["face_id"], "part_id": item["part_id"], "solid_id": item["solid_id"], "surface": "cylinder", "feature_guess": item["feature_guess"], "current_value": item["diameter"], "current_value_label": "diameter", "status": suppress_info["suppress_status"], "risk": suppress_info["suppress_risk"], "confidence": item["confidence"], "note": suppress_info["suppress_note"], } ) suppress_count += 1 depth_info = _cylinder_depth_readiness(item) if depth_count < depth_limit and depth_info["depth_status"] != "blocked": feature = self.feature_info(int(item["face_id"])) bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ())) if not bottom_face_ids: continue depth_context = self._blind_cylindrical_depth_context( int(item["face_id"]), item, feature, float(item["hole_depth_estimate"]), ) current_depth = float(depth_context.get("depth_current_depth", item["hole_depth_estimate"])) candidates.append( { "operation_key": "resize_depth", "operation": "调整盲孔/盲槽深度", "target_kind": "face", "target_id": item["face_id"], "face_id": item["face_id"], "part_id": item["part_id"], "solid_id": item["solid_id"], "surface": "cylinder", "feature_guess": item["feature_guess"], "current_value": current_depth, "current_value_label": "depth", "status": depth_info["depth_status"], "risk": depth_info["depth_risk"], "confidence": item["confidence"], "note": ( f"{depth_info['depth_note']} " f"底面: {bottom_face_ids}; " f"来源: {feature.get('feature_bottom_detection')}; " f"深度来源: {depth_context.get('depth_current_depth_source', 'cylinder-v-range')}。" ), } ) depth_count += 1 if ( diameter_count >= per_type_limit and boss_diameter_count >= per_type_limit and suppress_count >= suppress_limit and depth_count >= depth_limit and slot_width_count >= slot_width_limit and existing_fillet_count >= existing_fillet_limit ): break plane_count = 0 shell_thickness_count = 0 shell_thickness_limit = max(2, min(per_type_limit, limit // 10)) face_scan_limit = len(self.faces) if max_scan_faces is None else min(len(self.faces), max(0, int(max_scan_faces))) for face_id, face in enumerate(self.faces[:face_scan_limit]): if progress_callback is not None and face_id % 30 == 0: progress_callback() if plane_count >= per_type_limit and shell_thickness_count >= shell_thickness_limit: break surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: continue props = GProp_GProps() brepgprop.SurfaceProperties(face, props) if plane_count < per_type_limit: if detailed: direction_info = self._plane_push_pull_direction(face_id, surf) confidence = str(direction_info["confidence"]) risk = "low" if confidence == "high" else "medium" status = "ready" if confidence == "high" else "caution" note = str(direction_info["note"]) else: confidence = "pending" risk = "medium" status = "caution" note = "快速扫描:推拉方向会在选中Face或执行编辑前再详细判断。" candidates.append( { "operation_key": "push_pull_plane", "operation": "推拉平面", "target_kind": "face", "target_id": face_id, "face_id": face_id, "part_id": self.face_part_ids[face_id], "solid_id": self.face_solid_ids[face_id], "surface": "plane", "feature_guess": "planar push/pull candidate", "current_value": props.Mass(), "current_value_label": "area", "status": status, "risk": risk, "confidence": confidence, "note": note, } ) plane_count += 1 if shell_thickness_count < shell_thickness_limit: feature = self.feature_info(face_id) if feature.get("shell_region_status") == "candidate": shell_confidence = str(feature.get("shell_confidence", "low")) shell_risk = "low" if shell_confidence == "high" else "medium" if shell_confidence == "medium" else "high" candidates.append( { "operation_key": "resize_shell_thickness", "operation": "调整薄壁厚度", "target_kind": "face", "target_id": face_id, "face_id": face_id, "part_id": self.face_part_ids[face_id], "solid_id": self.face_solid_ids[face_id], "surface": "plane", "feature_guess": "thin-wall opposite plane candidate", "current_value": feature.get("shell_thickness_estimate"), "current_value_label": "shell_thickness", "status": "ready" if shell_confidence == "high" else "caution", "risk": shell_risk, "confidence": shell_confidence, "note": ( "快速扫描:已找到投影重叠的相对平面;点击后会填入参考目标厚度," "执行时会移动当前平面区域来改变局部薄壁/壳体厚度。" ), } ) shell_thickness_count += 1 fillet_edge_count = 0 chamfer_edge_count = 0 edge_length_count = 0 circle_edge_radius_count = 0 edge_type_limit = max(1, per_type_limit // 3) edge_scan_limit = len(self.edges) if max_scan_edges is None else min(len(self.edges), max(0, int(max_scan_edges))) for edge_id, edge in enumerate(self.edges[:edge_scan_limit]): if progress_callback is not None and edge_id % 80 == 0: progress_callback() if ( fillet_edge_count >= edge_type_limit and chamfer_edge_count >= edge_type_limit and edge_length_count >= edge_type_limit and circle_edge_radius_count >= edge_type_limit ): break curve = BRepAdaptor_Curve(edge) is_line_edge = curve.GetType() == GeomAbs_Line is_circle_edge = curve.GetType() == GeomAbs_Circle props = GProp_GProps() brepgprop.LinearProperties(edge, props) length = props.Mass() if length <= 1e-9: continue solid_id = self._edge_solid_id(edge_id) curve_label = CURVE_TYPES.get(curve.GetType(), f"type {curve.GetType()}") if is_line_edge and fillet_edge_count < edge_type_limit: candidates.append( { "operation_key": "fillet_edge", "operation": "给Edge添加圆角", "target_kind": "edge", "target_id": edge_id, "edge_id": edge_id, "part_id": self.edge_part_ids[edge_id], "solid_id": solid_id, "surface": "edge", "feature_guess": "linear edge fillet candidate", "current_value": length, "current_value_label": "length", "status": "caution", "risk": "medium", "confidence": "pending", "note": "快速扫描:添加圆角半径会在执行前根据边长和相邻面再详细判断。", } ) fillet_edge_count += 1 if is_line_edge and chamfer_edge_count < edge_type_limit: candidates.append( { "operation_key": "chamfer_edge", "operation": "给Edge添加倒角", "target_kind": "edge", "target_id": edge_id, "edge_id": edge_id, "part_id": self.edge_part_ids[edge_id], "solid_id": solid_id, "surface": "edge", "feature_guess": "linear edge chamfer candidate", "current_value": length, "current_value_label": "length", "status": "caution", "risk": "medium", "confidence": "pending", "note": "快速扫描:倒角距离会在执行前根据边长和相邻面再详细判断。", } ) chamfer_edge_count += 1 if edge_length_count < edge_type_limit: candidates.append( { "operation_key": "resize_edge_length", "operation": "修改Edge长度", "target_kind": "edge", "target_id": edge_id, "edge_id": edge_id, "part_id": self.edge_part_ids[edge_id], "solid_id": solid_id, "surface": "edge", "feature_guess": f"{curve_label} edge length candidate", "current_value": length, "current_value_label": "length", "status": "caution", "risk": "medium" if is_line_edge or is_circle_edge else "high", "confidence": "pending", "note": "快速扫描:直线边优先局部形变;圆边优先换算相邻圆柱直径;椭圆/平面曲线会尝试径向缩放;其他边使用后备策略。", } ) edge_length_count += 1 if is_circle_edge and circle_edge_radius_count < edge_type_limit: radius = float(curve.Circle().Radius()) if radius > 1e-9: candidates.append( { "operation_key": "resize_edge_length", "operation": "修改圆Edge半径", "target_kind": "edge", "target_id": edge_id, "edge_id": edge_id, "part_id": self.edge_part_ids[edge_id], "solid_id": solid_id, "surface": "edge", "feature_guess": "circular edge radius candidate", "current_value": radius, "current_value_label": "radius", "status": "caution", "risk": "medium", "confidence": "pending", "note": ( "快速扫描:点击后会选中圆Edge;可在当前选中对象里修改圆Edge半径或直径," "执行时优先换算相邻圆柱直径。" ), } ) circle_edge_radius_count += 1 status_order = {"ready": 0, "caution": 1, "blocked": 2} risk_order = {"low": 0, "medium": 1, "high": 2, "blocked": 3} operation_order = { "resize_cylinder": 0, "resize_slot_width": 1, "resize_slot_depth": 2, "resize_slot_arc_length": 3, "resize_slot_angular_span": 4, "resize_boss": 5, "resize_boss_height": 6, "suppress_cylinder": 7, "resize_depth": 8, "inspect_existing_fillet": 9, "resize_shell_thickness": 10, "push_pull_plane": 11, "fillet_edge": 12, "chamfer_edge": 13, "resize_edge_length": 14, } candidates.sort( key=lambda item: ( status_order.get(str(item["status"]), 9), risk_order.get(str(item["risk"]), 9), operation_order.get(str(item["operation_key"]), 9), int(item.get("target_id", item.get("face_id", item.get("edge_id", -1)))), ) ) return candidates[:limit] def cylindrical_feature_candidates( self, limit: int = 100, include_end_info: bool = False, max_scan_faces: int | None = None, progress_callback: Callable[[], None] | None = None, ) -> list[dict[str, object]]: candidates: list[dict[str, object]] = [] face_scan_limit = len(self.faces) if max_scan_faces is None else min(len(self.faces), max(0, int(max_scan_faces))) for face_id, face in enumerate(self.faces[:face_scan_limit]): if progress_callback is not None and face_id % 30 == 0: progress_callback() surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: continue cyl = surf.Cylinder() props = GProp_GProps() brepgprop.SurfaceProperties(face, props) radius = cyl.Radius() u_span = abs(surf.LastUParameter() - surf.FirstUParameter()) v_span = abs(surf.LastVParameter() - surf.FirstVParameter()) swept_area = max(radius * max(u_span, 1e-9), 1e-9) height_estimate = props.Mass() / swept_area boundary_edges = len(list(TopologyExplorer(face, ignore_orientation=True).edges())) classification = self._classify_cylindrical_face(face_id, surf) candidate = { "face_id": face_id, "part_id": self.face_part_ids[face_id], "solid_id": self.face_solid_ids[face_id], "radius": radius, "diameter": radius * 2.0, "axis": _dir_tuple(cyl.Axis().Direction()), "area": props.Mass(), "angular_span": u_span, "height_estimate": height_estimate, "param_height": v_span, "boundary_edges": boundary_edges, "feature_guess": classification["feature_guess"], "material_toward_axis": classification["toward_axis"], "material_away_axis": classification["away_axis"], "material_vote_summary": classification["vote_summary"], "material_sample_count": classification["sample_count"], "confidence": classification["confidence"], "note": classification["note"], } if include_end_info: candidate.update(self._cylinder_end_opening_info(face_id, surf)) candidate.update(_cylinder_resize_readiness(candidate)) candidate.update(_cylinder_boss_resize_readiness(candidate)) candidates.append(candidate) if len(candidates) >= limit: break return candidates def cylindrical_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) if info.get("surface") != "cylinder" or "diameter" not in info: return { "status": "blocked", "risk": "blocked", "message": "当前选中的 Face 不是圆柱面,不能执行圆柱切削。", } current_diameter = float(info["diameter"]) feature = self.feature_info(face_id) axis_range = self._cylindrical_axis_range( face_id, BRepAdaptor_Surface(self.faces[face_id]), _int_values(feature.get("feature_side_face_ids")), ) scoped_info = dict(info) scoped_info["height_estimate"] = axis_range["span"] scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"]) scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range)) readiness = _cylinder_resize_readiness(scoped_info, new_diameter) resize_mode = _resize_mode(current_diameter, new_diameter) delta_diameter = new_diameter - current_diameter diameter_delta_ratio = abs(delta_diameter) / max(current_diameter, 1e-9) height_estimate = float(scoped_info.get("height_estimate", 0.0)) target_to_height_ratio = new_diameter / height_estimate if height_estimate > 1e-9 else "" cutter_plan = self._bounded_cylinder_cutter_plan(face_id, new_diameter, feature) fill_plan = self._bounded_cylinder_fill_plan(face_id) if resize_mode == "shrink" else {} edit_semantics = ( "缩小孔/槽直径:先用同轴圆柱补料封住旧孔壁,再按目标直径同轴重切;保持当前轴线和估算高度。" if resize_mode == "shrink" else "扩大孔/槽直径:沿当前圆柱轴线用有限长度圆柱 cutter 切到目标直径;保持当前轴线和估算高度。" ) return { "status": readiness["resize_status"], "risk": readiness["resize_risk"], "message": readiness["resize_note"], "warnings": readiness["resize_warnings"], "blockers": readiness["resize_blockers"], "face_id": face_id, "part_id": info["part_id"], "solid_id": info["solid_id"], "current_diameter": current_diameter, "target_diameter": new_diameter, "delta_diameter": delta_diameter, "diameter_delta_ratio": diameter_delta_ratio, "target_to_height_ratio": target_to_height_ratio, "resize_mode": resize_mode, "feature_type": feature.get("feature_type"), "feature_bottom_face_ids": feature.get("feature_bottom_face_ids"), "feature_opening_face_ids": feature.get("feature_opening_face_ids"), "feature_bottom_note": feature.get("feature_bottom_note"), "feature_guess": info.get("feature_guess"), "confidence": info.get("confidence"), "angular_span": info.get("angular_span"), "height_estimate": scoped_info.get("height_estimate"), "same_domain_face_ids": axis_range["same_domain_face_ids"], "same_domain_face_count": axis_range["same_domain_face_count"], "same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]), "same_domain_range_source": axis_range["range_source"], "material_vote_summary": info.get("material_vote_summary"), "material_sample_count": info.get("material_sample_count"), "resize_strategy": "same-axis-bounded-cylinder-recut", "edit_strategy_label": "同轴圆柱重切", "edit_semantics": edit_semantics, **cutter_plan, **fill_plan, } def cylindrical_axis_move_plan( self, face_id: int, target_center: tuple[float, float, float], ) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) feature: dict[str, object] = {} try: feature = self.feature_info(face_id) except Exception: feature = {} blockers: list[str] = [] warnings: list[str] = [ "Cylinder axis move fills the current cylindrical hole, then cuts a same-diameter hole on the target axis." ] risk = "medium" try: target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2])) except (TypeError, ValueError, IndexError): target_center = (0.0, 0.0, 0.0) blockers.append("Target cylinder axis center must be three numeric coordinates.") current_diameter = _float_or_none(info.get("diameter")) angular_span = _float_or_none(info.get("angular_span")) feature_guess = str(info.get("feature_guess", "")) confidence = str(info.get("confidence", "low")) surf = BRepAdaptor_Surface(self.faces[face_id]) current_center: tuple[float, float, float] | None = None axis_direction: tuple[float, float, float] | None = None axis_range: dict[str, object] = {} if info.get("surface") != "cylinder" or surf.GetType() != GeomAbs_Cylinder: blockers.append("Selected Face is not a cylindrical hole/groove face.") else: cyl = surf.Cylinder() axis = cyl.Axis() axis_direction = _dir_tuple(axis.Direction()) axis_range = self._cylindrical_axis_range( face_id, surf, _int_values(feature.get("feature_side_face_ids")), ) mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5 current_center = _point_tuple(_point_on_axis(axis.Location(), axis.Direction(), mid_parameter)) if current_diameter is None or current_diameter <= 1e-9: blockers.append("Selected cylindrical Face has no stable diameter.") if feature_guess != "hole/groove candidate": blockers.append("Cylinder axis move currently supports recognized hole/groove candidates only.") if angular_span is None or angular_span < math.tau * 0.92: blockers.append("Cylinder axis move currently supports near-full cylindrical holes only; partial slots need a sector-aware tool.") if current_center is None or axis_direction is None: blockers.append("Could not derive a stable current cylinder axis center.") movement = (0.0, 0.0, 0.0) move_distance = 0.0 axial_delta = 0.0 radial_distance = 0.0 if current_center is not None and axis_direction is not None: movement = _tuple_sub(target_center, current_center) move_distance = _vector_length(movement) axial_delta = _tuple_dot(movement, axis_direction) radial_movement = _tuple_sub(movement, _tuple_scale(axis_direction, axial_delta)) radial_distance = _vector_length(radial_movement) diagonal = max(_shape_diagonal(self.faces[face_id]), current_diameter or 0.0, 1.0) if move_distance <= max(diagonal * 1e-7, 1e-6): blockers.append("Target cylinder axis center is almost the same as the current center.") if current_diameter is not None and current_diameter > 0: ratio = move_distance / current_diameter if ratio > 4.0: risk = _max_risk(risk, "high") warnings.append("Target axis move is more than four hole diameters; Boolean cut may affect unrelated geometry.") elif ratio > 1.0: risk = _max_risk(risk, "high") warnings.append("Target axis move is larger than one hole diameter; verify nearby walls after editing.") elif ratio > 0.35: risk = _max_risk(risk, "medium") warnings.append("Target axis move is a moderate local relocation.") if abs(axial_delta) > max(radial_distance * 0.5, (current_diameter or 1.0) * 0.25): risk = _max_risk(risk, "high") warnings.append("The target center includes a large movement along the cylinder axis; this may change the opening/bottom relationship.") if confidence == "low": risk = _max_risk(risk, "medium") warnings.append("Hole/groove recognition confidence is low.") cutter_plan: dict[str, object] = {} fill_plan: dict[str, object] = {} if not blockers and current_diameter is not None: cutter_plan = self._bounded_cylinder_cutter_plan(face_id, current_diameter, feature) fill_plan = self._bounded_cylinder_fill_plan(face_id) start = _tuple_or_none(cutter_plan.get("cutter_start_point")) axis_point = _tuple_or_none(cutter_plan.get("cutter_axis_point")) if start is None or axis_point is None: blockers.append("Could not build the bounded cutter for the moved cylinder axis.") else: cutter_plan["target_cutter_start_point"] = _tuple_add(start, movement) cutter_plan["target_cutter_axis_point"] = _tuple_add(axis_point, movement) if blockers: status = "blocked" risk = "blocked" else: status = "caution" if risk in {"medium", "high"} else "ready" message = "; ".join(blockers + warnings) if blockers or warnings else "Cylinder axis move can be attempted." return { **cutter_plan, **fill_plan, "status": status, "risk": risk, "message": message, "warnings": "; ".join(warnings), "blockers": "; ".join(blockers), "face_id": face_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id"), "surface": info.get("surface"), "feature_type": feature.get("feature_type"), "feature_guess": feature_guess, "confidence": confidence, "current_diameter": current_diameter, "target_diameter": current_diameter, "current_radius": None if current_diameter is None else current_diameter * 0.5, "target_radius": None if current_diameter is None else current_diameter * 0.5, "current_axis_center": current_center, "target_axis_center": target_center, "axis_move_vector": movement, "axis_move_distance": move_distance, "axis_move_axial_delta": axial_delta, "axis_move_radial_distance": radial_distance, "axis": axis_direction, "angular_span": angular_span, "same_domain_face_ids": axis_range.get("same_domain_face_ids", ()), "same_domain_face_count": axis_range.get("same_domain_face_count", 0), "same_domain_v_range": (axis_range.get("v_min"), axis_range.get("v_max")), "same_domain_range_source": axis_range.get("range_source", ""), "resize_strategy": "fill-old-cylinder-and-cut-moved-cylinder", "edit_strategy_label": "填旧孔并切新孔", "edit_semantics": "先填补当前完整圆柱孔,再按同直径在目标轴心切出新孔;这会改变孔的位置,不会整体平移零件。", } def cylindrical_slot_resize_plan( self, face_id: int, target_value: float, mode: str = "width", pair_face_id: int | None = None, ) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") mode_key = str(mode or "width").strip().lower().replace("-", "_") mode_aliases = { "width": "width", "slot_width": "width", "chord_width": "width", "depth": "depth", "slot_depth": "depth", "sagitta_depth": "depth", "arc": "arc_length", "arc_length": "arc_length", "slot_arc": "arc_length", "slot_arc_length": "arc_length", } mode_key = mode_aliases.get(mode_key, mode_key) mode_labels = { "width": "slot width", "depth": "slot depth", "arc_length": "slot arc length", } info = self.face_info(face_id) feature: dict[str, object] = {} try: feature = self.feature_info(face_id) except Exception: feature = {} current_diameter = _float_or_none(info.get("diameter")) if current_diameter is None: current_diameter = _float_or_none(feature.get("diameter")) current_radius = current_diameter / 2.0 if current_diameter is not None else None angular_span = ( _float_or_none(feature.get("slot_angular_span")) or _float_or_none(info.get("slot_angular_span")) or _float_or_none(info.get("angular_span")) ) slot_kind = str(feature.get("slot_kind") or info.get("slot_kind") or "") slot_status = str(feature.get("slot_status") or info.get("slot_status") or "") blockers: list[str] = [] warnings: list[str] = [ "Slot parameter edit keeps the current partial-cylinder angular span, converts the target value to a cylinder diameter, and rebuilds only the local sector volume." ] risk = "medium" try: target_value = float(target_value) except (TypeError, ValueError): target_value = 0.0 blockers.append("Target slot value must be a number.") if mode_key not in mode_labels: blockers.append(f"Unsupported slot resize mode: {mode}.") if target_value <= 0: blockers.append("Target slot value must be greater than 0.") if info.get("surface") != "cylinder" or current_diameter is None or current_diameter <= 1e-9: blockers.append("Selected face is not a measurable cylindrical slot face.") if slot_kind != "partial-cylindrical-groove": blockers.append("Selected cylindrical face is not recognized as a slot/half-hole candidate.") if angular_span is None or angular_span <= 1e-6 or angular_span >= math.tau * 0.92: blockers.append("Selected slot does not have a stable partial-cylinder angular span.") if slot_status and slot_status != "candidate": risk = _max_risk(risk, "high") warnings.append(f"Slot candidate status is {slot_status}.") span = min(max(float(angular_span or 0.0), 1e-6), math.tau - 1e-6) sin_half_span = math.sin(span / 2.0) sagitta_factor = 1.0 - math.cos(min(span, math.pi) / 2.0) if mode_key == "width" and abs(sin_half_span) <= 1e-6: blockers.append("Slot angular span is too small to derive width reliably.") if mode_key == "depth" and sagitta_factor <= 1e-6: blockers.append("Slot angular span is too small to derive depth reliably.") current_width = _float_or_none(feature.get("slot_chord_width_estimate")) if current_width is None and current_diameter is not None: current_width = current_diameter * sin_half_span current_depth = _float_or_none(feature.get("slot_sagitta_depth_estimate")) if current_depth is None and current_radius is not None: current_depth = current_radius * sagitta_factor current_arc_length = _float_or_none(feature.get("slot_arc_length_estimate")) if current_arc_length is None and current_radius is not None: current_arc_length = current_radius * span target_diameter = 0.0 if not blockers: if mode_key == "width": target_diameter = target_value / sin_half_span elif mode_key == "depth": target_diameter = 2.0 * target_value / sagitta_factor elif mode_key == "arc_length": target_diameter = 2.0 * target_value / span if target_diameter <= 1e-9: blockers.append("Target slot value produced an invalid cylinder diameter.") target_width = target_diameter * sin_half_span if target_diameter > 0 else None target_depth = target_diameter * 0.5 * sagitta_factor if target_diameter > 0 else None target_arc_length = target_diameter * 0.5 * span if target_diameter > 0 else None slot_plan = { "face_id": face_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id"), "surface": info.get("surface"), "slot_resize_mode": mode_key, "slot_resize_label": mode_labels.get(mode_key, "slot value"), "slot_resize_strategy": "local-sector-fixed-angular-span", "edit_strategy_label": "局部扇形槽重建", "edit_semantics": "保持当前槽/半孔圆弧角度,把目标宽度、深度或圆弧长度换算成圆柱直径,再填旧槽并切出新的局部扇形槽。", "slot_kind": slot_kind, "slot_status": slot_status, "slot_target_value": target_value, "slot_target_diameter": target_diameter if target_diameter > 0 else None, "slot_angular_span": angular_span, "slot_chord_factor": sin_half_span, "slot_sagitta_factor": sagitta_factor, "slot_current_width": current_width, "slot_target_width": target_width, "slot_width_delta": None if current_width is None or target_width is None else target_width - current_width, "slot_current_depth": current_depth, "slot_target_depth": target_depth, "slot_depth_delta": None if current_depth is None or target_depth is None else target_depth - current_depth, "slot_current_arc_length": current_arc_length, "slot_target_arc_length": target_arc_length, "slot_arc_length_delta": None if current_arc_length is None or target_arc_length is None else target_arc_length - current_arc_length, "feature_slot_face_ids": feature.get("feature_slot_face_ids"), "feature_slot_boundary_face_ids": feature.get("feature_slot_boundary_face_ids"), "slot_note": feature.get("slot_note"), } if blockers: return { **slot_plan, "status": "blocked", "risk": "blocked", "message": "; ".join(blockers + warnings), "warnings": "; ".join(warnings), "blockers": "; ".join(blockers), } resize_plan = self.cylindrical_resize_plan(face_id, target_diameter) resize_status = str(resize_plan.get("status", "ready")) resize_risk = str(resize_plan.get("risk", "low")) if resize_status == "blocked": status = "blocked" risk = "blocked" blockers.append(str(resize_plan.get("message", ""))) else: risk = _max_risk(risk, resize_risk) status = "caution" if risk in {"medium", "high"} else "ready" paired_slot_plan = self._paired_obround_slot_plan( face_id, target_diameter, feature, resize_plan, pair_face_id=pair_face_id, ) edit_strategy_label = "局部扇形槽重建" edit_semantics = "保持当前槽/半孔圆弧角度,把目标值换算成圆柱直径,再填旧槽并切出新的局部扇形槽。" if paired_slot_plan: edit_strategy_label = "配对长圆槽重建" edit_semantics = "识别到长圆槽另一端后,会填充旧长圆槽包络,再按同一槽中心线和目标宽度重切完整长圆槽。" risk = _max_risk(risk, str(paired_slot_plan.get("slot_pair_risk", "medium"))) pair_warning = str(paired_slot_plan.get("slot_pair_warning", "")) if pair_warning: warnings.append(pair_warning) elif pair_face_id is not None: risk = _max_risk(risk, "high") warnings.append( "Manual paired Face ID could not be used as a compatible obround slot end; " "this edit will fall back to rebuilding only the selected local slot sector." ) resize_warnings = str(resize_plan.get("warnings", "") or "") if resize_warnings: warnings.append(resize_warnings) message = "; ".join(blockers + warnings) if blockers or warnings else "Slot resize can be attempted." return { **resize_plan, **slot_plan, **paired_slot_plan, "edit_strategy_label": edit_strategy_label, "edit_semantics": edit_semantics, "status": status, "risk": risk, "message": message, "warnings": "; ".join(warnings), "blockers": "; ".join(blockers), "target_diameter": target_diameter, "derived_new_diameter": target_diameter, } def cylindrical_slot_angular_span_plan(self, face_id: int, target_angular_span: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) feature: dict[str, object] = {} try: feature = self.feature_info(face_id) except Exception: feature = {} current_diameter = _float_or_none(info.get("diameter")) if current_diameter is None: current_diameter = _float_or_none(feature.get("diameter")) current_radius = current_diameter * 0.5 if current_diameter is not None else None slot_kind = str(feature.get("slot_kind") or info.get("slot_kind") or "") slot_status = str(feature.get("slot_status") or info.get("slot_status") or "") blockers: list[str] = [] warnings: list[str] = [ "Slot angular-span edit keeps the current cylinder radius, fills the old local sector, then cuts a new local sector around the same angular center." ] risk = "medium" try: target_angular_span = float(target_angular_span) except (TypeError, ValueError): target_angular_span = 0.0 blockers.append("Target slot angular span must be a number.") surf = BRepAdaptor_Surface(self.faces[face_id]) if surf.GetType() != GeomAbs_Cylinder: blockers.append("Selected face is not a cylindrical slot face.") current_u_first = 0.0 current_u_last = 0.0 current_span = 0.0 signed_span = 0.0 else: current_u_first = float(surf.FirstUParameter()) current_u_last = float(surf.LastUParameter()) signed_span = current_u_last - current_u_first current_span = abs(signed_span) if info.get("surface") != "cylinder" or current_diameter is None or current_diameter <= 1e-9: blockers.append("Selected face is not a measurable cylindrical slot face.") if slot_kind != "partial-cylindrical-groove": blockers.append("Selected cylindrical face is not recognized as a slot/half-hole candidate.") if current_span <= 1e-6 or current_span >= math.tau * 0.92: blockers.append("Selected slot does not have a stable partial-cylinder angular span.") if target_angular_span <= 1e-6: blockers.append("Target slot angular span must be greater than 0.") if target_angular_span >= math.tau * 0.92: blockers.append("Target slot angular span must remain below a near-full cylinder.") if slot_status and slot_status != "candidate": risk = _max_risk(risk, "high") warnings.append(f"Slot candidate status is {slot_status}.") delta_span = None if current_span <= 0 else target_angular_span - current_span delta_ratio = ( None if current_span <= 1e-9 or delta_span is None else abs(delta_span) / current_span ) if delta_ratio is not None: if abs(delta_span or 0.0) <= max(current_span * 1e-5, 1e-6): blockers.append("Target slot angular span is almost the same as the current span.") elif delta_ratio > 0.75: risk = _max_risk(risk, "high") warnings.append("Target slot angular span changes by more than 75%.") elif delta_ratio > 0.35: risk = _max_risk(risk, "medium") warnings.append("Target slot angular span changes by more than 35%.") cutter_plan: dict[str, object] = {} fill_plan: dict[str, object] = {} pair_plan: dict[str, object] = {} if current_diameter is not None and current_diameter > 0: try: cutter_plan = self._bounded_cylinder_cutter_plan(face_id, current_diameter, feature) fill_plan = self._bounded_cylinder_fill_plan(face_id) pair_plan = self._paired_obround_slot_plan(face_id, current_diameter, feature, cutter_plan) except Exception as exc: blockers.append(f"Could not build slot angular-span edit tool plan: {exc}") if pair_plan: risk = _max_risk(risk, "high") warnings.append( "A paired obround slot end was detected; angular-span edit currently rebuilds the selected local sector only." ) sign = 1.0 if signed_span >= 0 else -1.0 center_u = (current_u_first + current_u_last) * 0.5 target_signed_span = target_angular_span * sign target_u_first = center_u - target_signed_span * 0.5 target_u_last = center_u + target_signed_span * 0.5 radius = current_radius if current_radius is not None else 0.0 current_width = 2.0 * radius * math.sin(min(max(current_span, 0.0), math.tau) * 0.5) if radius > 0 else None target_width = 2.0 * radius * math.sin(min(max(target_angular_span, 0.0), math.tau) * 0.5) if radius > 0 else None current_depth = radius * (1.0 - math.cos(min(max(current_span, 0.0), math.pi) * 0.5)) if radius > 0 else None target_depth = radius * (1.0 - math.cos(min(max(target_angular_span, 0.0), math.pi) * 0.5)) if radius > 0 else None current_arc_length = radius * current_span if radius > 0 else None target_arc_length = radius * target_angular_span if radius > 0 else None status = "blocked" if blockers else "caution" if risk in {"medium", "high"} else "ready" if not blockers: warnings.append("This is a B-Rep local-sector rebuild, not a recovered CAD sketch angle parameter.") message = "; ".join(blockers + warnings) if blockers or warnings else "Slot angular-span resize can be attempted." return { **cutter_plan, **fill_plan, "status": status, "risk": "blocked" if blockers else risk, "message": message, "warnings": "; ".join(warnings), "blockers": "; ".join(blockers), "face_id": face_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id"), "surface": info.get("surface"), "slot_resize_mode": "angular_span", "slot_resize_label": "slot angular span", "slot_resize_strategy": "local-sector-angular-span", "edit_strategy_label": "局部扇形槽角度重建", "edit_semantics": "保持当前圆柱半径和轴线,围绕当前角度中心填旧扇形槽并切出目标圆弧角度的新扇形槽。", "resize_mode": "widen" if (delta_span or 0.0) > 0 else "narrow", "feature_type": feature.get("feature_type"), "feature_guess": info.get("feature_guess"), "confidence": info.get("confidence"), "material_vote_summary": info.get("material_vote_summary"), "cylinder_end_type": info.get("cylinder_end_type"), "slot_kind": slot_kind, "slot_status": slot_status, "current_diameter": current_diameter, "target_diameter": current_diameter, "slot_target_diameter": current_diameter, "slot_current_angular_span": current_span, "slot_target_angular_span": target_angular_span, "slot_angular_span_delta": delta_span, "slot_angular_span_delta_ratio": delta_ratio, "slot_current_u_first": current_u_first, "slot_current_u_last": current_u_last, "slot_target_u_first": target_u_first, "slot_target_u_last": target_u_last, "slot_angular_center_parameter": center_u, "slot_current_width": current_width, "slot_target_width": target_width, "slot_current_depth": current_depth, "slot_target_depth": target_depth, "slot_current_arc_length": current_arc_length, "slot_target_arc_length": target_arc_length, "slot_pair_face_id": pair_plan.get("slot_pair_face_id", ""), "slot_pair_axis_distance": pair_plan.get("slot_pair_axis_distance", ""), "slot_pair_boundary_overlap_count": pair_plan.get("slot_pair_boundary_overlap_count", ""), "feature_slot_face_ids": feature.get("feature_slot_face_ids"), "feature_slot_boundary_face_ids": feature.get("feature_slot_boundary_face_ids"), "slot_note": feature.get("slot_note"), } def cylindrical_slot_axis_move_plan( self, face_id: int, target_center: tuple[float, float, float], ) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) feature: dict[str, object] = {} try: feature = self.feature_info(face_id) except Exception: feature = {} blockers: list[str] = [] warnings: list[str] = [ "Slot/half-hole axis move fills the old local sector, then cuts the same sector tool on the target axis." ] risk = "medium" try: target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2])) except (TypeError, ValueError, IndexError): target_center = (0.0, 0.0, 0.0) blockers.append("Target slot axis center must be three numeric coordinates.") current_diameter = _float_or_none(info.get("diameter")) if current_diameter is None: current_diameter = _float_or_none(feature.get("diameter")) slot_kind = str(feature.get("slot_kind") or info.get("slot_kind") or "") slot_status = str(feature.get("slot_status") or info.get("slot_status") or "") feature_guess = str(info.get("feature_guess", "")) confidence = str(info.get("confidence", "low")) surf = BRepAdaptor_Surface(self.faces[face_id]) current_center: tuple[float, float, float] | None = None axis_direction: tuple[float, float, float] | None = None axis_range: dict[str, object] = {} current_u_first = 0.0 current_u_last = 0.0 current_span = 0.0 if info.get("surface") != "cylinder" or surf.GetType() != GeomAbs_Cylinder: blockers.append("Selected Face is not a cylindrical slot/half-hole face.") else: cyl = surf.Cylinder() axis = cyl.Axis() axis_direction = _dir_tuple(axis.Direction()) current_u_first = float(surf.FirstUParameter()) current_u_last = float(surf.LastUParameter()) current_span = abs(current_u_last - current_u_first) axis_range = self._cylindrical_axis_range( face_id, surf, _int_values(feature.get("feature_side_face_ids")), ) mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5 current_center = _point_tuple(_point_on_axis(axis.Location(), axis.Direction(), mid_parameter)) if current_diameter is None or current_diameter <= 1e-9: blockers.append("Selected slot/half-hole has no stable diameter.") if feature_guess != "hole/groove candidate" or slot_kind != "partial-cylindrical-groove": blockers.append("Slot axis move currently supports recognized partial cylindrical slot/half-hole candidates only.") if current_span <= 1e-6 or current_span >= math.tau * 0.92: blockers.append("Selected slot/half-hole does not have a stable partial-cylinder angular span.") if current_center is None or axis_direction is None: blockers.append("Could not derive a stable current slot axis center.") movement = (0.0, 0.0, 0.0) move_distance = 0.0 axial_delta = 0.0 radial_distance = 0.0 if current_center is not None and axis_direction is not None: movement = _tuple_sub(target_center, current_center) move_distance = _vector_length(movement) axial_delta = _tuple_dot(movement, axis_direction) radial_movement = _tuple_sub(movement, _tuple_scale(axis_direction, axial_delta)) radial_distance = _vector_length(radial_movement) diagonal = max(_shape_diagonal(self.faces[face_id]), current_diameter or 0.0, 1.0) if move_distance <= max(diagonal * 1e-7, 1e-6): blockers.append("Target slot axis center is almost the same as the current center.") if current_diameter is not None and current_diameter > 0: ratio = move_distance / current_diameter if ratio > 4.0: risk = _max_risk(risk, "high") warnings.append("Target slot axis move is more than four slot diameters; Boolean cut may affect unrelated geometry.") elif ratio > 1.0: risk = _max_risk(risk, "high") warnings.append("Target slot axis move is larger than one slot diameter; verify nearby walls after editing.") elif ratio > 0.35: risk = _max_risk(risk, "medium") warnings.append("Target slot axis move is a moderate local relocation.") if abs(axial_delta) > max(radial_distance * 0.5, (current_diameter or 1.0) * 0.25): risk = _max_risk(risk, "high") warnings.append("The target center includes a large movement along the slot axis; this may change end overlap.") if confidence == "low": risk = _max_risk(risk, "medium") warnings.append("Slot recognition confidence is low.") if slot_status and slot_status != "candidate": risk = _max_risk(risk, "high") warnings.append(f"Slot candidate status is {slot_status}.") cutter_plan: dict[str, object] = {} fill_plan: dict[str, object] = {} pair_plan: dict[str, object] = {} if not blockers and current_diameter is not None: try: cutter_plan = self._bounded_cylinder_cutter_plan(face_id, current_diameter, feature) fill_plan = self._bounded_cylinder_fill_plan(face_id) pair_plan = self._paired_obround_slot_plan(face_id, current_diameter, feature, cutter_plan) axis_point = _tuple_or_none(cutter_plan.get("cutter_axis_point")) start = _tuple_or_none(cutter_plan.get("cutter_start_point")) if axis_point is None or start is None: blockers.append("Could not build the bounded sector cutter for the moved slot axis.") else: cutter_plan["target_cutter_axis_point"] = _tuple_add(axis_point, movement) cutter_plan["target_cutter_start_point"] = _tuple_add(start, movement) except Exception as exc: blockers.append(f"Could not build slot axis move tool plan: {exc}") resize_strategy = "fill-old-slot-sector-and-cut-moved-sector" edit_strategy_label = "填旧槽并切新槽" edit_semantics = "先填补当前槽/半孔扇形区域,再按同宽度、同角度在目标轴心切出新槽;不整体平移零件。" if pair_plan: center_1 = _tuple_or_none(pair_plan.get("slot_capsule_start_center_1")) center_2 = _tuple_or_none(pair_plan.get("slot_capsule_start_center_2")) if center_1 is None or center_2 is None: risk = _max_risk(risk, "high") warnings.append( "A paired obround slot end was detected, but the full-slot centerline could not be derived." ) else: pair_plan["slot_capsule_target_start_center_1"] = _tuple_add(center_1, movement) pair_plan["slot_capsule_target_start_center_2"] = _tuple_add(center_2, movement) pair_plan["slot_target_center_distance"] = pair_plan.get("slot_pair_axis_distance", "") pair_plan["slot_current_center_distance"] = pair_plan.get("slot_pair_axis_distance", "") pair_plan["slot_pair_current_axis_center_1"] = current_center pair_plan["slot_pair_target_axis_center_1"] = target_center try: pair_face_id = int(pair_plan.get("slot_pair_face_id", -1)) pair_surf = BRepAdaptor_Surface(self.faces[pair_face_id]) pair_feature = self.feature_info(pair_face_id) pair_axis_range = self._cylindrical_axis_range( pair_face_id, pair_surf, _int_values(pair_feature.get("feature_side_face_ids")), ) pair_cyl = pair_surf.Cylinder() pair_mid_parameter = (float(pair_axis_range["v_min"]) + float(pair_axis_range["v_max"])) * 0.5 pair_center = _point_tuple( _point_on_axis(pair_cyl.Axis().Location(), pair_cyl.Axis().Direction(), pair_mid_parameter) ) pair_plan["slot_pair_current_axis_center_2"] = pair_center pair_plan["slot_pair_target_axis_center_2"] = _tuple_add(pair_center, movement) except Exception: pair_plan["slot_pair_current_axis_center_2"] = "" pair_plan["slot_pair_target_axis_center_2"] = "" if not pair_plan.get("slot_pair_target_axis_center_2"): blockers.append("Could not derive both obround slot end centers for the axis move.") else: resize_strategy = "paired-obround-slot-axis-prism" edit_strategy_label = "整条长圆槽轴心移动" edit_semantics = "识别到长圆槽另一端后,会填充旧长圆槽包络,再按同槽宽、同总长度在目标轴心重切整条长圆槽;不整体平移零件。" risk = _max_risk(risk, str(pair_plan.get("slot_pair_risk", "medium"))) warnings.append("Detected paired partial-cylinder slot ends; axis move will relocate the full obround slot.") current_radius = current_diameter * 0.5 if current_diameter is not None else None current_width = ( 2.0 * current_radius * math.sin(min(max(current_span, 0.0), math.tau) * 0.5) if current_radius is not None and current_radius > 0 else None ) current_depth = ( current_radius * (1.0 - math.cos(min(max(current_span, 0.0), math.pi) * 0.5)) if current_radius is not None and current_radius > 0 else None ) status = "blocked" if blockers else "caution" if risk in {"medium", "high"} else "ready" if not blockers: warnings.append("This is a B-Rep local-sector relocation, not a recovered CAD sketch constraint.") message = "; ".join(blockers + warnings) if blockers or warnings else "Slot axis move can be attempted." return { **cutter_plan, **fill_plan, **pair_plan, "status": status, "risk": "blocked" if blockers else risk, "message": message, "warnings": "; ".join(warnings), "blockers": "; ".join(blockers), "face_id": face_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id"), "surface": info.get("surface"), "feature_type": feature.get("feature_type"), "feature_guess": feature_guess, "confidence": confidence, "material_vote_summary": info.get("material_vote_summary"), "slot_kind": slot_kind, "slot_status": slot_status, "current_diameter": current_diameter, "target_diameter": current_diameter, "slot_target_diameter": current_diameter, "current_radius": current_radius, "target_radius": current_radius, "current_axis_center": current_center, "target_axis_center": target_center, "axis_move_vector": movement, "axis_move_distance": move_distance, "axis_move_axial_delta": axial_delta, "axis_move_radial_distance": radial_distance, "axis": axis_direction, "angular_span": current_span, "slot_current_angular_span": current_span, "slot_target_angular_span": current_span, "slot_current_u_first": current_u_first, "slot_current_u_last": current_u_last, "slot_target_u_first": current_u_first, "slot_target_u_last": current_u_last, "slot_current_width": current_width, "slot_current_depth": current_depth, "slot_pair_face_id": pair_plan.get("slot_pair_face_id", ""), "slot_pair_axis_distance": pair_plan.get("slot_pair_axis_distance", ""), "slot_pair_boundary_overlap_count": pair_plan.get("slot_pair_boundary_overlap_count", ""), "same_domain_face_ids": axis_range.get("same_domain_face_ids", ()), "same_domain_face_count": axis_range.get("same_domain_face_count", 0), "same_domain_v_range": (axis_range.get("v_min"), axis_range.get("v_max")), "same_domain_range_source": axis_range.get("range_source", ""), "resize_strategy": resize_strategy, "edit_strategy_label": edit_strategy_label, "edit_semantics": edit_semantics, } def _paired_obround_slot_plan( self, face_id: int, target_diameter: float, feature: dict[str, object], resize_plan: dict[str, object], pair_face_id: int | None = None, ) -> dict[str, object]: if target_diameter <= 1e-9: return {} face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: return {} cyl = surf.Cylinder() radius = float(cyl.Radius()) if radius <= 1e-9: return {} axis_dir = _tuple_normalized(_dir_tuple(cyl.Axis().Direction())) if axis_dir is None: return {} part_id = self.face_part_ids[face_id] solid_id = self.face_solid_ids[face_id] axis_range = self._cylindrical_axis_range( face_id, surf, _int_values(feature.get("feature_side_face_ids")), ) selected_span = _float_or_none(feature.get("slot_angular_span")) if selected_span is None: selected_span = _float_or_none(feature.get("angular_span")) selected_span = float(selected_span or 0.0) selected_mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5 selected_mid = _point_tuple(_point_on_axis(cyl.Axis().Location(), cyl.Axis().Direction(), selected_mid_parameter)) selected_boundary = set(_int_values(feature.get("feature_slot_boundary_face_ids"))) if not selected_boundary: selected_boundary = set(_int_values(feature.get("feature_adjacent_face_ids"))) - set( _int_values(feature.get("feature_end_face_ids")) ) candidates: list[tuple[tuple[float, float, float, float, int], dict[str, object]]] = [] diagonal = max(_shape_diagonal(self.part_by_id(part_id).shape) if self.part_by_id(part_id) is not None else 0.0, radius, 1.0) radius_tolerance = max(radius * 0.08, diagonal * 1e-5, 1e-4) height = max(float(axis_range["v_max"]) - float(axis_range["v_min"]), 1e-9) manual_pair_face_id = None if pair_face_id is not None: try: manual_pair_face_id = int(pair_face_id) except (TypeError, ValueError): return {} if manual_pair_face_id == face_id or manual_pair_face_id < 0 or manual_pair_face_id >= len(self.faces): return {} for other_face_id, other_face in enumerate(self.faces): if other_face_id == face_id: continue if manual_pair_face_id is not None and other_face_id != manual_pair_face_id: continue if self.face_part_ids[other_face_id] != part_id: continue if solid_id >= 0 and self.face_solid_ids[other_face_id] != solid_id: continue try: other_info = self.face_info(other_face_id) if other_info.get("surface") != "cylinder": continue if str(other_info.get("feature_guess", "")) != "hole/groove candidate": continue other_surf = BRepAdaptor_Surface(other_face) if other_surf.GetType() != GeomAbs_Cylinder: continue other_cyl = other_surf.Cylinder() other_radius = float(other_cyl.Radius()) if abs(other_radius - radius) > radius_tolerance: continue axis_alignment = abs(_tuple_dot(axis_dir, _dir_tuple(other_cyl.Axis().Direction()))) if axis_alignment < 1.0 - 1e-4: continue other_feature = self.feature_info(other_face_id) other_span = _float_or_none(other_feature.get("slot_angular_span")) if other_span is None: other_span = _float_or_none(other_info.get("angular_span")) other_span = float(other_span or 0.0) if other_span <= 1e-6 or other_span >= math.tau * 0.92: continue other_range = self._cylindrical_axis_range( other_face_id, other_surf, _int_values(other_feature.get("feature_side_face_ids")), ) other_height = max(float(other_range["v_max"]) - float(other_range["v_min"]), 1e-9) if abs(other_height - height) > max(height, other_height) * 0.25: continue other_mid_parameter = (float(other_range["v_min"]) + float(other_range["v_max"])) * 0.5 other_mid = _point_tuple( _point_on_axis(other_cyl.Axis().Location(), other_cyl.Axis().Direction(), other_mid_parameter) ) raw_offset = _tuple_sub(other_mid, selected_mid) axis_offset = _tuple_scale(axis_dir, _tuple_dot(raw_offset, axis_dir)) section_offset = _tuple_sub(raw_offset, axis_offset) center_distance = _vector_length(section_offset) if center_distance <= max(radius * 1.2, diagonal * 1e-5, 1e-4): continue other_boundary = set(_int_values(other_feature.get("feature_slot_boundary_face_ids"))) if not other_boundary: other_boundary = set(_int_values(other_feature.get("feature_adjacent_face_ids"))) - set( _int_values(other_feature.get("feature_end_face_ids")) ) boundary_overlap = len(selected_boundary & other_boundary) both_half_like = ( math.pi * 0.35 <= selected_span <= math.pi * 1.65 and math.pi * 0.35 <= other_span <= math.pi * 1.65 ) if boundary_overlap <= 0 and not both_half_like and manual_pair_face_id is None: continue length_dir = _tuple_normalized(section_offset) if length_dir is None: continue side_dir = _tuple_normalized(_tuple_cross(axis_dir, length_dir)) if side_dir is None: continue overlap_score = 0.0 if boundary_overlap >= 2 else 1.0 if boundary_overlap == 1 else 2.0 score = ( overlap_score, abs(other_radius - radius), abs(other_span - selected_span), abs(other_height - height) / max(height, other_height, 1e-9), other_face_id, ) candidates.append( ( score, { "slot_pair_face_id": other_face_id, "slot_pair_boundary_overlap_count": boundary_overlap, "slot_pair_axis_distance": center_distance, "slot_pair_selected_span": selected_span, "slot_pair_other_span": other_span, "slot_pair_selected_radius": radius, "slot_pair_other_radius": other_radius, "slot_pair_manual": manual_pair_face_id is not None, "slot_capsule_axis_direction": axis_dir, "slot_capsule_length_direction": length_dir, "slot_capsule_side_direction": side_dir, "slot_capsule_start_center_1": tuple(resize_plan.get("cutter_start_point", ())), "slot_capsule_start_center_2": ( float(resize_plan["cutter_start_point"][0]) + section_offset[0], float(resize_plan["cutter_start_point"][1]) + section_offset[1], float(resize_plan["cutter_start_point"][2]) + section_offset[2], ) if isinstance(resize_plan.get("cutter_start_point"), tuple) and len(resize_plan.get("cutter_start_point", ())) == 3 else "", }, ) ) except Exception: continue if not candidates: return {} candidates.sort(key=lambda item: item[0]) candidate = candidates[0][1] if not candidate.get("slot_capsule_start_center_1") or not candidate.get("slot_capsule_start_center_2"): return {} warning = "" pair_risk = "medium" if candidate.get("slot_pair_manual"): pair_risk = "high" if int(candidate.get("slot_pair_boundary_overlap_count", 0)) <= 0 else "medium" warning = ( "Using manually specified paired partial-cylinder slot end; " "please verify the selected pair before applying the obround slot rebuild." ) elif int(candidate.get("slot_pair_boundary_overlap_count", 0)) <= 0: pair_risk = "high" warning = ( "Detected another parallel partial-cylinder slot end, but no shared boundary face was confirmed; " "the obround slot rebuild is higher risk." ) else: warning = "Detected paired partial-cylinder slot ends; the edit will rebuild the full obround slot prism." return { **candidate, "slot_resize_strategy": "paired-obround-slot-prism", "slot_pair_risk": pair_risk, "slot_pair_warning": warning, "feature_slot_face_ids": tuple( sorted({face_id, int(candidate["slot_pair_face_id"]), *_int_values(feature.get("feature_slot_face_ids"))}) ), } def cylindrical_slot_total_length_plan( self, face_id: int, target_total_length: float, pair_face_id: int | None = None, ) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) feature = self.feature_info(face_id) current_diameter = _float_or_none(info.get("diameter")) if current_diameter is None or current_diameter <= 1e-9: return { "status": "blocked", "risk": "blocked", "message": "Selected face is not a measurable cylindrical slot face.", "blockers": "Selected face is not a measurable cylindrical slot face.", "warnings": "", } try: target_total_length = float(target_total_length) except (TypeError, ValueError): target_total_length = 0.0 cutter_plan = self._bounded_cylinder_cutter_plan(face_id, current_diameter, feature) fill_plan = self._bounded_cylinder_fill_plan(face_id) paired_plan = self._paired_obround_slot_plan( face_id, current_diameter, feature, cutter_plan, pair_face_id=pair_face_id, ) blockers: list[str] = [] warnings: list[str] = [] risk = "medium" if not paired_plan: if pair_face_id is None: blockers.append("Current slot face is not recognized as one end of a paired obround slot.") else: blockers.append("Manual paired Face ID could not be used as a compatible obround slot end.") if target_total_length <= 0: blockers.append("Target slot total length must be greater than 0.") current_center_distance = _float_or_none(paired_plan.get("slot_pair_axis_distance")) current_total_length = ( current_center_distance + current_diameter if current_center_distance is not None and current_center_distance > 0 else None ) target_center_distance = target_total_length - current_diameter if target_center_distance <= max(current_diameter * 0.08, 1e-6): blockers.append("Target slot total length must be meaningfully greater than the current slot width/diameter.") delta_length = None if current_total_length is None else target_total_length - current_total_length delta_ratio = ( None if current_total_length is None or current_total_length <= 1e-9 or delta_length is None else abs(delta_length) / current_total_length ) if delta_ratio is not None: if abs(delta_length or 0.0) <= max(current_total_length * 1e-5, 1e-6): blockers.append("Target slot total length is almost the same as the current length.") elif delta_ratio > 0.75: risk = _max_risk(risk, "high") warnings.append("Target slot total length changes by more than 75%.") elif delta_ratio > 0.35: risk = _max_risk(risk, "medium") warnings.append("Target slot total length changes by more than 35%.") center_1 = _tuple_or_none(paired_plan.get("slot_capsule_start_center_1")) center_2 = _tuple_or_none(paired_plan.get("slot_capsule_start_center_2")) length_dir = _tuple_normalized(_tuple_or_none(paired_plan.get("slot_capsule_length_direction"))) target_center_1 = None target_center_2 = None if center_1 is None or center_2 is None or length_dir is None: blockers.append("Could not derive the obround slot centerline.") elif target_center_distance > 0: midpoint = ( (center_1[0] + center_2[0]) * 0.5, (center_1[1] + center_2[1]) * 0.5, (center_1[2] + center_2[2]) * 0.5, ) half_vector = _tuple_scale(length_dir, target_center_distance * 0.5) target_center_1 = _tuple_sub(midpoint, half_vector) target_center_2 = _tuple_add(midpoint, half_vector) if blockers: status = "blocked" risk = "blocked" else: status = "caution" if risk in {"medium", "high"} else "ready" warnings.append( "Slot total length edit fills the old obround slot volume, then cuts a new obround slot with the same width." ) message = "; ".join(blockers + warnings) if blockers or warnings else "Slot total length resize can be attempted." return { **cutter_plan, **fill_plan, **paired_plan, "status": status, "risk": risk, "message": message, "warnings": "; ".join(warnings), "blockers": "; ".join(blockers), "face_id": face_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id"), "surface": info.get("surface"), "current_diameter": current_diameter, "target_diameter": current_diameter, "slot_target_diameter": current_diameter, "slot_resize_mode": "total_length", "slot_resize_label": "slot total length", "slot_resize_strategy": "paired-obround-slot-length-prism", "edit_strategy_label": "长圆槽总长度重建", "edit_semantics": "保持槽宽不变,围绕长圆槽中心线对两端中心距做对称调整,然后填旧槽并重切目标总长度的长圆槽。", "resize_mode": "lengthen" if (delta_length or 0.0) > 0 else "shorten", "slot_current_total_length": current_total_length, "slot_target_total_length": target_total_length, "slot_total_length_delta": delta_length, "slot_total_length_delta_ratio": delta_ratio, "slot_current_center_distance": current_center_distance, "slot_target_center_distance": target_center_distance, "slot_capsule_target_start_center_1": target_center_1, "slot_capsule_target_start_center_2": target_center_2, } def cylindrical_slot_center_distance_plan( self, face_id: int, target_center_distance: float, pair_face_id: int | None = None, ) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) current_diameter = _float_or_none(info.get("diameter")) if current_diameter is None or current_diameter <= 1e-9: return { "status": "blocked", "risk": "blocked", "message": "Selected face is not a measurable cylindrical slot face.", "blockers": "Selected face is not a measurable cylindrical slot face.", "warnings": "", } try: target_center_distance = float(target_center_distance) except (TypeError, ValueError): target_center_distance = 0.0 target_total_length = target_center_distance + current_diameter plan = self.cylindrical_slot_total_length_plan( face_id, target_total_length, pair_face_id=pair_face_id, ) current_center_distance = _float_or_none(plan.get("slot_current_center_distance")) delta = ( None if current_center_distance is None else target_center_distance - current_center_distance ) delta_ratio = ( None if current_center_distance is None or current_center_distance <= 1e-9 or delta is None else abs(delta) / current_center_distance ) if target_center_distance <= 0: blockers = str(plan.get("blockers") or "") extra = "Target slot center distance must be greater than 0." plan["blockers"] = "; ".join(item for item in (blockers, extra) if item) plan["message"] = plan["blockers"] plan["status"] = "blocked" plan["risk"] = "blocked" plan.update( { "slot_resize_mode": "center_distance", "slot_resize_label": "slot center distance", "slot_resize_strategy": "paired-obround-slot-center-distance-prism", "edit_strategy_label": "长圆槽中心距重建", "edit_semantics": "保持槽宽不变,围绕长圆槽中心线对两端半圆中心距做对称调整,然后填旧槽并重切目标中心距的长圆槽。", "slot_target_center_distance": target_center_distance, "slot_target_total_length": target_total_length, "slot_center_distance_delta": delta, "slot_center_distance_delta_ratio": delta_ratio, } ) return plan def cylindrical_boss_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) if info.get("surface") != "cylinder" or "diameter" not in info: return { "status": "blocked", "risk": "blocked", "message": "当前选中的 Face 不是圆柱面,不能调整圆柱凸台直径。", } current_diameter = float(info["diameter"]) feature = self.feature_info(face_id) axis_range = self._cylindrical_axis_range( face_id, BRepAdaptor_Surface(self.faces[face_id]), _int_values(feature.get("feature_side_face_ids")), ) scoped_info = dict(info) scoped_info["height_estimate"] = axis_range["span"] scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"]) readiness = _cylinder_boss_resize_readiness(scoped_info, new_diameter) resize_mode = _resize_mode(current_diameter, new_diameter) delta_diameter = new_diameter - current_diameter diameter_delta_ratio = abs(delta_diameter) / max(current_diameter, 1e-9) height_estimate = float(scoped_info.get("height_estimate", 0.0)) target_to_height_ratio = new_diameter / height_estimate if height_estimate > 1e-9 else "" tool_plan = self._bounded_boss_resize_tool_plan(face_id, new_diameter) edit_semantics = ( "扩大凸台直径:按当前凸台轴线和估算高度生成目标圆柱补料体,并与所属特征做局部 Fuse。" if resize_mode == "enlarge" else "缩小凸台直径:先移除旧凸台包络,再补回目标直径圆柱;这是局部重建,不是缩放整个零件。" ) return { "status": readiness["boss_resize_status"], "risk": readiness["boss_resize_risk"], "message": readiness["boss_resize_note"], "warnings": readiness["boss_resize_warnings"], "blockers": readiness["boss_resize_blockers"], "face_id": face_id, "part_id": info["part_id"], "solid_id": info["solid_id"], "current_diameter": current_diameter, "target_diameter": new_diameter, "delta_diameter": delta_diameter, "diameter_delta_ratio": diameter_delta_ratio, "target_to_height_ratio": target_to_height_ratio, "resize_mode": resize_mode, "feature_type": feature.get("feature_type"), "feature_guess": info.get("feature_guess"), "confidence": info.get("confidence"), "angular_span": info.get("angular_span"), "height_estimate": scoped_info.get("height_estimate"), "same_domain_face_ids": axis_range["same_domain_face_ids"], "same_domain_face_count": axis_range["same_domain_face_count"], "same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]), "same_domain_range_source": axis_range["range_source"], "material_vote_summary": info.get("material_vote_summary"), "material_sample_count": info.get("material_sample_count"), "feature_adjacent_face_ids": feature.get("feature_adjacent_face_ids"), "feature_boundary_edge_ids": feature.get("feature_boundary_edge_ids"), "resize_strategy": "bounded-cylindrical-boss-envelope-rebuild", "edit_strategy_label": "凸台包络重建", "edit_semantics": edit_semantics, **tool_plan, } def cylindrical_boss_axis_move_plan( self, face_id: int, target_center: tuple[float, float, float], ) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) feature: dict[str, object] = {} try: feature = self.feature_info(face_id) except Exception: feature = {} blockers: list[str] = [] warnings: list[str] = [ "Cylindrical boss axis move removes the old boss envelope, then fuses a same-diameter boss on the target axis." ] risk = "medium" try: target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2])) except (TypeError, ValueError, IndexError): target_center = (0.0, 0.0, 0.0) blockers.append("Target boss axis center must be three numeric coordinates.") current_diameter = _float_or_none(info.get("diameter")) angular_span = _float_or_none(info.get("angular_span")) feature_guess = str(info.get("feature_guess", "")) confidence = str(info.get("confidence", "low")) surf = BRepAdaptor_Surface(self.faces[face_id]) current_center: tuple[float, float, float] | None = None axis_direction: tuple[float, float, float] | None = None axis_range: dict[str, object] = {} if info.get("surface") != "cylinder" or surf.GetType() != GeomAbs_Cylinder: blockers.append("Selected Face is not a cylindrical boss face.") else: cyl = surf.Cylinder() axis = cyl.Axis() axis_direction = _dir_tuple(axis.Direction()) axis_range = self._cylindrical_axis_range( face_id, surf, _int_values(feature.get("feature_side_face_ids")), ) mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5 current_center = _point_tuple(_point_on_axis(axis.Location(), axis.Direction(), mid_parameter)) if current_diameter is None or current_diameter <= 1e-9: blockers.append("Selected cylindrical boss has no stable diameter.") if feature_guess != "boss/outer-round candidate": blockers.append("Boss axis move currently supports recognized boss/outer-round candidates only.") if angular_span is None or angular_span < math.tau * 0.92: blockers.append("Boss axis move currently supports near-full cylindrical bosses only.") if current_center is None or axis_direction is None: blockers.append("Could not derive a stable current boss axis center.") movement = (0.0, 0.0, 0.0) move_distance = 0.0 axial_delta = 0.0 radial_distance = 0.0 if current_center is not None and axis_direction is not None: movement = _tuple_sub(target_center, current_center) move_distance = _vector_length(movement) axial_delta = _tuple_dot(movement, axis_direction) radial_movement = _tuple_sub(movement, _tuple_scale(axis_direction, axial_delta)) radial_distance = _vector_length(radial_movement) diagonal = max(_shape_diagonal(self.faces[face_id]), current_diameter or 0.0, 1.0) if move_distance <= max(diagonal * 1e-7, 1e-6): blockers.append("Target boss axis center is almost the same as the current center.") if current_diameter is not None and current_diameter > 0: ratio = move_distance / current_diameter if ratio > 4.0: risk = _max_risk(risk, "high") warnings.append("Target boss axis move is more than four diameters; Boolean operations may affect unrelated geometry.") elif ratio > 1.0: risk = _max_risk(risk, "high") warnings.append("Target boss axis move is larger than one diameter; verify nearby walls after editing.") elif ratio > 0.35: risk = _max_risk(risk, "medium") warnings.append("Target boss axis move is a moderate local relocation.") if abs(axial_delta) > max(radial_distance * 0.5, (current_diameter or 1.0) * 0.25): risk = _max_risk(risk, "high") warnings.append("The target center includes a large movement along the boss axis; this may change the boss/base overlap.") if confidence == "low": risk = _max_risk(risk, "medium") warnings.append("Boss recognition confidence is low.") tool_plan: dict[str, object] = {} if not blockers and current_diameter is not None: tool_plan = self._bounded_boss_resize_tool_plan(face_id, current_diameter) start = _tuple_or_none(tool_plan.get("boss_tool_start_point")) axis_point = _tuple_or_none(tool_plan.get("boss_tool_axis_point")) exact_start = _tuple_or_none(tool_plan.get("boss_tool_exact_start_point")) if start is None or axis_point is None: blockers.append("Could not build the bounded boss tool for the moved axis.") else: tool_plan["target_boss_tool_start_point"] = _tuple_add(start, movement) tool_plan["target_boss_tool_axis_point"] = _tuple_add(axis_point, movement) tool_plan["target_boss_tool_exact_start_point"] = ( _tuple_add(exact_start, movement) if exact_start is not None else "" ) tool_plan["target_boss_tool_radius"] = current_diameter * 0.5 if blockers: status = "blocked" risk = "blocked" else: status = "caution" if risk in {"medium", "high"} else "ready" message = "; ".join(blockers + warnings) if blockers or warnings else "Cylindrical boss axis move can be attempted." return { **tool_plan, "status": status, "risk": risk, "message": message, "warnings": "; ".join(warnings), "blockers": "; ".join(blockers), "face_id": face_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id"), "surface": info.get("surface"), "feature_type": feature.get("feature_type"), "feature_guess": feature_guess, "confidence": confidence, "current_diameter": current_diameter, "target_diameter": current_diameter, "current_radius": None if current_diameter is None else current_diameter * 0.5, "target_radius": None if current_diameter is None else current_diameter * 0.5, "current_axis_center": current_center, "target_axis_center": target_center, "axis_move_vector": movement, "axis_move_distance": move_distance, "axis_move_axial_delta": axial_delta, "axis_move_radial_distance": radial_distance, "axis": axis_direction, "angular_span": angular_span, "same_domain_face_ids": axis_range.get("same_domain_face_ids", ()), "same_domain_face_count": axis_range.get("same_domain_face_count", 0), "same_domain_v_range": (axis_range.get("v_min"), axis_range.get("v_max")), "same_domain_range_source": axis_range.get("range_source", ""), "resize_strategy": "remove-old-boss-and-fuse-moved-cylinder", "edit_strategy_label": "移除旧凸台并补新凸台", "edit_semantics": "先移除当前凸台包络,再按同直径在目标轴心补出新凸台;不整体平移零件。", } def cylindrical_suppress_plan(self, face_id: int) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) if info.get("surface") != "cylinder" or "diameter" not in info: return { "status": "blocked", "risk": "blocked", "message": "当前选中的 Face 不是圆柱面,不能封堵圆柱孔。", } feature = self.feature_info(face_id) axis_range = self._cylindrical_axis_range( face_id, BRepAdaptor_Surface(self.faces[face_id]), _int_values(feature.get("feature_side_face_ids")), ) scoped_info = dict(info) scoped_info["height_estimate"] = axis_range["span"] scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"]) scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range)) readiness = _cylinder_suppress_readiness(scoped_info) fill_plan = self._bounded_cylinder_fill_plan(face_id) fill_plan["fill_strategy"] = "bounded-hole-suppress-fill" fill_plan["fill_note"] = "按当前圆柱孔范围生成略带重叠的补料圆柱体,用于封堵完整通孔或盲孔。" return { "status": readiness["suppress_status"], "risk": readiness["suppress_risk"], "message": readiness["suppress_note"], "warnings": readiness["suppress_warnings"], "blockers": readiness["suppress_blockers"], "face_id": face_id, "part_id": info["part_id"], "solid_id": info["solid_id"], "diameter": info.get("diameter"), "radius": info.get("radius"), "angular_span": info.get("angular_span"), "height_estimate": scoped_info.get("height_estimate"), "feature_type": feature.get("feature_type"), "feature_guess": info.get("feature_guess"), "confidence": info.get("confidence"), "material_vote_summary": info.get("material_vote_summary"), "cylinder_end_type": scoped_info.get("cylinder_end_type"), "same_domain_face_ids": axis_range["same_domain_face_ids"], "same_domain_face_count": axis_range["same_domain_face_count"], "same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]), "same_domain_range_source": axis_range["range_source"], "feature_bottom_face_ids": feature.get("feature_bottom_face_ids"), "feature_opening_face_ids": feature.get("feature_opening_face_ids"), "feature_bottom_note": feature.get("feature_bottom_note"), "resize_strategy": "fill-cylindrical-hole-volume", "edit_strategy_label": "圆柱补料封堵", "edit_semantics": "按当前孔轴线和估算高度生成补料圆柱体,局部 Fuse 后封堵当前完整圆柱孔。", **fill_plan, } def cylindrical_depth_plan( self, face_id: int, target_depth: float, bottom_face_id: int | None = None, ) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) if info.get("surface") != "cylinder" or "diameter" not in info: return { "status": "blocked", "risk": "blocked", "message": "当前选中的 Face 不是圆柱面,不能调整盲孔/盲槽深度。", } feature = self.feature_info(face_id) context = self._blind_cylindrical_depth_context( face_id, info, feature, target_depth, bottom_face_id=bottom_face_id, ) depth_info = dict(info) if context.get("context_status") == "ready" and isinstance(context.get("depth_current_depth"), (int, float)): depth_info["hole_depth_estimate"] = float(context["depth_current_depth"]) depth_info["manual_bottom_face_used"] = bool(context.get("manual_bottom_face_used")) readiness = _cylinder_depth_readiness(depth_info, target_depth) if context.get("context_status") == "blocked": readiness = dict(readiness) readiness["depth_status"] = "blocked" readiness["depth_risk"] = "blocked" readiness["depth_blockers"] = _join_nonempty( readiness.get("depth_blockers"), context.get("context_message"), ) readiness["depth_note"] = readiness["depth_blockers"] current_depth = float(depth_info.get("hole_depth_estimate", 0.0)) delta_depth = target_depth - current_depth depth_delta_ratio = abs(delta_depth) / max(current_depth, 1e-9) plan = { "status": readiness["depth_status"], "risk": readiness["depth_risk"], "message": readiness["depth_note"], "warnings": readiness["depth_warnings"], "blockers": readiness["depth_blockers"], "face_id": face_id, "part_id": info["part_id"], "solid_id": info["solid_id"], "current_depth": current_depth, "target_depth": target_depth, "delta_depth": delta_depth, "depth_delta_ratio": depth_delta_ratio, "depth_mode": "deepen" if delta_depth > 0 else "shallow", "diameter": info.get("diameter"), "radius": info.get("radius"), "feature_type": feature.get("feature_type"), "feature_guess": info.get("feature_guess"), "confidence": info.get("confidence"), "angular_span": info.get("angular_span"), "material_vote_summary": info.get("material_vote_summary"), "cylinder_end_type": info.get("cylinder_end_type"), "start_end_state": info.get("start_end_state"), "end_end_state": info.get("end_end_state"), "feature_bottom_face_ids": context.get("feature_bottom_face_ids", feature.get("feature_bottom_face_ids")), "manual_bottom_face_id": context.get("manual_bottom_face_id", ""), "manual_bottom_face_used": bool(context.get("manual_bottom_face_used")), "manual_bottom_face_note": context.get("manual_bottom_face_note", ""), "feature_opening_face_ids": feature.get("feature_opening_face_ids"), "feature_bottom_confidence": feature.get("feature_bottom_confidence"), "feature_bottom_detection": feature.get("feature_bottom_detection"), "feature_bottom_note": feature.get("feature_bottom_note"), "resize_strategy": "bounded-blind-depth-cut-or-fill", "edit_strategy_label": "盲孔/盲槽深度切削或补料", "edit_semantics": "沿识别到的开口到底面方向调整深度:加深时切削,变浅时从新底面到旧底面补料。", } plan.update(context) return plan def _blind_cylindrical_depth_context( self, face_id: int, info: dict[str, object], feature: dict[str, object], target_depth: float, bottom_face_id: int | None = None, ) -> dict[str, object]: face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: return { "context_status": "blocked", "context_message": "当前选中的 Face 不是圆柱面。", } manual_bottom_face_id = None manual_bottom_face_used = bottom_face_id is not None if bottom_face_id is not None: manual_bottom_face_id = int(bottom_face_id) if manual_bottom_face_id < 0 or manual_bottom_face_id >= len(self.faces): return { "context_status": "blocked", "context_message": f"手动底面 Face ID {manual_bottom_face_id} 不存在。", } if manual_bottom_face_id == face_id: return { "context_status": "blocked", "context_message": "手动底面不能和当前圆柱侧壁使用同一个 Face ID。", } source_solid_id = self.face_solid_ids[face_id] bottom_solid_id = self.face_solid_ids[manual_bottom_face_id] if source_solid_id >= 0 and bottom_solid_id >= 0 and source_solid_id != bottom_solid_id: return { "context_status": "blocked", "context_message": "手动底面 Face 与当前圆柱面不属于同一个 solid,已阻止孔深修改。", } bottom_face_ids = (manual_bottom_face_id,) manual_bottom_face_note = "使用用户手动指定的底面 Face ID 计算孔深。" else: bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ())) manual_bottom_face_note = "" if not bottom_face_ids: return { "context_status": "blocked", "context_message": "当前版本的孔深调整需要疑似底面;如果自动识别失败,请手动填写底面Face ID。", } cyl = surf.Cylinder() radius = float(cyl.Radius()) axis = cyl.Axis() axis_point = axis.Location() axis_dir = axis.Direction() axis_range = self._cylindrical_axis_range( face_id, surf, _int_values(feature.get("feature_side_face_ids")), ) v_min = float(axis_range["v_min"]) v_max = float(axis_range["v_max"]) start_open = info.get("start_end_open") is True end_open = info.get("end_end_open") is True open_direction_source = "axis-end-material-sampling" if start_open != end_open: open_parameter = v_min nominal_bottom_parameter = v_max direction_sign = 1.0 if not start_open: open_parameter = v_max nominal_bottom_parameter = v_min direction_sign = -1.0 bottom_parameter = self._bottom_face_axis_parameter( bottom_face_ids, axis_point, axis_dir, nominal_bottom_parameter, ) current_depth_source = "bottom-face-axis-parameter" if bottom_parameter is not None else "cylinder-v-range" if bottom_parameter is None: bottom_parameter = nominal_bottom_parameter elif manual_bottom_face_used: bottom_parameter = self._bottom_face_axis_parameter( bottom_face_ids, axis_point, axis_dir, (v_min + v_max) * 0.5, ) if bottom_parameter is None: return { "context_status": "blocked", "context_message": "手动底面无法投影到当前圆柱轴线上,不能计算孔深。", } if abs(bottom_parameter - v_min) <= abs(bottom_parameter - v_max): open_parameter = v_max nominal_bottom_parameter = v_min direction_sign = -1.0 else: open_parameter = v_min nominal_bottom_parameter = v_max direction_sign = 1.0 current_depth_source = "manual-bottom-face-axis-parameter" open_direction_source = "manual-bottom-face-nearest-axis-end" else: return { "context_status": "blocked", "context_message": "圆柱端部开口方向不唯一,不能可靠判断孔深方向。", } current_depth = max(abs(bottom_parameter - open_parameter), 1e-9) target_bottom_parameter = open_parameter + direction_sign * target_depth delta_depth = target_depth - current_depth depth_mode = "deepen" if delta_depth > 0 else "shallow" tool_direction = ( axis_dir.X() * direction_sign, axis_dir.Y() * direction_sign, axis_dir.Z() * direction_sign, ) open_margin = min(max(radius * 0.05, abs(delta_depth) * 0.2, 0.02), max(current_depth * 0.1, 0.2)) bottom_overlap = min(max(radius * 0.02, abs(delta_depth) * 0.05, 0.01), max(current_depth * 0.03, 0.08)) if depth_mode == "deepen": start_parameter = open_parameter - direction_sign * open_margin end_parameter = target_bottom_parameter tool_height = target_depth + open_margin tool_role = "cutter" tool_strategy = "bounded-blind-depth-cut" tool_radius = radius radius_overlap = 0.0 tool_note = "加深盲孔:沿识别出的开口到疑似底面方向,使用有限长度圆柱 cutter 延伸切削。" else: start_parameter = target_bottom_parameter end_parameter = bottom_parameter + direction_sign * bottom_overlap tool_height = current_depth - target_depth + bottom_overlap tool_role = "fill" tool_strategy = "bounded-bottom-fill" radius_overlap = min(max(radius * 0.001, 0.001), 0.05) tool_radius = radius + radius_overlap tool_note = "变浅盲孔:从目标新底面到旧底面方向补料,并让补料半径略有重叠以便和原实体合并。" return { "context_status": "ready", "depth_tool_strategy": tool_strategy, "depth_tool_role": tool_role, "depth_tool_note": tool_note, "depth_axis_direction": tool_direction, "depth_open_parameter": open_parameter, "depth_bottom_parameter": bottom_parameter, "depth_nominal_bottom_parameter": nominal_bottom_parameter, "depth_bottom_parameter_source": current_depth_source, "depth_current_depth": current_depth, "depth_current_depth_source": current_depth_source, "depth_open_direction_source": open_direction_source, "depth_target_bottom_parameter": target_bottom_parameter, "depth_tool_start_parameter": start_parameter, "depth_tool_end_parameter": end_parameter, "depth_tool_height": max(tool_height, 1e-6), "depth_tool_radius": tool_radius, "depth_tool_radius_overlap": radius_overlap, "depth_scope_face_ids": axis_range["same_domain_face_ids"], "depth_scope_face_count": axis_range["same_domain_face_count"], "depth_range_source": axis_range["range_source"], "depth_open_point": _point_tuple(_point_on_axis(axis_point, axis_dir, open_parameter)), "depth_current_bottom_point": _point_tuple(_point_on_axis(axis_point, axis_dir, bottom_parameter)), "depth_target_bottom_point": _point_tuple(_point_on_axis(axis_point, axis_dir, target_bottom_parameter)), "depth_tool_start_point": _point_tuple(_point_on_axis(axis_point, axis_dir, start_parameter)), "feature_bottom_face_ids": bottom_face_ids, "manual_bottom_face_id": manual_bottom_face_id if manual_bottom_face_used else "", "manual_bottom_face_used": manual_bottom_face_used, "manual_bottom_face_note": manual_bottom_face_note, } def _bounded_cylinder_cutter_plan( self, face_id: int, new_diameter: float, feature: dict[str, object] | None = None, ) -> dict[str, object]: face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: return { "cutter_strategy": "unavailable", "cutter_note": "选中Face不是圆柱面", } cyl = surf.Cylinder() old_radius = cyl.Radius() new_radius = new_diameter / 2.0 feature = feature or self.feature_info(face_id) axis_range = self._cylindrical_axis_range( face_id, surf, _int_values(feature.get("feature_side_face_ids")), ) v_min = float(axis_range["v_min"]) v_max = float(axis_range["v_max"]) span = max(v_max - v_min, 0.0) end_info = self._cylinder_end_opening_info(face_id, surf, axis_range) base_margin = min(max(new_radius * 0.05, abs(new_radius - old_radius) * 0.5, 0.02), max(span * 0.05, 0.2)) closed_margin = min(base_margin, max(span * 0.005, 0.02)) start_margin = base_margin if end_info["start_end_open"] else closed_margin end_margin = base_margin if end_info["end_end_open"] else closed_margin bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ())) opening_face_ids = tuple(feature.get("feature_opening_face_ids", ())) bottom_protection = bool(bottom_face_ids) if bottom_protection: bottom_note = "检测到疑似盲孔底面,封闭端 cutter 只保留很小余量,避免明显加深孔。" else: bottom_note = "未检测到明确疑似底面,按端部开口/封闭采样设置 cutter 余量。" start_parameter = v_min - start_margin end_parameter = v_max + end_margin height = max(end_parameter - start_parameter, 1e-6) axis = cyl.Axis() direction = axis.Direction() axis_point = axis.Location() start = gp_Pnt( axis_point.X() + direction.X() * start_parameter, axis_point.Y() + direction.Y() * start_parameter, axis_point.Z() + direction.Z() * start_parameter, ) return { "cutter_strategy": "bounded-to-selected-cylinder-v-range", "cutter_note": ( "有限长度切削:优先按同域圆柱侧壁整体 V 范围生成 cutter," "如果没有同域拆分则退回选中Face范围,减少贯穿整个特征的误切风险。" ), "cutter_scope_face_ids": axis_range["same_domain_face_ids"], "cutter_scope_face_count": axis_range["same_domain_face_count"], "cutter_range_source": axis_range["range_source"], "cutter_start_parameter": start_parameter, "cutter_end_parameter": end_parameter, "cutter_height": height, "cutter_margin": base_margin, "cutter_start_margin": start_margin, "cutter_end_margin": end_margin, "cutter_radius": new_radius, "cutter_axis_point": _point_tuple(axis_point), "cutter_axis_direction": _dir_tuple(direction), "cutter_start_point": _point_tuple(start), "cutter_bottom_protection": bottom_protection, "cutter_protected_bottom_face_ids": bottom_face_ids, "cutter_opening_face_ids": opening_face_ids, "cutter_bottom_note": bottom_note, **end_info, } def _bounded_cylinder_fill_plan(self, face_id: int) -> dict[str, object]: face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: return { "fill_strategy": "unavailable", "fill_note": "选中Face不是圆柱面", } cyl = surf.Cylinder() radius = cyl.Radius() axis_range = self._cylindrical_axis_range(face_id, surf) v_min = float(axis_range["v_min"]) v_max = float(axis_range["v_max"]) height = max(v_max - v_min, 1e-6) overlap = min(max(radius * 0.001, 0.001), 0.05) axis = cyl.Axis() direction = axis.Direction() axis_point = axis.Location() start = _point_on_axis(axis_point, direction, v_min) return { "fill_strategy": "bounded-fill-then-recut", "fill_note": ( "缩小孔径实验策略:先在同域圆柱侧壁范围内补料,再按目标直径重切。" "补料不向开口端外伸。" ), "fill_scope_face_ids": axis_range["same_domain_face_ids"], "fill_scope_face_count": axis_range["same_domain_face_count"], "fill_range_source": axis_range["range_source"], "fill_start_parameter": v_min, "fill_end_parameter": v_max, "fill_height": height, "fill_radius": radius + overlap, "fill_radius_overlap": overlap, "fill_start_point": _point_tuple(start), } def _bounded_boss_resize_tool_plan(self, face_id: int, new_diameter: float) -> dict[str, object]: face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: return { "boss_tool_strategy": "unavailable", "boss_tool_note": "选中Face不是圆柱面", } cyl = surf.Cylinder() old_radius = cyl.Radius() new_radius = new_diameter / 2.0 axis_range = self._cylindrical_axis_range(face_id, surf) end_info = self._cylinder_end_opening_info(face_id, surf, axis_range) v_min = float(axis_range["v_min"]) v_max = float(axis_range["v_max"]) span = max(v_max - v_min, 1e-6) resize_mode = _resize_mode(old_radius * 2.0, new_diameter) axial_margin = 0.0 start_parameter = v_min end_parameter = v_max radial_overlap = min(max(old_radius * 0.001, 0.001), 0.05) height = max(end_parameter - start_parameter, 1e-6) axis = cyl.Axis() direction = axis.Direction() axis_point = axis.Location() start = _point_on_axis(axis_point, direction, start_parameter) exact_start = _point_on_axis(axis_point, direction, v_min) return { "boss_tool_strategy": "bounded-cylinder-fuse" if resize_mode == "enlarge" else "remove-envelope-then-fuse-target-cylinder", "boss_tool_note": ( "扩大凸台会在同域圆柱侧壁整体 V 范围内生成目标半径圆柱并 Fuse;" "缩小凸台会先用旧半径包络体移除原凸台范围,再 Fuse 目标半径圆柱重建。" "补新凸台时使用原凸台轴向范围,避免直径或轴心修改时悄悄改变高度。" ), "boss_tool_scope_face_ids": axis_range["same_domain_face_ids"], "boss_tool_scope_face_count": axis_range["same_domain_face_count"], "boss_tool_range_source": axis_range["range_source"], "boss_tool_start_parameter": start_parameter, "boss_tool_end_parameter": end_parameter, "boss_tool_height": height, "boss_tool_axial_margin": axial_margin, "boss_tool_radius": new_radius, "boss_tool_old_radius": old_radius, "boss_tool_outer_radius": old_radius + radial_overlap if resize_mode == "shrink" else new_radius, "boss_tool_inner_radius": new_radius if resize_mode == "shrink" else "", "boss_tool_radial_overlap": radial_overlap if resize_mode == "shrink" else "", "boss_tool_axis_point": _point_tuple(axis_point), "boss_tool_axis_direction": _dir_tuple(direction), "boss_tool_start_point": _point_tuple(start), "boss_tool_exact_start_point": _point_tuple(exact_start), "boss_tool_exact_height": max(v_max - v_min, 1e-6), **end_info, } def _cylinder_end_opening_info( self, face_id: int, surf: BRepAdaptor_Surface, axis_range: dict[str, object] | None = None, ) -> dict[str, object]: solid_id = self.face_solid_ids[face_id] fallback = { "cylinder_end_type": "unknown", "hole_depth_estimate": abs(surf.LastVParameter() - surf.FirstVParameter()), "start_end_state": "unknown", "end_end_state": "unknown", "start_end_open": False, "end_end_open": False, "open_end_count": 0, "closed_end_count": 0, "end_sample_offset": "", "end_sample_note": "no owning solid was found", } if solid_id < 0 or solid_id >= len(self.solids): return fallback solid = self.solids[solid_id][1] cyl = surf.Cylinder() axis = cyl.Axis() axis_point = axis.Location() direction = axis.Direction() axis_range = axis_range or self._cylindrical_axis_range(face_id, surf) v_min = float(axis_range["v_min"]) v_max = float(axis_range["v_max"]) span = max(v_max - v_min, 0.0) radius = cyl.Radius() offset = min(max(radius * 0.08, span * 0.02, 0.05), max(span * 0.25, 0.2)) start_probe = _point_on_axis(axis_point, direction, v_min - offset) end_probe = _point_on_axis(axis_point, direction, v_max + offset) start_state = _solid_state(solid, start_probe) end_state = _solid_state(solid, end_probe) start_open = start_state == "outside" end_open = end_state == "outside" start_closed = start_state == "inside" end_closed = end_state == "inside" open_count = int(start_open) + int(end_open) closed_count = int(start_closed) + int(end_closed) if open_count == 2: end_type = "through/open-ended" note = "both axis-end probes are outside material" elif open_count == 1 and closed_count == 1: end_type = "blind" note = "one axis-end probe is outside material and the other is inside material" elif closed_count == 2: end_type = "closed/internal" note = "both axis-end probes are inside material" else: end_type = "unclear" note = "axis-end probes did not produce a clear open/closed pattern" return { "cylinder_end_type": end_type, "hole_depth_estimate": span, "start_end_state": start_state, "end_end_state": end_state, "start_end_open": start_open, "end_end_open": end_open, "open_end_count": open_count, "closed_end_count": closed_count, "end_sample_offset": offset, "end_sample_note": note, "end_sample_range_source": axis_range["range_source"], "end_sample_scope_face_ids": axis_range["same_domain_face_ids"], "end_sample_scope_face_count": axis_range["same_domain_face_count"], } def _classify_cylindrical_face( self, face_id: int, surf: BRepAdaptor_Surface, detailed: bool = False, ) -> dict[str, object]: solid_id = self.face_solid_ids[face_id] if solid_id < 0 or solid_id >= len(self.solids): return { "feature_guess": "cylindrical face", "toward_axis": "unknown", "away_axis": "unknown", "vote_summary": "hole=0, boss=0, unclear=0", "sample_count": 0, "confidence": "low", "note": "no owning solid was found", } solid = self.solids[solid_id][1] radius = surf.Cylinder().Radius() angular_span = abs(surf.LastUParameter() - surf.FirstUParameter()) boundary_edges = len(list(TopologyExplorer(self.faces[face_id], ignore_orientation=True).edges())) solid_diagonal = _shape_diagonal(solid) is_partial_cylinder = angular_span < math.tau * 0.92 is_small_radius = solid_diagonal > 0 and radius <= solid_diagonal * 0.04 is_fillet_radius = solid_diagonal > 0 and radius <= solid_diagonal * 0.12 is_quarter_roundish = 0.15 <= angular_span <= math.pi * 1.05 is_fillet_like_partial = ( is_partial_cylinder and is_quarter_roundish and is_fillet_radius and boundary_edges >= 4 ) samples = self._sample_cylinder_material_states(surf, solid, detailed=detailed) sample_count = len(samples) if sample_count == 0: return { "feature_guess": "cylindrical face", "toward_axis": "unknown", "away_axis": "unknown", "vote_summary": "hole=0, boss=0, unclear=0", "sample_count": 0, "confidence": "low", "note": "could not sample cylinder material sides", } toward_states = [sample["toward"] for sample in samples] away_states = [sample["away"] for sample in samples] hole_votes = sum(1 for sample in samples if sample["toward"] == "outside" and sample["away"] == "inside") boss_votes = sum(1 for sample in samples if sample["toward"] == "inside" and sample["away"] == "outside") unclear_votes = sample_count - hole_votes - boss_votes vote_summary = f"hole={hole_votes}, boss={boss_votes}, unclear={unclear_votes}" threshold = max(1, math.ceil(sample_count * 0.6)) base = { "toward_axis": _state_summary(toward_states), "away_axis": _state_summary(away_states), "vote_summary": vote_summary, "sample_count": sample_count, } if hole_votes >= threshold: confidence = "high" if hole_votes == sample_count and not is_partial_cylinder else "medium" return { "feature_guess": "hole/groove candidate", "confidence": confidence, "note": "axis side is mostly empty and outer side is mostly material", **base, } if is_partial_cylinder and (is_small_radius or is_fillet_like_partial): return { "feature_guess": "round/fillet candidate", "confidence": "medium" if boundary_edges >= 4 else "low", "note": "partial small-radius cylinder; may be a fillet or blend", **base, } if boss_votes >= threshold: return { "feature_guess": "boss/outer-round candidate", "confidence": "high" if boss_votes == sample_count and not is_partial_cylinder else "medium", "note": "axis side is mostly material and outer side is mostly empty", **base, } return { "feature_guess": "cylindrical face", "confidence": "low", "note": "material sampling did not produce a clear inside/outside pattern", **base, } def _sample_cylinder_material_states( self, surf: BRepAdaptor_Surface, solid: TopoDS_Shape, detailed: bool = False, ) -> list[dict[str, str]]: cyl = surf.Cylinder() axis = cyl.Axis() axis_point = axis.Location() axis_dir = axis.Direction() radius = cyl.Radius() u_first = surf.FirstUParameter() u_last = surf.LastUParameter() v = (surf.FirstVParameter() + surf.LastVParameter()) / 2.0 u_span = u_last - u_first fractions = [0.5] if detailed and abs(u_span) > 0.2: fractions = [0.25, 0.5, 0.75] samples: list[dict[str, str]] = [] for fraction in fractions: u = u_first + u_span * fraction point = surf.Value(u, v) axis_to_point = _vec_from_points(axis_point, point) projection = _dot(axis_to_point, axis_dir) center = gp_Pnt( axis_point.X() + axis_dir.X() * projection, axis_point.Y() + axis_dir.Y() * projection, axis_point.Z() + axis_dir.Z() * projection, ) radial = _vec_from_points(center, point) radial_len = radial.Magnitude() if radial_len <= 1e-9: continue unit = gp_Vec(radial.X() / radial_len, radial.Y() / radial_len, radial.Z() / radial_len) epsilon = min(max(radius * 0.03, 0.05), 1.0) toward_point = gp_Pnt( point.X() - unit.X() * epsilon, point.Y() - unit.Y() * epsilon, point.Z() - unit.Z() * epsilon, ) away_point = gp_Pnt( point.X() + unit.X() * epsilon, point.Y() + unit.Y() * epsilon, point.Z() + unit.Z() * epsilon, ) samples.append( { "toward": _solid_state(solid, toward_point), "away": _solid_state(solid, away_point), } ) return samples def _plane_push_pull_direction(self, face_id: int, surf: BRepAdaptor_Surface) -> dict[str, object]: face = self.faces[face_id] direction = surf.Plane().Axis().Direction() axis_tuple = _dir_tuple(direction) oriented_tuple = _oriented_dir_tuple(direction, face) fallback = { "outward_direction": oriented_tuple, "inward_direction": _neg_tuple(oriented_tuple), "plus_side_state": "unknown", "minus_side_state": "unknown", "confidence": "low", "note": "falling back to topology-oriented plane normal", } solid_id = self.face_solid_ids[face_id] if solid_id < 0 or solid_id >= len(self.solids): fallback["note"] = "no owning solid was found; using topology-oriented plane normal" return fallback solid = self.solids[solid_id][1] props = GProp_GProps() brepgprop.SurfaceProperties(face, props) sample = props.CentreOfMass() diagonal = _shape_diagonal(solid) epsilon = min(max(diagonal * 1e-4, 0.05), 1.0) plus_point = gp_Pnt( sample.X() + direction.X() * epsilon, sample.Y() + direction.Y() * epsilon, sample.Z() + direction.Z() * epsilon, ) minus_point = gp_Pnt( sample.X() - direction.X() * epsilon, sample.Y() - direction.Y() * epsilon, sample.Z() - direction.Z() * epsilon, ) plus_state = _solid_state(solid, plus_point) minus_state = _solid_state(solid, minus_point) if plus_state == "outside" and minus_state == "inside": return { "outward_direction": axis_tuple, "inward_direction": _neg_tuple(axis_tuple), "plus_side_state": plus_state, "minus_side_state": minus_state, "confidence": "high", "note": "positive plane normal side is outside material", } if plus_state == "inside" and minus_state == "outside": return { "outward_direction": _neg_tuple(axis_tuple), "inward_direction": axis_tuple, "plus_side_state": plus_state, "minus_side_state": minus_state, "confidence": "high", "note": "negative plane normal side is outside material", } fallback["plus_side_state"] = plus_state fallback["minus_side_state"] = minus_state fallback["note"] = "inside/outside sampling was unclear; using topology-oriented plane normal" return fallback