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_MakeEdge, BRepBuilderAPI_GTransform, BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeWire, BRepBuilderAPI_MakeSolid, BRepBuilderAPI_Sewing, BRepBuilderAPI_Transform, ) from OCC.Core.BRepCheck import BRepCheck_Analyzer from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet from OCC.Core.BRepGProp import brepgprop from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCone, 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.GC import GC_MakeArcOfCircle from OCC.Core.GeomAPI import GeomAPI_PointsToBSplineSurface from OCC.Core.ShapeFix import ShapeFix_Shape from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain from OCC.Core.TColgp import TColgp_Array2OfPnt from OCC.Core.TopAbs import ( TopAbs_EDGE, TopAbs_EXTERNAL, TopAbs_FACE, TopAbs_FORWARD, TopAbs_IN, TopAbs_INTERNAL, TopAbs_OUT, TopAbs_REVERSED, TopAbs_SHELL, TopAbs_SOLID, TopAbs_VERTEX, TopAbs_WIRE, ) from OCC.Core.TopExp import TopExp_Explorer, topexp from OCC.Core.TopLoc import TopLoc_Location from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_IndexedMapOfShape from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_GTrsf, gp_Pnt, gp_Trsf, gp_Vec, gp_XYZ from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES from .geometry_utils import * # noqa: F403 from .step_io import _prepare_shape_for_step_export def _int_or_none(value: object) -> int | None: if value in {"", None}: return None try: return int(value) except (TypeError, ValueError): return None def _angle_degrees_or_none(value: object) -> float | None: radians = _float_or_none(value) if radians is None: return None return abs(math.degrees(radians)) def _effective_cylinder_angular_span(info: dict[str, object]) -> float | None: if bool(info.get("is_full_cylinder")): return math.tau values: list[float] = [] for key in ("same_domain_angular_span", "angular_span"): value = _float_or_none(info.get(key)) if value is not None: values.append(value) return max(values) if values else None def _is_effectively_full_cylinder(info: dict[str, object]) -> bool: angular_span = _effective_cylinder_angular_span(info) return angular_span is not None and angular_span >= math.tau * 0.92 def _result_value_error(actual: object, target: object) -> float: if isinstance(actual, tuple) and isinstance(target, tuple): if len(actual) != len(target): return math.inf return max(abs(float(actual[index]) - float(target[index])) for index in range(len(actual))) try: return abs(float(actual) - float(target)) except (TypeError, ValueError): return math.inf def _format_result_number(value: object) -> str: try: number = float(value) except (TypeError, ValueError): return str(value) if not math.isfinite(number): return str(number) return f"{number:g}" class OperationMixin: def _face_first_level_plan_fields(self, face_id: int) -> dict[str, object]: boundary_info: dict[str, object] = {} try: source_info = self.quick_face_info(face_id) boundary_info = { "selected_boundary_wires": source_info.get("boundary_wires", 0), "selected_inner_boundary_wires": source_info.get("inner_boundary_wires", 0), "selected_has_inner_boundaries": bool(source_info.get("has_inner_boundaries")), } except Exception: boundary_info = { "selected_boundary_wires": 0, "selected_inner_boundary_wires": 0, "selected_has_inner_boundaries": False, } fact_fields = self._first_level_fact_plan_fields(face_id, "face") try: topology = self.face_first_level_topology(face_id) except Exception as exc: return { "topology_relation_depth": 1, "topology_relation_model": "STEP/B-Rep shared-edge first-level", "topology_relation_status": "unavailable", "topology_relation_message": str(exc), "first_level_adjacent_face_ids": (), "first_level_adjacent_face_count": 0, "first_level_boundary_edge_ids": (), "first_level_boundary_edge_count": 0, "first_level_boundary_vertex_count": 0, "same_domain_face_ids": (face_id,), "same_domain_face_count": 1, **boundary_info, **fact_fields, } fields = { "topology_relation_depth": topology.get("topology_relation_depth", 1), "topology_relation_model": topology.get("topology_relation_model"), "topology_relation_scope": topology.get("topology_relation_scope"), "topology_relation_boundary": topology.get("topology_relation_boundary"), "topology_relation_status": "ready", "topology_ignored_relation_depths": topology.get("topology_ignored_relation_depths", ()), "topology_ignored_relation_note": topology.get("topology_ignored_relation_note", ""), "same_domain_face_ids": topology.get("same_domain_face_ids", (face_id,)), "same_domain_face_count": topology.get("same_domain_face_count", 1), "same_domain_region_kind": topology.get("same_domain_region_kind", "single-face"), "selected_boundary_edge_ids": topology.get("selected_boundary_edge_ids", ()), "selected_boundary_edge_count": topology.get("selected_boundary_edge_count", 0), "first_level_boundary_edge_ids": topology.get("first_level_boundary_edge_ids", ()), "first_level_boundary_edge_count": topology.get("first_level_boundary_edge_count", 0), "first_level_boundary_vertex_count": topology.get("first_level_boundary_vertex_count", 0), "first_level_adjacent_face_ids": topology.get("first_level_adjacent_face_ids", ()), "first_level_adjacent_face_count": topology.get("first_level_adjacent_face_count", 0), "first_level_adjacent_surface_types": topology.get("first_level_adjacent_surface_types", ()), "first_level_shared_edges_by_face": topology.get("first_level_shared_edges_by_face", ()), "first_level_face_ids": topology.get("first_level_face_ids", ()), "first_level_face_count": topology.get("first_level_face_count", 0), "first_level_topology_note": topology.get("first_level_topology_note", ""), **boundary_info, } fields.update(fact_fields) fields["first_level_edit_semantics"] = ( "当前 Face 阶段只使用一级共享边拓扑:当前同域 Face 区域会作为编辑对象," "直接相邻 Face 会跟随重建或作为拉伸/切除侧壁参与结果校验;二级/三级关系暂不递归传播。" ) return fields def _face_first_level_plan_blockers( self, fields: dict[str, object], *, operation_label: str, require_adjacent: bool = True, ) -> list[str]: blockers: list[str] = [] topology_status = str(fields.get("topology_relation_status") or "") fact_status = str(fields.get("first_level_fact_status") or "") fact_scope = str(fields.get("first_level_fact_scope") or "") fact_boundary = str(fields.get("first_level_fact_relation_boundary") or "") try: fact_depth = int(fields.get("first_level_fact_relation_depth", 0) or 0) except (TypeError, ValueError): fact_depth = 0 if topology_status != "ready": message = str(fields.get("topology_relation_message") or "").strip() blockers.append( f"{operation_label}需要先确认当前 Face 的一级共享边拓扑;当前拓扑关系不可用。" + (f" 原因:{message}" if message else "") ) if fact_status != "ready" or fact_scope != "face" or fact_depth != 1 or fact_boundary != "shared-edge": blockers.append( f"{operation_label}需要当前 Face 的一级事实图处于 ready 状态;" f"当前 status={fact_status or 'unknown'}, scope={fact_scope or 'unknown'}, " f"depth={fact_depth}, boundary={fact_boundary or 'unknown'}。" ) boundary_edges = int(fields.get("first_level_fact_boundary_edge_count", 0) or 0) boundary_vertices = int(fields.get("first_level_fact_boundary_vertex_count", 0) or 0) adjacent_faces = int(fields.get("first_level_fact_adjacent_face_count", 0) or 0) subject_faces = int(fields.get("first_level_fact_subject_face_count", 0) or 0) included_faces = int(fields.get("first_level_fact_included_face_count", 0) or 0) if subject_faces <= 0 or included_faces < subject_faces: blockers.append( f"{operation_label}没有拿到稳定的当前 Face 主体区域,不能判断哪些面要作为同一局部面处理。" ) if boundary_edges < 3 or boundary_vertices < 3: blockers.append( f"{operation_label}需要至少 3 条边界 Edge 和 3 个边界 Vertex;" f"当前 Edge={boundary_edges}, Vertex={boundary_vertices}。" ) if require_adjacent and adjacent_faces <= 0: blockers.append(f"{operation_label}没有识别到共享边的一级相邻 Face,不能稳定重建周边面。") ignored_depths = tuple(str(item) for item in fields.get("first_level_fact_ignored_relation_depths", ()) or ()) if "second-level" not in ignored_depths or "third-level" not in ignored_depths: blockers.append(f"{operation_label}没有明确记录二级/三级关系暂不传播,当前计划不够明确。") role_groups = tuple(fields.get("first_level_fact_role_groups") or ()) if not any(isinstance(item, dict) and item.get("role") == "selected-same-domain-region" for item in role_groups): blockers.append(f"{operation_label}缺少当前同域 Face 区域角色,不能确定局部编辑主体。") if require_adjacent and not any(isinstance(item, dict) and item.get("role") == "direct-adjacent" for item in role_groups): blockers.append(f"{operation_label}缺少直接相邻 Face 角色,不能确定一级联动范围。") return blockers def repair_model(self) -> str: before_stats = self.stats() repaired_parts = 0 skipped_parts = 0 for part in self.display_parts(): if part.shape.IsNull(): skipped_parts += 1 continue repaired = _prepare_shape_for_step_export(part.shape) if repaired.IsNull(): skipped_parts += 1 continue part.shape = repaired repaired_parts += 1 if repaired_parts == 0: raise RuntimeError("No valid part shape was available for repair.") self.refresh_topology() after_stats = self.stats() return ( "Model repair completed: " f"parts repaired={repaired_parts}, skipped={skipped_parts}, " f"solids {before_stats.solids}->{after_stats.solids}, " f"faces {before_stats.faces}->{after_stats.faces}, " f"edges {before_stats.edges}->{after_stats.edges}." ) def repair_part(self, part_id: int) -> str: part = self.part_by_id(part_id) if part is None: raise ValueError(f"未知零件 ID {part_id}") if part.shape.IsNull(): raise RuntimeError(f"零件 {part_id} 为空 shape,无法修复。") before_stats = self.part_topology_stats(part_id) repaired = _prepare_shape_for_step_export(part.shape) if repaired.IsNull(): raise RuntimeError(f"零件 {part_id} 修复结果为空 shape。") part.shape = repaired self.refresh_topology() after_stats = self.part_topology_stats(part_id) return ( f"零件修复完成: 零件 {part_id}, " f"solids {before_stats.solids}->{after_stats.solids}, " f"faces {before_stats.faces}->{after_stats.faces}, " f"edges {before_stats.edges}->{after_stats.edges}." ) def repair_solid(self, solid_id: int) -> str: if solid_id < 0 or solid_id >= len(self.solids): raise ValueError(f"Unknown solid id {solid_id}") part_id, solid = self.solids[solid_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") before_part_stats = self.part_topology_stats(part_id) repaired = _prepare_shape_for_step_export(solid) if repaired.IsNull(): raise RuntimeError(f"Solid {solid_id} repair returned a null shape.") part_solids = _explore(part.shape, TopAbs_SOLID) if len(part_solids) <= 1: part.shape = repaired else: replaced = False shapes: list[TopoDS_Shape] = [] for item in part_solids: if not replaced and _same_shape(item, solid): shapes.append(repaired) replaced = True else: shapes.append(item) if not replaced: raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.") part.shape = _compound_from_shapes(shapes) _ensure_valid_shape(part.shape) self.refresh_topology() after_part_stats = self.part_topology_stats(part_id) return ( f"Solid repair completed: solid {solid_id}, part {part_id}, " f"part solids {before_part_stats.solids}->{after_part_stats.solids}, " f"faces {before_part_stats.faces}->{after_part_stats.faces}, " f"edges {before_part_stats.edges}->{after_part_stats.edges}." ) def _edge_first_level_plan_fields(self, edge_id: int) -> dict[str, object]: try: return self.edge_first_level_facts(edge_id) except Exception as exc: return { "first_level_fact_model": "STEP/B-Rep first-level fact graph", "first_level_fact_source_model": "edge", "first_level_fact_status": "unavailable", "first_level_fact_relation_depth": 1, "first_level_fact_relation_boundary": "shared-vertex/shared-face", "first_level_fact_scope": "edge", "first_level_fact_subject_role": "selected Edge", "first_level_fact_subject_edge_ids": (edge_id,), "first_level_fact_subject_edge_count": 1, "first_level_fact_boundary_edge_count": 1, "first_level_fact_boundary_vertex_count": 0, "first_level_fact_adjacent_edge_count": 0, "first_level_fact_adjacent_face_count": 0, "first_level_fact_ignored_relation_depths": ("second-level", "third-level", "deeper"), "first_level_fact_summary": f"Edge first-level facts are temporarily unavailable: {exc}", } def edge_fillet_plan(self, edge_id: int, radius: float) -> dict[str, object]: if edge_id < 0 or edge_id >= len(self.edges): raise ValueError(f"Unknown edge id {edge_id}") info = self.edge_info(edge_id) readiness = _edge_fillet_readiness(info, radius) part_id = int(info["part_id"]) part_stats = None try: part_stats = self.part_topology_stats(part_id) except Exception: part_stats = None if part_stats is not None and part_stats.solids != 1: readiness = dict(readiness) if readiness["fillet_status"] != "blocked": readiness["fillet_status"] = "caution" readiness["fillet_risk"] = _max_risk(str(readiness["fillet_risk"]), "high") readiness["fillet_warnings"] = _join_nonempty( readiness["fillet_warnings"], f"当前零件包含 {part_stats.solids} 个Solid,Edge倒圆会作用在整个零件 shape 上,请导出前检查结果。", ) readiness["fillet_note"] = _join_nonempty(readiness["fillet_note"], readiness["fillet_warnings"]) length = float(info.get("length", 0.0)) radius_to_length_ratio = radius / max(length, 1e-9) plan = { "status": readiness["fillet_status"], "risk": readiness["fillet_risk"], "message": readiness["fillet_note"], "warnings": readiness["fillet_warnings"], "blockers": readiness["fillet_blockers"], "edge_id": edge_id, "part_id": info["part_id"], "solid_id": info.get("solid_id", -1), "curve": info.get("curve"), "edge_length": length, "target_radius": radius, "radius_to_length_ratio": radius_to_length_ratio, "adjacent_face_ids": info.get("adjacent_face_ids", ()), "adjacent_face_count": info.get("adjacent_face_count", 0), "start_point": info.get("start_point"), "end_point": info.get("end_point"), "direction": info.get("direction"), "resize_strategy": "add-edge-fillet", "edit_strategy_label": "给Edge添加新圆角", "edit_semantics": ( "在当前直线 Edge 及其相邻 Face 上调用 OCCT 倒圆;会替换这条边附近的局部拓扑," "不是修改已有圆角面。" ), } plan.update(self._edge_first_level_plan_fields(edge_id)) return plan def edge_chamfer_plan(self, edge_id: int, distance: float) -> dict[str, object]: if edge_id < 0 or edge_id >= len(self.edges): raise ValueError(f"Unknown edge id {edge_id}") info = self.edge_info(edge_id) readiness = _edge_chamfer_readiness(info, distance) part_id = int(info["part_id"]) part_stats = None try: part_stats = self.part_topology_stats(part_id) except Exception: part_stats = None if part_stats is not None and part_stats.solids != 1: readiness = dict(readiness) if readiness["chamfer_status"] != "blocked": readiness["chamfer_status"] = "caution" readiness["chamfer_risk"] = _max_risk(str(readiness["chamfer_risk"]), "high") readiness["chamfer_warnings"] = _join_nonempty( readiness["chamfer_warnings"], f"当前零件包含 {part_stats.solids} 个Solid,Edge倒角会作用在整个零件 shape 上,请导出前检查结果。", ) readiness["chamfer_note"] = _join_nonempty(readiness["chamfer_note"], readiness["chamfer_warnings"]) length = float(info.get("length", 0.0)) distance_to_length_ratio = distance / max(length, 1e-9) plan = { "status": readiness["chamfer_status"], "risk": readiness["chamfer_risk"], "message": readiness["chamfer_note"], "warnings": readiness["chamfer_warnings"], "blockers": readiness["chamfer_blockers"], "edge_id": edge_id, "part_id": info["part_id"], "solid_id": info.get("solid_id", -1), "curve": info.get("curve"), "edge_length": length, "target_distance": distance, "distance_to_length_ratio": distance_to_length_ratio, "adjacent_face_ids": info.get("adjacent_face_ids", ()), "adjacent_face_count": info.get("adjacent_face_count", 0), "start_point": info.get("start_point"), "end_point": info.get("end_point"), "direction": info.get("direction"), "resize_strategy": "add-edge-symmetric-chamfer", "edit_strategy_label": "给Edge添加对称倒角", "edit_semantics": ( "在当前直线 Edge 及其相邻 Face 上调用 OCCT 对称倒角;会替换这条边附近的局部拓扑。" ), } plan.update(self._edge_first_level_plan_fields(edge_id)) return plan def edge_asymmetric_chamfer_plan( self, edge_id: int, distance1: float, distance2: float, reference_face_id: int | None = None, ) -> dict[str, object]: distance1 = float(distance1) distance2 = float(distance2) max_distance = max(distance1, distance2) plan = self.edge_chamfer_plan(edge_id, max_distance) info = self.edge_info(edge_id) length = float(info.get("length", 0.0)) adjacent_face_ids = _int_values(info.get("adjacent_face_ids")) blockers = [str(plan.get("blockers", ""))] if str(plan.get("blockers", "")).strip() else [] warnings = [str(plan.get("warnings", ""))] if str(plan.get("warnings", "")).strip() else [] risk = str(plan.get("risk", "medium")) if distance1 <= 0 or distance2 <= 0: blockers.append("Asymmetric chamfer distances D1 and D2 must both be greater than 0.") if length <= 1e-9: blockers.append("Current Edge length is invalid.") if len(adjacent_face_ids) < 2: blockers.append("Asymmetric chamfer needs at least two adjacent Faces on the selected Edge.") resolved_reference_face_id: int | None if reference_face_id is None: resolved_reference_face_id = adjacent_face_ids[0] if adjacent_face_ids else None else: try: resolved_reference_face_id = int(reference_face_id) except (TypeError, ValueError): resolved_reference_face_id = None blockers.append("Reference Face ID must be an integer.") if resolved_reference_face_id is None: blockers.append("Could not resolve an adjacent reference Face for asymmetric chamfer.") elif resolved_reference_face_id not in adjacent_face_ids: blockers.append( f"Reference Face {resolved_reference_face_id} is not adjacent to Edge {edge_id}; " f"available adjacent Faces: {tuple(adjacent_face_ids)}." ) elif resolved_reference_face_id < 0 or resolved_reference_face_id >= len(self.faces): blockers.append(f"Reference Face {resolved_reference_face_id} does not exist.") ratio1 = distance1 / max(length, 1e-9) ratio2 = distance2 / max(length, 1e-9) if length > 1e-9: if max(ratio1, ratio2) >= 0.45: blockers.append("D1 or D2 is close to half of the Edge length; asymmetric chamfer is blocked.") elif max(ratio1, ratio2) > 0.25: risk = _max_risk(risk, "high") warnings.append("D1 or D2 is larger than 25% of the Edge length; OCCT chamfer failure is more likely.") elif max(ratio1, ratio2) > 0.12: risk = _max_risk(risk, "medium") warnings.append("D1 or D2 is relatively large compared with the Edge length.") if abs(distance1 - distance2) <= max(max_distance * 1e-6, 1e-7): warnings.append("D1 and D2 are almost equal; the result will be close to a symmetric chamfer.") if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers) elif risk != "low": status = "caution" message = " ".join(warnings) if warnings else "Asymmetric chamfer can be attempted, but it depends on OCCT." else: status = "ready" message = "Asymmetric chamfer can be attempted on this straight Edge." plan.update( { "status": status, "risk": risk, "message": message, "warnings": "; ".join(warnings), "blockers": "; ".join(blockers), "chamfer_mode": "asymmetric-distances", "target_distance": max_distance, "target_distance1": distance1, "target_distance2": distance2, "distance1_to_length_ratio": ratio1, "distance2_to_length_ratio": ratio2, "reference_face_id": resolved_reference_face_id, "reference_face_candidates": tuple(adjacent_face_ids), "resize_strategy": "add-edge-asymmetric-chamfer", "edit_strategy_label": "给Edge添加不等距倒角", "edit_semantics": ( "按 D1/D2 两个距离在当前 Edge 两侧生成不等距倒角;参考 Face 决定 D1/D2 的方向。" ), } ) return plan def edge_distance_angle_chamfer_plan( self, edge_id: int, distance: float, angle_degrees: float, reference_face_id: int | None = None, ) -> dict[str, object]: distance = float(distance) angle_degrees = float(angle_degrees) angle_radians = math.radians(angle_degrees) plan = self.edge_chamfer_plan(edge_id, distance) info = self.edge_info(edge_id) length = float(info.get("length", 0.0)) adjacent_face_ids = _int_values(info.get("adjacent_face_ids")) blockers = [str(plan.get("blockers", ""))] if str(plan.get("blockers", "")).strip() else [] warnings = [str(plan.get("warnings", ""))] if str(plan.get("warnings", "")).strip() else [] risk = str(plan.get("risk", "medium")) if distance <= 0: blockers.append("Distance-angle chamfer distance must be greater than 0.") if angle_degrees <= 0 or angle_degrees >= 89.0: blockers.append("Distance-angle chamfer angle must be greater than 0 and less than 89 degrees.") elif angle_degrees < 10.0 or angle_degrees > 80.0: risk = _max_risk(risk, "high") warnings.append("Chamfer angle is near an extreme value; OCCT failure is more likely.") elif angle_degrees < 20.0 or angle_degrees > 70.0: risk = _max_risk(risk, "medium") warnings.append("Chamfer angle is relatively steep; please check the result carefully.") if length <= 1e-9: blockers.append("Current Edge length is invalid.") if len(adjacent_face_ids) < 2: blockers.append("Distance-angle chamfer needs at least two adjacent Faces on the selected Edge.") resolved_reference_face_id: int | None if reference_face_id is None: resolved_reference_face_id = adjacent_face_ids[0] if adjacent_face_ids else None else: try: resolved_reference_face_id = int(reference_face_id) except (TypeError, ValueError): resolved_reference_face_id = None blockers.append("Reference Face ID must be an integer.") if resolved_reference_face_id is None: blockers.append("Could not resolve an adjacent reference Face for distance-angle chamfer.") elif resolved_reference_face_id not in adjacent_face_ids: blockers.append( f"Reference Face {resolved_reference_face_id} is not adjacent to Edge {edge_id}; " f"available adjacent Faces: {tuple(adjacent_face_ids)}." ) elif resolved_reference_face_id < 0 or resolved_reference_face_id >= len(self.faces): blockers.append(f"Reference Face {resolved_reference_face_id} does not exist.") distance_ratio = distance / max(length, 1e-9) if length > 1e-9: if distance_ratio >= 0.45: blockers.append("Chamfer distance is close to half of the Edge length; distance-angle chamfer is blocked.") elif distance_ratio > 0.25: risk = _max_risk(risk, "high") warnings.append("Chamfer distance is larger than 25% of the Edge length.") elif distance_ratio > 0.12: risk = _max_risk(risk, "medium") warnings.append("Chamfer distance is relatively large compared with the Edge length.") if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers) elif risk != "low": status = "caution" message = " ".join(warnings) if warnings else "Distance-angle chamfer can be attempted, but it depends on OCCT." else: status = "ready" message = "Distance-angle chamfer can be attempted on this straight Edge." plan.update( { "status": status, "risk": risk, "message": message, "warnings": "; ".join(warnings), "blockers": "; ".join(blockers), "chamfer_mode": "distance-angle", "target_distance": distance, "target_angle_degrees": angle_degrees, "target_angle_radians": angle_radians, "distance_to_length_ratio": distance_ratio, "reference_face_id": resolved_reference_face_id, "reference_face_candidates": tuple(adjacent_face_ids), "resize_strategy": "add-edge-distance-angle-chamfer", "edit_strategy_label": "给Edge添加距离+角度倒角", "edit_semantics": ( "按距离 D 和角度在当前 Edge 上生成倒角;参考 Face 决定距离和角度的方向。" ), } ) return plan def general_edge_length_plan( self, edge_id: int, target_length: float, anchor_mode: str = "auto", strategy_mode: str = "auto", ) -> dict[str, object]: if edge_id < 0 or edge_id >= len(self.edges): raise ValueError(f"Unknown edge id {edge_id}") info = self.edge_info(edge_id) current_length = float(info.get("length", 0.0)) target_length = float(target_length) delta_length = target_length - current_length curve = str(info.get("curve", "")) anchor_mode = self._edge_length_anchor_mode(anchor_mode) strategy_mode = self._edge_length_strategy_mode(strategy_mode) force_local = strategy_mode == "local-edge-only-deform" force_end_face = strategy_mode == "move-edge-end-plane-by-push-pull" force_cylinder = strategy_mode == "resize-adjacent-cylinder-from-circular-edge-length" base: dict[str, object] = { "edge_id": edge_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id", -1), "curve": curve, "edge_length_anchor_mode": anchor_mode, "edge_length_anchor_label": self._edge_length_anchor_label(anchor_mode), "edge_length_strategy_mode": strategy_mode, "edge_length_strategy_label": self._edge_length_strategy_label(strategy_mode), "current_length": current_length, "target_length": target_length, "delta_length": delta_length, "length_change_ratio": abs(delta_length) / max(current_length, 1e-9), "start_point": info.get("start_point"), "end_point": info.get("end_point"), "length_center": info.get("length_center"), } base.update(self._edge_first_level_plan_fields(edge_id)) warnings: list[str] = [ "Edge长度修改基于当前 STEP/B-Rep 结果几何,不是 CAD 建模历史里的参数编辑。" ] blockers: list[str] = [] risk = "low" status = "ready" if current_length <= 1e-9: blockers.append("当前Edge长度无效。") if target_length <= 1e-9: blockers.append("Edge目标长度必须大于 0。") if abs(delta_length) <= max(current_length * 1e-7, 1e-7): blockers.append("Edge目标长度与当前Edge长度几乎相同,不需要修改。") if not blockers: ratio = abs(delta_length) / max(current_length, 1e-9) if ratio > 0.5: risk = _max_risk(risk, "high") warnings.append("长度变化超过当前Edge长度的 50%,形状异常或修复失败的概率较高。") elif ratio > 0.25: risk = _max_risk(risk, "medium") warnings.append("长度变化超过当前Edge长度的 25%,请确认预览范围。") if not blockers and force_local and curve != "line": blockers.append("“只改当前Edge”策略当前只支持直线Edge。") if not blockers and force_end_face and curve != "line": blockers.append("“移动端面/整体尺寸”策略当前只支持直线Edge。") if not blockers and force_cylinder and curve != "circle": blockers.append("“相邻圆柱直径”策略当前只支持圆形/圆弧Edge。") if not blockers and force_end_face and anchor_mode == "center": blockers.append("“移动端面/整体尺寸”需要固定起点、固定终点或自动基准,不能使用固定中心。") if not blockers and curve == "line" and strategy_mode in {"auto", "local-edge-only-deform"}: local_candidate, local_skip_note = self._local_edge_length_deform_candidate( info, target_length, anchor_mode=anchor_mode, ) if local_candidate is not None: risk = _max_risk(risk, str(local_candidate.get("local_edge_deform_risk", "medium"))) status = "caution" if risk != "low" else status warnings.append( "将优先只移动当前 Edge 的端点并重建相邻平面;四点非共面面会优先重建为连续曲面。" ) base.update(local_candidate) elif local_skip_note: if force_local: blockers.append(local_skip_note) else: warnings.append(local_skip_note) if not blockers and "resize_strategy" not in base and curve == "line" and anchor_mode != "center" and strategy_mode in {"auto", "move-edge-end-plane-by-push-pull"}: candidate = self._straight_edge_length_end_face_candidate(info, delta_length, anchor_mode=anchor_mode) if candidate is not None: push_plan = self.push_pull_plan(int(candidate["end_face_id"]), float(candidate["push_pull_distance"])) if push_plan["status"] != "blocked": risk = _max_risk(risk, str(push_plan["risk"])) push_warnings = str(push_plan.get("warnings", "")) if push_warnings: warnings.append(push_warnings) base.update(candidate) base.update( { "resize_strategy": "move-edge-end-plane-by-push-pull", "push_pull_status": push_plan.get("status"), "push_pull_risk": push_plan.get("risk"), "push_pull_message": push_plan.get("message"), "push_pull_scope_face_ids": push_plan.get("push_pull_scope_face_ids", ()), "push_pull_scope_face_count": push_plan.get("push_pull_scope_face_count", 1), "push_pull_scope_note": push_plan.get("push_pull_scope_note", ""), } ) else: message = f"端面拉伸/切除路径不可用:{push_plan['message']}" if force_end_face: blockers.append(message) else: warnings.append(f"{message} 将尝试通用仿射缩放。") elif anchor_mode in {"keep-start", "keep-end"}: message = f"未找到可用于{self._edge_length_anchor_label(anchor_mode)}的端面拉伸/切除路径。" if force_end_face: blockers.append(message) else: warnings.append(f"{message} 将尝试按该基准缩放所属对象。") elif force_end_face: blockers.append("未找到可用于当前Edge的端面拉伸/切除路径。") elif not blockers and curve == "line" and anchor_mode == "center" and strategy_mode in {"auto", "scale-owning-shape-from-edge"}: warnings.append("Edge长度基准为固定中心;将使用轴向仿射缩放,让Edge中心尽量保持不动。") if not blockers and "resize_strategy" not in base and curve == "circle" and strategy_mode in {"auto", "resize-adjacent-cylinder-from-circular-edge-length"}: cylinder_candidate, cylinder_notes = self._circular_edge_length_cylinder_candidate(info, target_length) if cylinder_candidate is not None: risk = _max_risk(risk, str(cylinder_candidate["cylinder_resize_risk"])) status = "caution" if risk != "low" else status warnings.append("识别到相邻圆柱面;将优先把Edge目标长度换算成圆柱直径做局部编辑。") cylinder_warnings = str(cylinder_candidate.get("cylinder_resize_warnings", "")) if cylinder_warnings: warnings.append(cylinder_warnings) base.update(cylinder_candidate) elif cylinder_notes: if force_cylinder: blockers.append("未找到可复用的相邻圆柱直径编辑路径:" + " ".join(cylinder_notes[:3])) else: warnings.extend(cylinder_notes[:3]) if not blockers and "resize_strategy" not in base and curve != "line" and strategy_mode in {"auto", "scale-owning-shape-from-edge"}: planar_candidate, planar_note = self._planar_edge_length_scale_candidate(info, target_length) if planar_candidate is not None: risk = _max_risk(risk, str(planar_candidate.get("affine_transform_risk", "medium"))) status = "caution" warnings.append(str(planar_candidate.get("affine_transform_warning", ""))) note = str(planar_candidate.get("affine_transform_note", "")) if note: warnings.append(note) base.update(planar_candidate) elif planar_note: warnings.append(planar_note) if not blockers and "resize_strategy" not in base and strategy_mode in {"auto", "scale-owning-shape-from-edge"}: axis = self._edge_length_affine_axis(info, anchor_mode=anchor_mode) if axis is None: blockers.append("无法为当前 Edge 推断可靠的缩放方向。") else: part_id = int(info.get("part_id", -1)) solid_id = int(info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0 target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" scale = target_length / max(current_length, 1e-9) transform_kind = "axis-affine" if curve == "line" else "uniform" transform_label = "沿Edge方向仿射缩放" if transform_kind == "axis-affine" else "以Edge中心整体缩放" risk = _max_risk(risk, "medium") if curve != "line" or abs(scale - 1.0) > 0.25: risk = _max_risk(risk, "high") status = "caution" if transform_kind == "axis-affine": warnings.append( "未找到可拉伸/切除端面;将沿该Edge的几何方向对所属 " f"{target_kind} 做仿射缩放。该 fallback 会影响同一 {target_kind} 上的其他尺寸。" ) else: warnings.append( "当前 Edge 不是直线;将以 Edge 中心为基准对所属 " f"{target_kind} 做均匀缩放。该 fallback 会影响同一 {target_kind} 上的其他尺寸。" ) if curve != "line": warnings.append("非直线Edge的目标长度通过整体比例缩放实现,执行后请复查周边尺寸。") base.update( { "resize_strategy": "scale-owning-shape-from-edge", "affine_scale": scale, "affine_transform_kind": transform_kind, "affine_transform_label": transform_label, "affine_transform_note": "", "affine_axis_point": axis["axis_point"], "affine_axis_direction": axis["axis_direction"], "affine_axis_source": axis["axis_source"], "affine_anchor_source": axis["anchor_source"], "affine_target_kind": target_kind, "part_solid_count": part_solid_count, } ) elif not blockers and "resize_strategy" not in base and strategy_mode != "auto": blockers.append(f"当前Edge不满足所选策略“{self._edge_length_strategy_label(strategy_mode)}”的执行条件。") if not blockers and base.get("resize_strategy") == "scale-owning-shape-from-edge": refine_note = self._refine_affine_edge_length_scale(base) if refine_note: warnings.append(refine_note) if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers) elif risk != "low": status = "caution" message = " ".join(warnings) else: strategy = str(base.get("resize_strategy", "")) if strategy == "local-edge-only-deform": message = "可以通过局部边形变只调整这条直线Edge,并重建周边平面。" elif strategy == "move-edge-end-plane-by-push-pull": message = "可以通过端面拉伸/切除调整这条直线Edge长度。" elif strategy == "resize-adjacent-cylinder-from-circular-edge-length": message = "可以通过相邻圆柱直径编辑调整这条圆形/圆弧Edge长度。" elif strategy == "scale-owning-shape-from-edge": message = "可以通过几何缩放 fallback 尝试调整该Edge长度。" else: message = "可以尝试直接修改该Edge长度。" base.update( { "edge_length_constraint_summary": self._edge_length_constraint_summary(anchor_mode), "edge_length_impact_summary": self._edge_length_impact_summary(base), "edit_strategy_label": self._edge_length_strategy_label(str(base.get("resize_strategy") or strategy_mode)), "edit_semantics": self._edge_length_impact_summary(base), "status": status, "risk": risk, "message": message, "warnings": ";".join(warnings), "blockers": ";".join(blockers), } ) return base def _local_edge_length_deform_candidate( self, edge_info: dict[str, object], target_length: float, anchor_mode: str = "auto", ) -> tuple[dict[str, object] | None, str]: if str(edge_info.get("curve", "")) != "line": return None, "" start = _tuple_or_none(edge_info.get("start_point")) end = _tuple_or_none(edge_info.get("end_point")) if start is None or end is None: return None, "局部边形变不可用:当前 Edge 缺少稳定起点或终点。" current_length = float(edge_info.get("length", 0.0)) if current_length <= 1e-9: return None, "局部边形变不可用:当前Edge长度无效。" axis = _tuple_normalized(_tuple_sub(end, start)) if axis is None: return None, "局部边形变不可用:当前 Edge 方向无效。" solid_id = int(edge_info.get("solid_id", -1)) if solid_id < 0 or solid_id >= len(self.solids): return None, "局部边形变不可用:当前 Edge 没有关联到稳定 Solid。" part_id = int(edge_info.get("part_id", -1)) part = self.part_by_id(part_id) if part is None: return None, "局部边形变不可用:找不到所属零件。" solid = self.solids[solid_id][1] solid_faces = _explore(solid, TopAbs_FACE) if not solid_faces: return None, "局部边形变不可用:所属Solid没有可重建Face。" if len(solid_faces) > 128: return None, "局部边形变暂只对较简单的平面多面体开放,复杂模型将使用后备策略。" for face in solid_faces: surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: return None, "局部边形变暂只支持全平面多面体;含曲面的模型将使用后备策略。" if len(_explore(face, TopAbs_WIRE)) != 1: return None, "局部边形变暂不处理带内孔的Face;将使用后备策略。" if len(self._local_deform_face_vertex_points(face, max(_shape_diagonal(solid) * 1e-7, 1e-6))) < 3: return None, "局部边形变不可用:部分Face顶点环无法稳定读取。" anchor_mode = self._edge_length_anchor_mode(anchor_mode) target_length = float(target_length) delta_length = target_length - current_length if anchor_mode == "keep-end": start_move = _tuple_scale(axis, -delta_length) end_move = (0.0, 0.0, 0.0) moved_label = "移动起点,固定终点" elif anchor_mode == "center": start_move = _tuple_scale(axis, -delta_length * 0.5) end_move = _tuple_scale(axis, delta_length * 0.5) moved_label = "两端各移动一半,保持中心" else: start_move = (0.0, 0.0, 0.0) end_move = _tuple_scale(axis, delta_length) moved_label = "固定起点,移动终点" part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if part_solid_count > 1 else "part" ratio = abs(delta_length) / max(current_length, 1e-9) local_risk = "high" if ratio > 0.5 else "medium" return ( { "resize_strategy": "local-edge-only-deform", "local_edge_deform_target_kind": target_kind, "local_edge_deform_face_count": len(solid_faces), "local_edge_deform_anchor": moved_label, "local_edge_deform_start_move": start_move, "local_edge_deform_end_move": end_move, "local_edge_deform_moved_endpoint_count": 2 if anchor_mode == "center" else 1, "local_edge_deform_risk": local_risk, "local_edge_deform_note": ( "只移动当前 Edge 的端点并重建所属平面多面体;四点非共面 Face 会优先重建为连续曲面。" ), "part_solid_count": part_solid_count, }, "", ) def edge_endpoint_move_plan( self, edge_id: int, endpoint_role: str, target_point: tuple[float, float, float], ) -> dict[str, object]: if edge_id < 0 or edge_id >= len(self.edges): raise ValueError(f"Unknown edge id {edge_id}") role = str(endpoint_role or "").strip().lower() role_aliases = { "start": "start", "edge-start": "start", "begin": "start", "起点": "start", "end": "end", "edge-end": "end", "finish": "end", "终点": "end", } role = role_aliases.get(role, role) if role not in {"start", "end"}: raise ValueError("endpoint_role must be 'start' or 'end'.") info = self.edge_info(edge_id) current_length = float(info.get("length", 0.0)) curve = str(info.get("curve", "")) start = _tuple_or_none(info.get("start_point")) end = _tuple_or_none(info.get("end_point")) target = _tuple_or_none(target_point) base: dict[str, object] = { "edge_id": edge_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id", -1), "curve": curve, "current_length": current_length, "target_length": current_length, "delta_length": 0.0, "length_change_ratio": 0.0, "edge_endpoint_role": role, "edge_endpoint_label": "start point" if role == "start" else "end point", "start_point": start, "end_point": end, "target_endpoint_point": target, "length_center": info.get("length_center"), "resize_strategy": "local-edge-endpoint-deform", } base.update(self._edge_first_level_plan_fields(edge_id)) warnings = [ "Edge endpoint coordinate edit rebuilds the current STEP/B-Rep result geometry; it is not recovered CAD history." ] blockers: list[str] = [] risk = "low" status = "ready" if curve != "line": blockers.append("Only straight line Edge endpoints can be moved directly.") if current_length <= 1e-9: blockers.append("Current Edge length is invalid.") if start is None or end is None: blockers.append("Current Edge does not have stable start/end coordinates.") if target is None: blockers.append("Target endpoint coordinate must be a valid X/Y/Z tuple.") if not blockers and start is not None and end is not None and target is not None: current_endpoint = start if role == "start" else end fixed_endpoint = end if role == "start" else start move = _tuple_sub(target, current_endpoint) move_distance = _vector_length(move) target_length = _vector_length(_tuple_sub(target, fixed_endpoint)) delta_length = target_length - current_length length_change_ratio = abs(delta_length) / max(current_length, 1e-9) move_ratio = move_distance / max(current_length, 1e-9) base.update( { "current_endpoint_point": current_endpoint, "fixed_endpoint_point": fixed_endpoint, "target_length": target_length, "delta_length": delta_length, "length_change_ratio": length_change_ratio, "moved_endpoint_delta": move, "moved_endpoint_distance": move_distance, "moved_endpoint_ratio": move_ratio, "edge_length_anchor_mode": "keep-end" if role == "start" else "keep-start", "edge_length_anchor_label": "fixed end point" if role == "start" else "fixed start point", } ) if move_distance <= max(current_length * 1e-7, 1e-7): blockers.append("Target endpoint coordinate is almost identical to the current coordinate.") if target_length <= 1e-9: blockers.append("Moving this endpoint would collapse the Edge length to zero.") if move_ratio > 0.5 or length_change_ratio > 0.5: risk = _max_risk(risk, "high") warnings.append("Endpoint movement or resulting length change is larger than 50% of the current Edge length.") elif move_ratio > 0.25 or length_change_ratio > 0.25: risk = _max_risk(risk, "medium") warnings.append("Endpoint movement or resulting length change is larger than 25% of the current Edge length.") if not blockers and start is not None and end is not None and target is not None: anchor_mode = "keep-end" if role == "start" else "keep-start" local_candidate, local_skip_note = self._local_edge_length_deform_candidate( info, float(base["target_length"]), anchor_mode=anchor_mode, ) if local_candidate is None: blockers.append(local_skip_note or "Local endpoint deformation is not available for this Edge.") else: start_move = _tuple_sub(target, start) if role == "start" else (0.0, 0.0, 0.0) end_move = _tuple_sub(target, end) if role == "end" else (0.0, 0.0, 0.0) expected_start = _tuple_add(start, start_move) expected_end = _tuple_add(end, end_move) base.update(local_candidate) base.update( { "resize_strategy": "local-edge-endpoint-deform", "local_edge_deform_anchor": "move start point, keep end point" if role == "start" else "move end point, keep start point", "local_edge_deform_start_move": start_move, "local_edge_deform_end_move": end_move, "local_edge_deform_moved_endpoint_count": 1, "local_edge_deform_note": ( "Move one endpoint of the selected straight Edge and rebuild the surrounding planar solid." ), "expected_start_point": expected_start, "expected_end_point": expected_end, } ) risk = _max_risk(risk, str(local_candidate.get("local_edge_deform_risk", "medium"))) warnings.append( "The selected Edge endpoint will be moved and the surrounding planar faces will be rebuilt; four-point non-planar faces are rebuilt as continuous surfaces when possible." ) if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers) elif risk != "low": status = "caution" message = " ".join(warnings) else: message = "The straight Edge endpoint can be moved by local planar-solid deformation." base.update( { "status": status, "risk": risk, "message": message, "warnings": "; ".join(warnings), "blockers": "; ".join(blockers), } ) return base def edge_center_move_plan( self, edge_id: int, target_center: tuple[float, float, float], ) -> dict[str, object]: if edge_id < 0 or edge_id >= len(self.edges): raise ValueError(f"Unknown edge id {edge_id}") info = self.edge_info(edge_id) current_length = float(info.get("length", 0.0)) curve = str(info.get("curve", "")) start = _tuple_or_none(info.get("start_point")) end = _tuple_or_none(info.get("end_point")) current_center = _tuple_or_none(info.get("length_center")) target = _tuple_or_none(target_center) base: dict[str, object] = { "edge_id": edge_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id", -1), "curve": curve, "current_length": current_length, "target_length": current_length, "delta_length": 0.0, "length_change_ratio": 0.0, "start_point": start, "end_point": end, "length_center": current_center, "target_edge_center": target, "resize_strategy": "local-edge-center-deform", } base.update(self._edge_first_level_plan_fields(edge_id)) warnings = [ "Edge center coordinate edit rebuilds the current STEP/B-Rep result geometry; it is not recovered CAD history." ] blockers: list[str] = [] risk = "low" status = "ready" if curve != "line": blockers.append("Only straight line Edge centers can be moved directly.") if current_length <= 1e-9: blockers.append("Current Edge length is invalid.") if start is None or end is None or current_center is None: blockers.append("Current Edge does not have stable start/end/center coordinates.") if target is None: blockers.append("Target Edge center coordinate must be a valid X/Y/Z tuple.") if not blockers and start is not None and end is not None and current_center is not None and target is not None: move = _tuple_sub(target, current_center) move_distance = _vector_length(move) move_ratio = move_distance / max(current_length, 1e-9) base.update( { "current_edge_center": current_center, "target_edge_center": target, "moved_edge_center_delta": move, "moved_edge_center_distance": move_distance, "moved_edge_center_ratio": move_ratio, "edge_length_anchor_mode": "center", "edge_length_anchor_label": "move whole edge center", } ) if move_distance <= max(current_length * 1e-7, 1e-7): blockers.append("Target Edge center coordinate is almost identical to the current coordinate.") if move_ratio > 0.5: risk = _max_risk(risk, "high") warnings.append("Edge center movement is larger than 50% of the current Edge length.") elif move_ratio > 0.25: risk = _max_risk(risk, "medium") warnings.append("Edge center movement is larger than 25% of the current Edge length.") if not blockers and start is not None and end is not None and current_center is not None and target is not None: local_candidate, local_skip_note = self._local_edge_length_deform_candidate( info, current_length, anchor_mode="center", ) if local_candidate is None: blockers.append(local_skip_note or "Local Edge center deformation is not available for this Edge.") else: move = _tuple_sub(target, current_center) expected_start = _tuple_add(start, move) expected_end = _tuple_add(end, move) base.update(local_candidate) base.update( { "resize_strategy": "local-edge-center-deform", "local_edge_deform_anchor": "move both endpoints equally", "local_edge_deform_start_move": move, "local_edge_deform_end_move": move, "local_edge_deform_moved_endpoint_count": 2, "local_edge_deform_note": ( "Move both endpoints of the selected straight Edge and rebuild the surrounding planar solid." ), "expected_start_point": expected_start, "expected_end_point": expected_end, } ) risk = _max_risk(risk, str(local_candidate.get("local_edge_deform_risk", "medium"))) warnings.append( "Both endpoints of the selected Edge will be moved and the surrounding planar faces will be rebuilt; four-point non-planar faces are rebuilt as continuous surfaces when possible." ) if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers) elif risk != "low": status = "caution" message = " ".join(warnings) else: message = "The straight Edge center can be moved by local planar-solid deformation." base.update( { "status": status, "risk": risk, "message": message, "warnings": "; ".join(warnings), "blockers": "; ".join(blockers), } ) return base def _edge_length_anchor_mode(self, anchor_mode: str | None) -> str: normalized = str(anchor_mode or "auto").strip().lower() aliases = { "自动": "auto", "auto": "auto", "center": "center", "centre": "center", "固定中心": "center", "keep-center": "center", "start": "keep-start", "起点": "keep-start", "固定起点": "keep-start", "keep-start": "keep-start", "end": "keep-end", "终点": "keep-end", "固定终点": "keep-end", "keep-end": "keep-end", } return aliases.get(normalized, "auto") def _edge_length_anchor_label(self, anchor_mode: str) -> str: return { "auto": "自动选择局部端面", "center": "固定中心", "keep-start": "固定起点", "keep-end": "固定终点", }.get(anchor_mode, "自动选择局部端面") def _edge_length_strategy_mode(self, strategy_mode: str | None) -> str: normalized = str(strategy_mode or "auto").strip().lower() aliases = { "自动": "auto", "auto": "auto", "local": "local-edge-only-deform", "local-edge": "local-edge-only-deform", "local-edge-only": "local-edge-only-deform", "local-edge-only-deform": "local-edge-only-deform", "只改当前edge": "local-edge-only-deform", "只改这条edge": "local-edge-only-deform", "只改当前边": "local-edge-only-deform", "只改这条边": "local-edge-only-deform", "只变当前edge": "local-edge-only-deform", "只变这条edge": "local-edge-only-deform", "只变当前边": "local-edge-only-deform", "只变这条边": "local-edge-only-deform", "局部边形变": "local-edge-only-deform", "end-face": "move-edge-end-plane-by-push-pull", "push-pull": "move-edge-end-plane-by-push-pull", "move-edge-end-plane": "move-edge-end-plane-by-push-pull", "move-edge-end-plane-by-push-pull": "move-edge-end-plane-by-push-pull", "移动端面": "move-edge-end-plane-by-push-pull", "移动端面/保持垂直": "move-edge-end-plane-by-push-pull", "保持垂直": "move-edge-end-plane-by-push-pull", "保持面垂直": "move-edge-end-plane-by-push-pull", "保持相邻面垂直": "move-edge-end-plane-by-push-pull", "端面拉伸/切除": "move-edge-end-plane-by-push-pull", "整体尺寸变化": "move-edge-end-plane-by-push-pull", "变成长方体": "move-edge-end-plane-by-push-pull", "cylinder": "resize-adjacent-cylinder-from-circular-edge-length", "adjacent-cylinder": "resize-adjacent-cylinder-from-circular-edge-length", "resize-adjacent-cylinder-from-circular-edge-length": "resize-adjacent-cylinder-from-circular-edge-length", "相邻圆柱": "resize-adjacent-cylinder-from-circular-edge-length", "相邻圆柱直径": "resize-adjacent-cylinder-from-circular-edge-length", "scale": "scale-owning-shape-from-edge", "scale-owning": "scale-owning-shape-from-edge", "scale-owning-shape-from-edge": "scale-owning-shape-from-edge", "缩放所属对象": "scale-owning-shape-from-edge", "整体缩放": "scale-owning-shape-from-edge", } return aliases.get(normalized, "auto") def _edge_length_strategy_label(self, strategy_mode: str) -> str: return { "auto": "自动选择", "local-edge-only-deform": "只改当前Edge", "move-edge-end-plane-by-push-pull": "移动端面/保持垂直", "resize-adjacent-cylinder-from-circular-edge-length": "相邻圆柱直径", "scale-owning-shape-from-edge": "缩放所属对象", }.get(strategy_mode, "自动选择") def _edge_length_constraint_summary(self, anchor_mode: str) -> str: anchor_mode = self._edge_length_anchor_mode(anchor_mode) return { "auto": "自动基准:默认尽量固定起点,移动终点;如果策略需要,会按可用端面调整。", "center": "固定中心:Edge中心尽量不动,两端或所属对象围绕中心变化。", "keep-start": "固定起点:起点尽量不动,终点或相关端面承担长度变化。", "keep-end": "固定终点:终点尽量不动,起点或相关端面承担长度变化。", }.get(anchor_mode, "自动基准:程序会选择更稳定的一端作为固定约束。") def _edge_length_impact_summary(self, plan: dict[str, object]) -> str: strategy = str(plan.get("resize_strategy") or plan.get("edge_length_strategy_mode") or "auto") target_kind = str(plan.get("affine_target_kind") or plan.get("local_edge_deform_target_kind") or "所属对象") if strategy == "local-edge-only-deform": return ( "只改当前Edge:只移动被选Edge的端点并重建相邻平面;相邻面会自然变斜," "四点非共面面会优先重建为连续曲面,整体端面不会一起平移。" ) if strategy == "move-edge-end-plane-by-push-pull": end_face = plan.get("end_face_id") face_text = f" Face {end_face}" if end_face not in {None, ""} else "" return ( f"移动端面:把长度变化转换为端面{face_text}拉伸/切除;端面和同一端面区域上的相关边会跟随," "相邻平面会尽量保持垂直,正方体这类模型会更像变成长方体。" ) if strategy == "resize-adjacent-cylinder-from-circular-edge-length": return ( "相邻圆柱直径:把圆形/圆弧Edge目标长度换算成相邻圆柱直径," "优先重切孔/槽或重建凸台,不会把整个模型按Edge长度缩放。" ) if strategy == "scale-owning-shape-from-edge": return ( f"缩放所属对象:对所属 {target_kind} 做轴向、径向或整体缩放;" "同一对象上的其它尺寸会跟随变化,适合作为明确选择的高风险兜底语义。" ) return ( "自动选择:程序会按局部Edge形变、端面移动、相邻圆柱编辑、缩放所属对象的顺序寻找可用路径;" "确认窗口会显示最终采用的实际策略。" ) def _circular_edge_length_cylinder_candidate( self, edge_info: dict[str, object], target_length: float, ) -> tuple[dict[str, object] | None, list[str]]: current_length = float(edge_info.get("length", 0.0)) current_radius = float(edge_info.get("radius", 0.0)) if current_length <= 1e-9 or current_radius <= 1e-9: return None, [] length_scale = float(target_length) / current_length target_radius = current_radius * length_scale target_diameter = target_radius * 2.0 if target_diameter <= 1e-9: return None, [] notes: list[str] = [] candidates: list[tuple[tuple[int, int, int, int], dict[str, object]]] = [] risk_rank = {"low": 0, "medium": 1, "high": 2, "blocked": 3} status_rank = {"ready": 0, "caution": 1, "blocked": 2} adjacent_face_ids = _int_values(edge_info.get("adjacent_face_ids")) if not adjacent_face_ids: return None, [] for face_id in adjacent_face_ids: if face_id < 0 or face_id >= len(self.faces): continue face_info = self.face_info(face_id) if face_info.get("surface") != "cylinder" or "diameter" not in face_info: continue face_radius = float(face_info.get("radius", 0.0)) if face_radius <= 1e-9: continue radius_tolerance = max(current_radius * 0.06, face_radius * 0.06, _shape_diagonal(self.faces[face_id]) * 1e-5, 1e-4) if abs(face_radius - current_radius) > radius_tolerance: continue feature_guess = str(face_info.get("feature_guess", "cylindrical face")) if feature_guess == "round/fillet candidate": notes.append(f"相邻圆柱Face {face_id} 更像已有圆角,未自动按孔/凸台直径改边长。") continue if feature_guess == "hole/groove candidate": mode_order = ("hole",) elif feature_guess == "boss/outer-round candidate": mode_order = ("boss",) else: notes.append(f"相邻圆柱Face {face_id} 尚未明确识别为孔/槽或凸台,未自动按圆柱直径改边长。") continue for mode_index, mode in enumerate(mode_order): mode_label = "圆柱凸台直径" if mode == "boss" else "圆柱孔/槽直径" try: cylinder_plan = ( self.cylindrical_boss_resize_plan(face_id, target_diameter) if mode == "boss" else self.cylindrical_resize_plan(face_id, target_diameter) ) except Exception as exc: notes.append(f"相邻圆柱Face {face_id} 的{mode_label}计划生成失败:{exc}") continue plan_status = str(cylinder_plan.get("status", "blocked")) plan_risk = str(cylinder_plan.get("risk", "blocked")) if plan_status == "blocked": notes.append(f"相邻圆柱Face {face_id} 的{mode_label}不可用:{cylinder_plan.get('message', '')}") continue candidate = { "resize_strategy": "resize-adjacent-cylinder-from-circular-edge-length", "circular_edge_current_radius": current_radius, "circular_edge_target_radius": target_radius, "circular_edge_length_scale": length_scale, "circular_edge_cylinder_face_id": face_id, "circular_edge_cylinder_mode": mode, "circular_edge_cylinder_mode_label": mode_label, "cylinder_resize_face_id": face_id, "cylinder_resize_operation": "resize_cylindrical_boss" if mode == "boss" else "resize_cylindrical_hole", "cylinder_resize_current_diameter": cylinder_plan.get("current_diameter"), "cylinder_resize_target_diameter": target_diameter, "cylinder_resize_delta_diameter": cylinder_plan.get("delta_diameter"), "cylinder_resize_delta_ratio": cylinder_plan.get("diameter_delta_ratio"), "cylinder_resize_status": plan_status, "cylinder_resize_risk": plan_risk, "cylinder_resize_message": cylinder_plan.get("message"), "cylinder_resize_warnings": cylinder_plan.get("warnings", ""), "cylinder_resize_blockers": cylinder_plan.get("blockers", ""), "cylinder_resize_feature_guess": cylinder_plan.get("feature_guess", feature_guess), "cylinder_resize_confidence": cylinder_plan.get("confidence", face_info.get("confidence", "")), "cylinder_resize_same_domain_face_ids": cylinder_plan.get("same_domain_face_ids", ()), "cylinder_resize_same_domain_face_count": cylinder_plan.get("same_domain_face_count", ""), } score = ( status_rank.get(plan_status, 9), risk_rank.get(plan_risk, 9), mode_index, face_id, ) candidates.append((score, candidate)) if not candidates: if notes: notes.insert(0, "圆边没有找到可直接复用的相邻圆柱直径编辑路径,将回退到几何缩放。") return None, notes candidates.sort(key=lambda item: item[0]) return candidates[0][1], notes def circular_edge_axis_move_plan( self, edge_id: int, target_center: tuple[float, float, float], ) -> dict[str, object]: if edge_id < 0 or edge_id >= len(self.edges): raise ValueError(f"Unknown edge id {edge_id}") info = self.edge_info(edge_id) blockers: list[str] = [] warnings: list[str] = [ "圆Edge圆心/轴心移动会优先寻找相邻孔、槽或凸台圆柱面,再复用对应轴心移动路线。" ] risk = "medium" try: target = (float(target_center[0]), float(target_center[1]), float(target_center[2])) except (TypeError, ValueError, IndexError): target = (0.0, 0.0, 0.0) blockers.append("圆Edge目标圆心必须是 X, Y, Z 三个数字。") current_center = _tuple_or_none(info.get("center")) current_radius = _float_or_none(info.get("radius")) current_length = _float_or_none(info.get("length")) curve = str(info.get("curve", "")) if curve != "circle": blockers.append("只有圆形或圆弧 Edge 才能按相邻圆柱轴心移动。") if current_center is None: blockers.append("当前圆Edge缺少稳定圆心,不能换算轴心移动。") if current_radius is None or current_radius <= 1e-9: blockers.append("当前圆Edge缺少稳定半径,不能匹配相邻圆柱。") movement = (0.0, 0.0, 0.0) move_distance = 0.0 if current_center is not None: movement = _tuple_sub(target, current_center) move_distance = _vector_length(movement) diagonal = max(_shape_diagonal(self.edges[edge_id]), current_radius or 0.0, 1.0) if move_distance <= max(diagonal * 1e-7, 1e-6): blockers.append("目标圆心和当前圆心几乎相同,无需移动。") elif current_radius is not None and current_radius > 0: ratio = move_distance / current_radius if ratio > 8.0: risk = _max_risk(risk, "high") warnings.append("圆心移动超过 8 个圆边半径,布尔操作可能影响无关几何。") elif ratio > 2.0: risk = _max_risk(risk, "high") warnings.append("圆心移动超过 2 个圆边半径,建议执行后重点检查周边壁厚。") candidate: dict[str, object] | None = None candidate_notes: list[str] = [] if not blockers: candidate, candidate_notes = self._circular_edge_axis_move_cylinder_candidate(info, movement) if candidate is None: blockers.append( "未找到可复用的相邻孔、槽或凸台圆柱轴心移动路径。" + (" " + " ".join(candidate_notes[:3]) if candidate_notes else "") ) else: risk = _max_risk(risk, str(candidate.get("move_axis_risk", "medium"))) warnings.append(str(candidate.get("move_axis_warnings", ""))) if candidate_notes: warnings.extend(candidate_notes[:3]) if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers) else: status = "caution" if risk in {"medium", "high"} else "ready" mode_label = str(candidate.get("circular_edge_cylinder_mode_label", "相邻圆柱轴心")) if candidate else "相邻圆柱轴心" message = ( f"可以通过{mode_label}移动来调整圆Edge圆心;" "圆边所在截面的圆心移动量会同步应用到相邻圆柱的中间轴心。 " + " ".join(part for part in warnings if part) ) result = { "status": status, "risk": risk, "message": message, "warnings": "; ".join(part for part in warnings if part), "blockers": "; ".join(blockers), "edge_id": edge_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id"), "curve": curve, "current_edge_center": current_center, "target_edge_center": target, "circular_edge_center_move_vector": movement, "circular_edge_center_move_distance": move_distance, "circular_edge_current_radius": current_radius, "circular_edge_current_diameter": None if current_radius is None else current_radius * 2.0, "current_length": current_length, "resize_strategy": "move-adjacent-cylinder-from-circular-edge-center", "edit_strategy_label": "圆Edge相邻圆柱轴心移动", "edit_semantics": ( "把圆Edge圆心的移动量应用到相邻孔、槽或凸台的圆柱轴心;" "这是移动局部圆柱特征,不是整体平移零件。" ), } if candidate: result.update(candidate) result.update(self._edge_first_level_plan_fields(edge_id)) return result def _circular_edge_axis_move_cylinder_candidate( self, edge_info: dict[str, object], movement: tuple[float, float, float], ) -> tuple[dict[str, object] | None, list[str]]: current_radius = _float_or_none(edge_info.get("radius")) if current_radius is None or current_radius <= 1e-9: return None, [] notes: list[str] = [] candidates: list[tuple[tuple[int, int, int, int], dict[str, object]]] = [] risk_rank = {"low": 0, "medium": 1, "high": 2, "blocked": 3} status_rank = {"ready": 0, "caution": 1, "blocked": 2} adjacent_face_ids = _int_values(edge_info.get("adjacent_face_ids")) if not adjacent_face_ids: return None, ["圆Edge没有相邻Face信息,无法判断它属于哪个孔/槽/凸台。"] for face_id in adjacent_face_ids: if face_id < 0 or face_id >= len(self.faces): continue face_info = self.face_info(face_id) if face_info.get("surface") != "cylinder" or "diameter" not in face_info: continue face_radius = _float_or_none(face_info.get("radius")) if face_radius is None or face_radius <= 1e-9: continue radius_tolerance = max(current_radius * 0.06, face_radius * 0.06, _shape_diagonal(self.faces[face_id]) * 1e-5, 1e-4) if abs(face_radius - current_radius) > radius_tolerance: continue feature_guess = str(face_info.get("feature_guess", "cylindrical face")) if feature_guess == "round/fillet candidate": notes.append(f"相邻圆柱Face {face_id} 更像已有圆角,未按孔/槽/凸台轴心移动。") continue feature: dict[str, object] = {} try: feature = self.feature_info(face_id) except Exception: feature = {} face_feature_info = {**face_info, **feature} axis_data = self._cylindrical_face_axis_mid_center(face_id, feature) if axis_data is None: notes.append(f"相邻圆柱Face {face_id} 缺少稳定中间轴心。") continue target_axis_center = _tuple_add(axis_data["current_axis_center"], movement) angular_span = _effective_cylinder_angular_span(face_feature_info) slot_kind = str(feature.get("slot_kind") or face_info.get("slot_kind") or "") if feature_guess == "hole/groove candidate": if not _is_effectively_full_cylinder(face_feature_info) and ( slot_kind == "partial-cylindrical-groove" or (angular_span is not None and angular_span < math.tau * 0.92) ): mode_order = ("slot", "hole") else: mode_order = ("hole", "slot") elif feature_guess == "boss/outer-round candidate": mode_order = ("boss",) else: notes.append(f"相邻圆柱Face {face_id} 尚未明确识别为孔/槽或凸台。") continue for mode_index, mode in enumerate(mode_order): mode_label = { "hole": "圆柱孔轴心", "slot": "槽/半孔轴心", "boss": "圆柱凸台轴心", }.get(mode, "相邻圆柱轴心") try: axis_plan = ( self.cylindrical_slot_axis_move_plan(face_id, target_axis_center) if mode == "slot" else self.cylindrical_boss_axis_move_plan(face_id, target_axis_center) if mode == "boss" else self.cylindrical_axis_move_plan(face_id, target_axis_center) ) except Exception as exc: notes.append(f"相邻圆柱Face {face_id} 的{mode_label}计划生成失败:{exc}") continue plan_status = str(axis_plan.get("status", "blocked")) plan_risk = str(axis_plan.get("risk", "blocked")) if plan_status == "blocked": notes.append(f"相邻圆柱Face {face_id} 的{mode_label}不可用:{axis_plan.get('message', '')}") continue candidate = { "circular_edge_cylinder_face_id": face_id, "circular_edge_cylinder_mode": mode, "circular_edge_cylinder_mode_label": mode_label, "circular_edge_axis_operation": { "hole": "move_cylindrical_hole_axis", "slot": "move_cylindrical_slot_axis", "boss": "move_cylindrical_boss_axis", }[mode], "circular_edge_target_axis_center": target_axis_center, "current_axis_center": axis_plan.get("current_axis_center"), "target_axis_center": axis_plan.get("target_axis_center"), "axis_move_vector": axis_plan.get("axis_move_vector"), "axis_move_distance": axis_plan.get("axis_move_distance"), "axis_move_radial_distance": axis_plan.get("axis_move_radial_distance"), "axis_move_axial_delta": axis_plan.get("axis_move_axial_delta"), "target_diameter": axis_plan.get("target_diameter"), "target_radius": axis_plan.get("target_radius"), "move_axis_status": plan_status, "move_axis_risk": plan_risk, "move_axis_message": axis_plan.get("message"), "move_axis_warnings": axis_plan.get("warnings", ""), "move_axis_blockers": axis_plan.get("blockers", ""), "move_axis_feature_guess": axis_plan.get("feature_guess", feature_guess), "move_axis_confidence": axis_plan.get("confidence", face_info.get("confidence", "")), "resize_strategy": "move-adjacent-cylinder-from-circular-edge-center", "edit_strategy_label": f"通过{mode_label}移动圆Edge", "edit_semantics": ( f"圆Edge圆心的目标移动量会转成相邻Face {face_id} 的{mode_label}移动;" "这会重建局部孔/槽/凸台,不会整体平移零件。" ), } score = ( status_rank.get(plan_status, 9), risk_rank.get(plan_risk, 9), mode_index, face_id, ) candidates.append((score, candidate)) if not candidates: return None, notes candidates.sort(key=lambda item: item[0]) return candidates[0][1], notes def _cylindrical_face_axis_mid_center( self, face_id: int, feature: dict[str, object] | None = None, ) -> dict[str, object] | None: if face_id < 0 or face_id >= len(self.faces): return None try: surf = BRepAdaptor_Surface(self.faces[face_id]) if surf.GetType() != GeomAbs_Cylinder: return None if feature is None: feature = self.feature_info(face_id) cyl = surf.Cylinder() axis_range = self._cylindrical_axis_range( face_id, surf, _int_values((feature or {}).get("feature_side_face_ids")), ) mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5 return { "current_axis_center": _point_tuple(_point_on_axis(cyl.Axis().Location(), cyl.Axis().Direction(), mid_parameter)), "axis_direction": _dir_tuple(cyl.Axis().Direction()), "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", ""), } except Exception: return None def move_circular_edge_axis_center( self, edge_id: int, target_center: tuple[float, float, float], ) -> str: plan = self.circular_edge_axis_move_plan(edge_id, target_center) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) face_id = int(plan.get("circular_edge_cylinder_face_id", -1)) target_axis_center = _tuple_or_none(plan.get("target_axis_center")) mode = str(plan.get("circular_edge_cylinder_mode", "")) if face_id < 0 or target_axis_center is None: raise ValueError("圆Edge轴心移动缺少可执行的相邻圆柱Face或目标轴心。") if mode == "slot": delegated = self.move_cylindrical_slot_axis(face_id, target_axis_center) elif mode == "boss": delegated = self.move_cylindrical_boss_axis(face_id, target_axis_center) else: delegated = self.move_cylindrical_hole_axis(face_id, target_axis_center) return ( "Circular Edge adjacent-cylinder axis move completed: " f"edge {edge_id}, face {face_id}, mode={plan.get('circular_edge_cylinder_mode_label')}, " f"edge_center={plan.get('current_edge_center')}->{plan.get('target_edge_center')}, " f"axis_center={plan.get('current_axis_center')}->{plan.get('target_axis_center')}. " f"{delegated}" ) def _edge_length_affine_axis( self, edge_info: dict[str, object], anchor_mode: str = "auto", ) -> dict[str, object] | None: start = _tuple_or_none(edge_info.get("start_point")) end = _tuple_or_none(edge_info.get("end_point")) center = _tuple_or_none(edge_info.get("length_center")) anchor_mode = self._edge_length_anchor_mode(anchor_mode) if start is not None and end is not None: direction = _tuple_normalized(_tuple_sub(end, start)) if direction is not None: midpoint = ( (start[0] + end[0]) * 0.5, (start[1] + end[1]) * 0.5, (start[2] + end[2]) * 0.5, ) if anchor_mode == "keep-start": axis_point = start anchor_source = "edge start point" elif anchor_mode == "keep-end": axis_point = end anchor_source = "edge end point" else: axis_point = center or midpoint anchor_source = "edge center" return { "axis_point": axis_point, "axis_direction": direction, "axis_source": "edge start/end chord", "anchor_source": anchor_source, } bbox_min = _tuple_or_none(edge_info.get("bbox_min")) bbox_max = _tuple_or_none(edge_info.get("bbox_max")) if bbox_min is not None and bbox_max is not None: sizes = [abs(bbox_max[index] - bbox_min[index]) for index in range(3)] axis_index = max(range(3), key=lambda index: sizes[index]) if sizes[axis_index] > 1e-9: direction = [0.0, 0.0, 0.0] direction[axis_index] = 1.0 return { "axis_point": center or ( (bbox_min[0] + bbox_max[0]) * 0.5, (bbox_min[1] + bbox_max[1]) * 0.5, (bbox_min[2] + bbox_max[2]) * 0.5, ), "axis_direction": tuple(direction), "axis_source": "edge bounding-box longest axis", "anchor_source": "edge bounding-box center", } return None def _planar_edge_length_scale_candidate( self, edge_info: dict[str, object], target_length: float, ) -> tuple[dict[str, object] | None, str]: curve = str(edge_info.get("curve", "")) if curve == "line": return None, "" current_length = float(edge_info.get("length", 0.0)) if current_length <= 1e-9: return None, "平面曲线径向缩放不可用:当前Edge长度无效。" center = _tuple_or_none(edge_info.get("center")) axis = _tuple_normalized(_tuple_or_none(edge_info.get("axis"))) axis_source = "" anchor_source = "" label = "围绕平面曲线法向径向缩放" sample_count: int | str = "" plane_deviation: float | str = "" if center is not None and axis is not None and curve in {"circle", "ellipse"}: axis_source = f"{curve} center/axis" anchor_source = f"{curve} center" label = "围绕圆边轴线径向缩放" if curve == "circle" else "围绕椭圆边法向径向缩放" else: frame, note = self._sampled_planar_edge_frame(edge_info) if frame is None: return None, note center = frame["center"] axis = frame["axis"] axis_source = "sampled edge best-fit plane" anchor_source = "sampled edge center" sample_count = int(frame["sample_count"]) plane_deviation = float(frame["plane_deviation"]) if center is None or axis is None: return None, "平面曲线径向缩放不可用:无法确定曲线中心或平面法向。" part_id = int(edge_info.get("part_id", -1)) solid_id = int(edge_info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None if part is None: return None, "平面曲线径向缩放不可用:找不到所属零件。" part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" scale = float(target_length) / max(current_length, 1e-9) transform_risk = "high" if abs(scale - 1.0) > 0.25 or axis_source.startswith("sampled") else "medium" if curve == "circle": warning = ( "圆边没有可复用的相邻圆柱直径编辑路径;将围绕圆边轴线对所属 " f"{target_kind} 做径向缩放,尽量保留轴向尺寸,但仍会影响同一 {target_kind} 上的其他径向尺寸。" ) elif curve == "ellipse": warning = ( "椭圆边将围绕自身平面法向对所属 " f"{target_kind} 做径向缩放,尽量保留法向尺寸,但会影响同一 {target_kind} 上的平面内其它尺寸。" ) else: warning = ( "当前非直线Edge可近似为平面曲线;将按采样平面法向对所属 " f"{target_kind} 做径向缩放。该路径依赖采样估算,执行后请复查周边尺寸。" ) return ( { "resize_strategy": "scale-owning-shape-from-edge", "affine_scale": scale, "affine_transform_kind": "radial-affine", "affine_transform_label": label, "affine_transform_note": "可能把解析圆/圆锥/圆柱/椭圆边面转换为 B-spline 几何。", "affine_transform_warning": warning, "affine_transform_risk": transform_risk, "affine_axis_point": center, "affine_axis_direction": axis, "affine_axis_source": axis_source, "affine_anchor_source": anchor_source, "affine_target_kind": target_kind, "part_solid_count": part_solid_count, "planar_edge_scale_sample_count": sample_count, "planar_edge_scale_plane_deviation": plane_deviation, }, "", ) def _sampled_planar_edge_frame(self, edge_info: dict[str, object]) -> tuple[dict[str, object] | None, str]: edge_id = int(edge_info.get("edge_id", -1)) if edge_id < 0 or edge_id >= len(self.edges): return None, "平面曲线径向缩放不可用:Edge ID无效。" curve = BRepAdaptor_Curve(self.edges[edge_id]) first = float(curve.FirstParameter()) last = float(curve.LastParameter()) if not math.isfinite(first) or not math.isfinite(last) or abs(last - first) <= 1e-12: return None, "平面曲线径向缩放不可用:Edge参数范围无效。" sample_count = 17 points = [ _point_tuple(curve.Value(first + (last - first) * index / (sample_count - 1))) for index in range(sample_count) ] distinct: list[tuple[float, float, float]] = [] tolerance = max(float(edge_info.get("length", 0.0)) * 1e-7, _shape_diagonal(self.edges[edge_id]) * 1e-7, 1e-7) for point in points: if not any(_vector_length(_tuple_sub(point, existing)) <= tolerance for existing in distinct): distinct.append(point) if len(distinct) < 3: return None, "平面曲线径向缩放不可用:采样点不足以确定平面。" center = _tuple_or_none(edge_info.get("length_center")) if center is None: center = ( sum(point[0] for point in distinct) / len(distinct), sum(point[1] for point in distinct) / len(distinct), sum(point[2] for point in distinct) / len(distinct), ) normal = self._sampled_edge_plane_normal(distinct, center) if normal is None: return None, "平面曲线径向缩放不可用:采样点近似共线,无法确定平面法向。" plane_deviation = max(abs(_tuple_dot(_tuple_sub(point, center), normal)) for point in distinct) max_radius = max(_vector_length(_tuple_sub(point, center)) for point in distinct) allowed_deviation = max(max_radius * 1e-4, float(edge_info.get("length", 0.0)) * 1e-5, 1e-6) if plane_deviation > allowed_deviation: return None, ( "平面曲线径向缩放不可用:Edge采样点不在稳定平面内,将使用更保守的缩放 fallback。" ) return ( { "center": center, "axis": normal, "sample_count": len(distinct), "plane_deviation": plane_deviation, }, "", ) def _sampled_edge_plane_normal( self, points: list[tuple[float, float, float]], center: tuple[float, float, float], ) -> tuple[float, float, float] | None: normal = (0.0, 0.0, 0.0) for index, point in enumerate(points): next_point = points[(index + 1) % len(points)] cross = _tuple_cross(_tuple_sub(point, center), _tuple_sub(next_point, center)) normal = ( normal[0] + cross[0], normal[1] + cross[1], normal[2] + cross[2], ) normalized = _tuple_normalized(normal) if normalized is not None: return normalized best: tuple[float, float, float] | None = None best_length = 0.0 count = len(points) for first_index in range(count - 2): for second_index in range(first_index + 1, count - 1): for third_index in range(second_index + 1, count): candidate = _tuple_cross( _tuple_sub(points[second_index], points[first_index]), _tuple_sub(points[third_index], points[first_index]), ) candidate_length = _vector_length(candidate) if candidate_length > best_length: best = candidate best_length = candidate_length return _tuple_normalized(best) def ellipse_edge_axis_radius_plan( self, edge_id: int, target_radius: float, axis_kind: str = "major", ) -> dict[str, object]: if edge_id < 0 or edge_id >= len(self.edges): raise ValueError(f"Unknown edge id {edge_id}") info = self.edge_info(edge_id) axis_kind = "minor" if str(axis_kind).lower() in {"minor", "small", "y", "minor_radius"} else "major" radius_key = "minor_radius" if axis_kind == "minor" else "major_radius" direction_key = "minor_axis" if axis_kind == "minor" else "major_axis" axis_label = "小半径" if axis_kind == "minor" else "主半径" axis_direction_label = "小轴" if axis_kind == "minor" else "主轴" current_radius = _float_or_none(info.get(radius_key)) other_radius = _float_or_none(info.get("major_radius" if axis_kind == "minor" else "minor_radius")) center = _tuple_or_none(info.get("center")) direction = _tuple_normalized(_tuple_or_none(info.get(direction_key))) other_direction = _tuple_normalized( _tuple_or_none(info.get("major_axis" if axis_kind == "minor" else "minor_axis")) ) target_radius = float(target_radius) blockers: list[str] = [] warnings: list[str] = [ "椭圆Edge半径修改基于当前 STEP/B-Rep 结果几何;会对所属对象做单轴仿射缩放,不是恢复 CAD 草图约束。" ] risk = "medium" if info.get("curve") != "ellipse": blockers.append("当前Edge不是椭圆Edge。") if current_radius is None or current_radius <= 1e-9: blockers.append(f"当前椭圆Edge缺少稳定{axis_label}。") if other_radius is None or other_radius <= 1e-9: blockers.append("当前椭圆Edge缺少另一个半径,不能稳定校验结果。") if center is None: blockers.append("当前椭圆Edge缺少稳定中心。") if direction is None: blockers.append(f"当前椭圆Edge缺少稳定{axis_direction_label}方向。") if other_direction is None: blockers.append("当前椭圆Edge缺少另一个轴方向,不能稳定校验结果。") if target_radius <= 1e-9: blockers.append(f"椭圆Edge目标{axis_label}必须大于 0。") if current_radius is not None and current_radius > 0 and abs(target_radius - current_radius) <= max(current_radius * 1e-7, 1e-7): blockers.append(f"椭圆Edge目标{axis_label}与当前值几乎相同,不需要修改。") scale = target_radius / max(current_radius or 1.0, 1e-9) delta = None if current_radius is None else target_radius - current_radius delta_ratio = None if current_radius is None or current_radius <= 0 else abs(delta or 0.0) / current_radius if delta_ratio is not None: if delta_ratio > 0.5: risk = _max_risk(risk, "high") warnings.append(f"目标{axis_label}变化超过当前值的 50%,周边几何变形或修复失败的概率较高。") elif delta_ratio > 0.25: risk = _max_risk(risk, "medium") warnings.append(f"目标{axis_label}变化超过当前值的 25%,请确认建模意图。") part_id = int(info.get("part_id", -1)) solid_id = int(info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None if part is None: blockers.append("找不到椭圆Edge所属零件。") part_solid_count = 0 target_kind = "part" else: part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers) else: status = "caution" if risk != "low" else "ready" message = " ".join(warnings) target_major = target_radius if axis_kind == "major" else info.get("major_radius") target_minor = target_radius if axis_kind == "minor" else info.get("minor_radius") plan = { "edge_id": edge_id, "part_id": part_id, "solid_id": solid_id, "curve": info.get("curve"), "status": status, "risk": risk, "message": message, "warnings": ";".join(warnings), "blockers": ";".join(blockers), "resize_strategy": f"ellipse-edge-{axis_kind}-axis-affine", "edit_strategy_label": f"椭圆Edge{axis_label}单轴缩放", "edit_semantics": ( f"沿椭圆{axis_direction_label}方向缩放所属 {target_kind},让{axis_label}接近目标值;" "另一个半径方向尽量不动,但同一对象上的其它几何会受这个单轴缩放影响。" ), "ellipse_axis_kind": axis_kind, "ellipse_axis_label": axis_label, "ellipse_axis_direction_label": axis_direction_label, "ellipse_current_radius": current_radius, "ellipse_target_radius": target_radius, "ellipse_radius_delta": delta, "ellipse_radius_delta_ratio": delta_ratio, "ellipse_current_major_radius": info.get("major_radius"), "ellipse_target_major_radius": target_major, "ellipse_current_minor_radius": info.get("minor_radius"), "ellipse_target_minor_radius": target_minor, "affine_scale": scale, "affine_transform_kind": "axis-affine", "affine_transform_label": f"沿椭圆{axis_direction_label}单轴缩放", "affine_transform_note": "这会改变所属对象在该方向上的尺寸,可能把部分解析几何转换为 B-spline。", "affine_axis_point": center, "affine_axis_direction": direction, "ellipse_other_axis_direction": other_direction, "affine_axis_source": f"ellipse {axis_direction_label}", "affine_anchor_source": "ellipse center", "affine_target_kind": target_kind, "part_solid_count": part_solid_count, } plan.update(self._edge_first_level_plan_fields(edge_id)) return plan def resize_ellipse_edge_axis_radius( self, edge_id: int, target_radius: float, axis_kind: str = "major", ) -> str: plan = self.ellipse_edge_axis_radius_plan(edge_id, target_radius, axis_kind=axis_kind) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._apply_edge_length_affine_transform(plan) result_check = self._ellipse_edge_axis_radius_result_summary(plan) return ( f"Ellipse Edge {plan['ellipse_axis_kind']} radius resize completed: " f"edge {edge_id}, " f"current_radius={float(plan['ellipse_current_radius']):g}, " f"target_radius={float(plan['ellipse_target_radius']):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) def _ellipse_edge_axis_radius_result_summary(self, plan: dict[str, object]) -> str: axis_kind = str(plan.get("ellipse_axis_kind") or "major") radius_key = "minor_radius" if axis_kind == "minor" else "major_radius" target_radius = _float_or_none(plan.get("ellipse_target_radius")) if target_radius is None: return "Result check: target radius was unavailable." part_id = int(plan.get("part_id", -1)) solid_id = int(plan.get("solid_id", -1)) best: tuple[float, int, float, dict[str, object]] | None = None for candidate_edge_id in range(len(self.edges)): if part_id >= 0 and self.edge_part_ids[candidate_edge_id] != part_id: continue if solid_id >= 0 and self.edge_solid_ids[candidate_edge_id] != solid_id: continue info = self.edge_info(candidate_edge_id) if info.get("curve") != "ellipse": continue value = _float_or_none(info.get(radius_key)) if value is None: continue error = abs(value - target_radius) if best is None or error < best[0]: best = (error, candidate_edge_id, value, info) if best is None: sampled = self._ellipse_edge_axis_radius_sampled_result(plan) if sampled is not None: candidate_edge_id, value, other_value, error = sampled return ( f"Result check: nearest_edge={candidate_edge_id}, sampled_{axis_kind}_radius={value:.6g}, " f"target_error={error:.6g}, sampled_other_radius={other_value:.6g}; " "the refreshed edge is no longer an analytic ellipse." ) return "Result check: no ellipse-like Edge was recognized after the edit; inspect the refreshed B-Rep." error, candidate_edge_id, value, info = best other_key = "major_radius" if axis_kind == "minor" else "minor_radius" other_value = _float_or_none(info.get(other_key)) return ( f"Result check: nearest_edge={candidate_edge_id}, " f"nearest_{axis_kind}_radius={value:.6g}, " f"target_error={error:.6g}, " f"other_radius={other_value:.6g}." if other_value is not None else ( f"Result check: nearest_edge={candidate_edge_id}, " f"nearest_{axis_kind}_radius={value:.6g}, target_error={error:.6g}." ) ) def _ellipse_edge_axis_radius_sampled_result( self, plan: dict[str, object], ) -> tuple[int, float, float, float] | None: center = _tuple_or_none(plan.get("affine_axis_point")) axis_direction = _tuple_normalized(_tuple_or_none(plan.get("affine_axis_direction"))) other_direction = _tuple_normalized(_tuple_or_none(plan.get("ellipse_other_axis_direction"))) target_radius = _float_or_none(plan.get("ellipse_target_radius")) if center is None or axis_direction is None or other_direction is None or target_radius is None: return None part_id = int(plan.get("part_id", -1)) solid_id = int(plan.get("solid_id", -1)) best: tuple[int, float, float, float] | None = None for edge_id in range(len(self.edges)): if part_id >= 0 and self.edge_part_ids[edge_id] != part_id: continue if solid_id >= 0 and self.edge_solid_ids[edge_id] != solid_id: continue extents = self._sample_edge_axis_extents(edge_id, center, axis_direction, other_direction) if extents is None: continue axis_radius, other_radius = extents error = abs(axis_radius - target_radius) if best is None or error < best[3]: best = (edge_id, axis_radius, other_radius, error) return best def _sample_edge_axis_extents( self, edge_id: int, center: tuple[float, float, float], axis_direction: tuple[float, float, float], other_direction: tuple[float, float, float], ) -> tuple[float, float] | None: if edge_id < 0 or edge_id >= len(self.edges): return None curve = BRepAdaptor_Curve(self.edges[edge_id]) first = float(curve.FirstParameter()) last = float(curve.LastParameter()) if not math.isfinite(first) or not math.isfinite(last) or abs(last - first) <= 1e-12: return None sample_count = 1025 axis_extent = 0.0 other_extent = 0.0 for index in range(sample_count): parameter = first + (last - first) * index / (sample_count - 1) point = _point_tuple(curve.Value(parameter)) relative = _tuple_sub(point, center) axis_extent = max(axis_extent, abs(_tuple_dot(relative, axis_direction))) other_extent = max(other_extent, abs(_tuple_dot(relative, other_direction))) if axis_extent <= 1e-9 and other_extent <= 1e-9: return None return axis_extent, other_extent def straight_edge_length_plan(self, edge_id: int, target_length: float) -> dict[str, object]: return self.general_edge_length_plan(edge_id, target_length, anchor_mode="auto") def _straight_edge_length_end_face_candidate( self, edge_info: dict[str, object], delta_length: float, anchor_mode: str = "auto", ) -> dict[str, object] | None: start = _tuple_or_none(edge_info.get("start_point")) end = _tuple_or_none(edge_info.get("end_point")) if start is None or end is None: return None axis = _tuple_normalized(_tuple_sub(end, start)) if axis is None: return None solid_id = int(edge_info.get("solid_id", -1)) if solid_id < 0 or solid_id >= len(self.solids): return None solid = self.solids[solid_id][1] tolerance = max(_shape_diagonal(solid) * 1e-5, abs(delta_length) * 1e-5, 1e-4) candidates: list[tuple[float, dict[str, object]]] = [] anchor_mode = self._edge_length_anchor_mode(anchor_mode) if anchor_mode == "keep-start": endpoint_specs = [("end", "终点端", end, _tuple_scale(axis, delta_length))] elif anchor_mode == "keep-end": endpoint_specs = [("start", "起点端", start, _tuple_scale(axis, -delta_length))] elif anchor_mode == "center": endpoint_specs = [] else: endpoint_specs = [ ("start", "起点端", start, _tuple_scale(axis, -delta_length)), ("end", "终点端", end, _tuple_scale(axis, delta_length)), ] for endpoint_role, endpoint_label, endpoint, desired_vector in endpoint_specs: desired_unit = _tuple_normalized(desired_vector) if desired_unit is None: continue for face_id, face in enumerate(self.faces): if self.face_solid_ids[face_id] != solid_id: continue surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: continue plane = surf.Plane() plane_origin = _point_tuple(plane.Location()) plane_normal = _tuple_normalized(_dir_tuple(plane.Axis().Direction())) if plane_normal is None: continue plane_distance = abs(_tuple_dot(_tuple_sub(endpoint, plane_origin), plane_normal)) if plane_distance > tolerance: continue axis_alignment = abs(_tuple_dot(plane_normal, axis)) if axis_alignment < 0.82: continue face_info = self.face_info(face_id) outward = _tuple_normalized(_tuple_or_none(face_info.get("push_pull_outward_direction"))) if outward is None: continue movement_alignment = abs(_tuple_dot(outward, desired_unit)) if movement_alignment < 0.82: continue push_pull_distance = _tuple_dot(desired_vector, outward) if abs(push_pull_distance) <= 1e-9: continue confidence_bonus = 0.0 if face_info.get("push_pull_confidence") == "high" else 0.2 score = plane_distance / max(tolerance, 1e-9) + (1.0 - movement_alignment) + confidence_bonus candidates.append( ( score, { "end_face_id": face_id, "end_face_endpoint_role": endpoint_role, "end_face_label": endpoint_label, "end_face_plane_distance": plane_distance, "end_face_axis_alignment": axis_alignment, "end_face_movement_alignment": movement_alignment, "end_face_outward_direction": outward, "end_face_push_pull_confidence": face_info.get("push_pull_confidence", ""), "push_pull_distance": push_pull_distance, "desired_movement_vector": desired_vector, }, ) ) if not candidates: return None candidates.sort(key=lambda item: item[0]) return candidates[0][1] def existing_fillet_resize_plan(self, face_id: int, target_radius: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) if info.get("surface") != "cylinder" or "radius" not in info: return { "status": "blocked", "risk": "blocked", "message": "当前选中的 Face 不是圆柱圆角面,不能修改已有圆角半径。", "blockers": "当前选中的 Face 不是圆柱圆角面。", "warnings": "", "face_id": face_id, } feature = self.feature_info(face_id) feature_guess = str(info.get("feature_guess", "")) current_radius = float(feature.get("existing_fillet_radius_estimate", info["radius"])) support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ())) warnings: list[str] = [] blockers: list[str] = [] risk = "medium" status = "caution" if feature_guess != "round/fillet candidate": blockers.append("当前圆柱面没有被识别为已有圆角/倒圆候选。") if target_radius <= 0: blockers.append("目标圆角半径必须大于 0。") if current_radius <= 0: blockers.append("当前圆角半径估算无效。") if current_radius > 0 and abs(target_radius - current_radius) <= max(current_radius * 1e-5, 1e-6): blockers.append("目标圆角半径与当前估算半径几乎相同,不需要修改。") if len(support_face_ids) < 2: blockers.append("当前版本只对识别到至少两个支撑Face的已有圆角候选开放。") part_id = int(info.get("part_id", -1)) part_stats = None try: part_stats = self.part_topology_stats(part_id) except Exception: part_stats = None if part_stats is not None and part_stats.solids != 1: blockers.append( f"当前零件包含 {part_stats.solids} 个Solid;已有圆角半径修改当前版本只对单Solid零件开放。" ) height_estimate = float(info.get("height_estimate", 0.0)) angular_span = float(info.get("angular_span", 0.0)) radius_delta = target_radius - current_radius radius_delta_ratio = abs(radius_delta) / max(current_radius, 1e-9) if not blockers: if radius_delta_ratio > 1.0: risk = "high" warnings.append("目标半径变化超过当前半径的 100%,defeature/refillet 很可能失败。") elif radius_delta_ratio > 0.35: risk = _max_risk(risk, "high") warnings.append("目标半径变化超过当前半径的 35%,请谨慎检查结果。") if height_estimate > 0 and target_radius > height_estimate * 0.5: risk = _max_risk(risk, "high") warnings.append("目标半径超过圆角长度估算的一半,几何比例异常。") if angular_span > math.pi * 1.25: risk = _max_risk(risk, "high") warnings.append("当前圆角圆弧跨度较大,可能不是普通边圆角。") if str(info.get("confidence", "low")) != "high": warnings.append("已有圆角识别置信度不是 high,执行结果需要重点检查。") if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers + warnings) else: message = "将尝试先移除已有圆角面,再在恢复出的锐边上按目标半径重新倒圆。" if warnings: message += " " + " ".join(warnings) return { "status": status, "risk": risk, "message": message, "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id"), "feature_type": feature.get("feature_type"), "feature_guess": feature_guess, "confidence": info.get("confidence"), "current_radius": current_radius, "target_radius": target_radius, "delta_radius": radius_delta, "radius_delta_ratio": radius_delta_ratio, "height_estimate": info.get("height_estimate"), "angular_span": info.get("angular_span"), "axis_point": info.get("axis_point"), "axis": info.get("axis"), "feature_existing_fillet_support_face_ids": support_face_ids, "feature_boundary_edge_ids": feature.get("feature_boundary_edge_ids"), "resize_strategy": "defeature-existing-fillet-face-then-refillet-axis-edge", "edit_strategy_label": "移除旧圆角并重新倒圆", "edit_semantics": "先移除当前已有圆角面,再在恢复出的锐边上按目标半径重新倒圆;复杂 blend 可能失败并回滚。", "resize_note": ( "当前版本的已有圆角半径修改只支持由圆柱面表示的直线边圆角。" "执行后 Face/Edge ID 会重建,请重新选择对象确认结果。" ), } def _push_pull_inward_material_depth( self, face_id: int, outward: tuple[float, float, float] | None, ) -> float | None: if face_id < 0 or face_id >= len(self.faces): return None direction = _tuple_normalized(outward) if direction is None: return None solid_id = self.face_solid_ids[face_id] if solid_id < 0 or solid_id >= len(self.solids): return None try: props = GProp_GProps() brepgprop.SurfaceProperties(self.faces[face_id], props) interval = _shape_axis_interval(self.solids[solid_id][1], props.CentreOfMass(), gp_Dir(*direction)) except Exception: return None if interval is None: return None inward_depth = max(-float(interval[0]), 0.0) return inward_depth if inward_depth > 1e-9 else None def _push_pull_plan_face_info( self, face_id: int, surf: BRepAdaptor_Surface, ) -> dict[str, object]: quick_info = self.quick_face_info(face_id) cap_direction = self._cylindrical_cap_push_pull_direction(face_id, surf) if cap_direction is not None: info = dict(quick_info) info.update(cap_direction) return info return self.face_info(face_id) def push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: return { "status": "blocked", "risk": "blocked", "message": "当前选中的 Face 不是平面,不能执行拉伸/切除平面。", "blockers": "当前选中的 Face 不是平面。", "warnings": "", "face_id": face_id, "part_id": self.face_part_ids[face_id], "solid_id": self.face_solid_ids[face_id], "distance": distance, } info = self._push_pull_plan_face_info(face_id, surf) first_level_fields = self._face_first_level_plan_fields(face_id) scope_face_ids = self._connected_coplanar_planar_face_ids(face_id) scope_area = 0.0 for scope_face_id in scope_face_ids: try: scope_area += float(self.quick_face_info(scope_face_id).get("area") or 0.0) except Exception: try: scope_area += float(self.face_info(scope_face_id).get("area") or 0.0) except Exception: pass if len(scope_face_ids) > 1: scope_note = f"将一起拉伸/切除 {len(scope_face_ids)} 个共面且相接/重叠的Face,减少 STEP 碎面导致的贴块缝。" else: scope_note = "只拉伸/切除当前 Face。" direction_confidence = str(info.get("push_pull_confidence", "low")) bbox_diagonal = float(info.get("bbox_diagonal", 0.0)) distance_abs = abs(distance) face_distance_ratio = distance_abs / bbox_diagonal if bbox_diagonal > 1e-9 else None plane_origin = _tuple_or_none(info.get("plane_origin")) outward_direction = _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction"))) owning_axis_span: float | None = None distance_to_owning_axis_span_ratio: float | None = None if distance > 0 and plane_origin is not None and outward_direction is not None: source_shape = None solid_id_for_extent = _int_or_none(info.get("solid_id")) if solid_id_for_extent is not None and 0 <= solid_id_for_extent < len(self.solids): source_shape = self.solids[solid_id_for_extent][1] else: part_id_for_extent = _int_or_none(info.get("part_id")) part_for_extent = self.part_by_id(part_id_for_extent) if part_id_for_extent is not None else None source_shape = part_for_extent.shape if part_for_extent is not None else None if source_shape is not None: try: axis_interval = _shape_axis_interval( source_shape, gp_Pnt(*plane_origin), gp_Dir(*outward_direction), ) except Exception: axis_interval = None if axis_interval is not None: owning_axis_span = max(float(axis_interval[1]) - float(axis_interval[0]), 0.0) if owning_axis_span > 1e-9: distance_to_owning_axis_span_ratio = distance_abs / owning_axis_span warnings: list[str] = [] blockers: list[str] = [] risk = "low" status = "ready" inward_material_depth = ( self._push_pull_inward_material_depth( face_id, outward_direction, ) if distance < 0 else None ) inward_cut_ratio = ( distance_abs / inward_material_depth if distance < 0 and inward_material_depth is not None and inward_material_depth > 1e-9 else None ) if distance_abs <= 1e-9: status = "blocked" risk = "blocked" blockers.append("拉伸/切除距离为 0,不需要修改。") if direction_confidence != "high": risk = _max_risk(risk, "medium") warnings.append("拉伸/切除方向判断置信度较低,可能不是期望的内外方向。") if bbox_diagonal > 0 and distance_abs > bbox_diagonal * 5.0: risk = "blocked" status = "blocked" blockers.append("拉伸/切除距离超过当前 Face 尺寸的 5 倍,容易生成过大布尔体或影响无关几何。") elif bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.2: risk = _max_risk(risk, "high") warnings.append("拉伸/切除距离超过当前 Face 包围盒对角线的 20%,容易导致布尔失败或大范围变形。") elif bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.08: risk = _max_risk(risk, "medium") warnings.append("拉伸/切除距离相对当前 Face 尺寸偏大,请确认预览范围。") if inward_cut_ratio is not None and inward_material_depth is not None: depth_tolerance = max( inward_material_depth * 1e-5, bbox_diagonal * 1e-7 if bbox_diagonal > 0 else 0.0, 1e-6, ) if distance_abs >= inward_material_depth - depth_tolerance: status = "blocked" risk = "blocked" blockers.append( "向内切削距离达到或超过当前面背后的材料厚度;继续执行很可能把实体切空或生成无效几何。" ) elif inward_cut_ratio >= 0.85: risk = _max_risk(risk, "high") warnings.append("向内切削距离已经接近当前面背后的材料厚度,剩余壁厚很薄,请谨慎确认。") elif inward_cut_ratio >= 0.6: risk = _max_risk(risk, "medium") warnings.append("向内切削距离超过当前面背后材料厚度的 60%,请确认不会切穿。") cap_extension_info: dict[str, object] | None = None if abs(distance) > 1e-9: outward_for_cap = outward_direction if outward_for_cap is not None: try: cap_extension_info = self._cylindrical_cap_extension_plan(face_id, distance, outward_for_cap) except Exception: cap_extension_info = None if cap_extension_info is not None: old_height = _float_or_none(cap_extension_info.get("old_height")) new_height = _float_or_none(cap_extension_info.get("new_height")) radius = _float_or_none(cap_extension_info.get("radius")) extra_adjacent_count = int(cap_extension_info.get("cap_extra_adjacent_face_count") or 0) if extra_adjacent_count > 0: cap_method = str(cap_extension_info.get("cap_extension_method") or "") if cap_method == "cap-profile-prism" and distance > 0: risk = _max_risk(risk, "high") warnings.append( "已识别为圆柱/筒体端盖,端面边界还连接了槽、缺口或台阶等额外一级相邻 Face;" "本次向外拉伸会改用当前端盖真实轮廓拉伸,让这些开口边界跟随延长,避免通用大布尔长时间计算。" ) elif cap_method == "cap-profile-prism": risk = _max_risk(risk, "high") warnings.append( "已识别为圆柱/筒体端盖,端面边界还连接了槽、缺口或台阶等额外一级相邻 Face;" "本次向内切除没有越过这些开口的内侧终点,会改用当前端盖真实轮廓切削,让开口边界跟随缩短。" ) else: status = "blocked" risk = "blocked" target_parameter = _float_or_none(cap_extension_info.get("cap_profile_prism_target_parameter")) limit_parameter = _float_or_none(cap_extension_info.get("cap_profile_prism_retract_limit_parameter")) limit_text = "" if target_parameter is not None and limit_parameter is not None: limit_text = ( f"目标端面轴向位置约 {_format_result_number(target_parameter)}," f"额外开口内侧终点约 {_format_result_number(limit_parameter)};" ) blockers.append( "已识别为圆柱/筒体端盖,但端面边界还连接了槽、缺口或台阶等额外一级相邻 Face;" f"{limit_text}" "本次向内切除会越过这些开口/台阶的内侧终点,当前版本还不能判断槽底或台阶是否应一起移动。" "已快速阻止,避免留下残余台阶、长时间布尔或丢失这些特征。" ) simple_cap_rebuild = self._simple_cylindrical_cap_extension_rebuild_available( face_id, cap_extension_info, ) growth_ratio = ( new_height / old_height if old_height is not None and old_height > 1e-9 and new_height is not None else None ) distance_to_height_ratio = ( distance_abs / old_height if old_height is not None and old_height > 1e-9 else None ) if status != "blocked": if distance < 0 and growth_ratio is not None and growth_ratio < 0.2: risk = _max_risk(risk, "high") warnings.append( "当前圆柱/筒体端面向内切除后剩余高度低于原高度的 20%;" "已识别端盖一级关系,执行时会优先切除旧端盖到新端盖之间的局部段。" ) elif distance < 0: if str(cap_extension_info.get("cap_extension_method") or "") == "cap-profile-prism": warnings.append( "已识别圆柱/筒体端盖一级关系,向内切除会优先走端盖真实轮廓切削," "避免通用大范围 prism 切削过慢。" ) else: warnings.append( "已识别圆柱/筒体端盖一级关系,向内切除会优先切除旧端盖到新端盖之间的局部段," "避免通用平面 prism 切削过慢。" ) elif growth_ratio is not None and growth_ratio > 3.0 and simple_cap_rebuild: risk = _max_risk(risk, "high") warnings.append( "当前圆柱端面会一次性加长很多;已识别为简单圆柱/筒体端盖,执行时会优先走解析重建," "避免通用布尔长时间计算。" ) elif growth_ratio is not None and growth_ratio > 3.0: risk = _max_risk(risk, "high") warnings.append( "当前操作会把圆柱高度一次性放大到原来的 " f"{_format_result_number(growth_ratio)} 倍;已识别为圆柱/筒体端盖一级关系,执行时会只生成旧端盖到新端盖之间的局部延长段," "避免拿整根新圆柱做布尔。复杂 STEP 上仍建议检查结果。" ) elif distance_to_height_ratio is not None and distance_to_height_ratio > 1.0: risk = _max_risk(risk, "high") warnings.append( "当前圆柱端面拉伸/切除距离已经超过圆柱原高度,布尔延长可能较慢;建议优先小幅修改。" ) elif radius is not None and radius > 0 and distance_abs > radius * 4.0: risk = _max_risk(risk, "high") warnings.append( "当前圆柱端面拉伸/切除距离明显大于圆柱半径,可能生成很长的补料体;建议优先小幅修改。" ) boundary_shell_extension_info: dict[str, object] | None = None if status != "blocked" and cap_extension_info is None and outward_direction is not None: boundary_shell_extension_info = self._planar_cap_boundary_shell_extension_plan( face_id, float(distance), outward_direction, scope_face_ids, ) if boundary_shell_extension_info is not None: risk = _max_risk(risk, "medium") if distance > 0.0: warnings.append( "已识别为带内孔/多边界的平面端盖;本次向外拉伸会移动当前端面," "并沿所有一级边界 Edge 生成延长侧壁后局部缝合,避免复杂 STEP 的通用布尔长时间计算。" ) else: warnings.append( "已识别为带内孔/多边界的平面端盖;本次向内收缩会移动当前端面," "并沿所有一级边界 Edge 重建侧壁后局部缝合,避免复杂 STEP 的通用布尔长时间计算。" ) if ( status != "blocked" and cap_extension_info is None and boundary_shell_extension_info is None and len(self.faces) > 600 and int(info.get("inner_boundary_wires", 0) or 0) > 0 and distance < 0.0 ): status = "blocked" risk = "blocked" first_level_adjacent = int(first_level_fields.get("first_level_adjacent_face_count", 0) or 0) first_level_edges = int(first_level_fields.get("first_level_boundary_edge_count", 0) or 0) inner_wires = int(info.get("inner_boundary_wires", 0) or 0) blockers.append( f"当前 Face {face_id} 是复杂大 STEP 中带内孔/多边界的平面端盖:" f"内边界 {inner_wires} 个,一级边界 Edge {first_level_edges} 条," f"共享边一级相邻 Face {first_level_adjacent} 个。" "但没有识别到可安全向内切削的端盖一级关系。" "向内切除这类面通常需要判断孔壁、槽底、台阶内侧终点或其它相邻特征是否一起移动," "这已经涉及一级相邻面背后的二级关系;当前阶段只自动处理一级关系。" "已快速阻止,避免进入通用 OCCT 布尔后长时间卡住、超时或生成无效 B-Rep。" ) if status != "blocked" and cap_extension_info is None: if distance > 0 and ( ( distance_to_owning_axis_span_ratio is not None and distance_to_owning_axis_span_ratio > 1.0 ) or (face_distance_ratio is not None and face_distance_ratio > 0.6) ): risk = _max_risk(risk, "high") warnings.append( "本次向外拉伸距离相对当前 Face 或所属实体跨度较大,可能需要较长 OCCT 布尔计算;" "程序会在隔离子进程中执行,并在失败时保持原模型不变。" ) elif ( distance > 0 and distance_to_owning_axis_span_ratio is not None and distance_to_owning_axis_span_ratio > 0.6 ): risk = _max_risk(risk, "high") warnings.append( "本次向外拉伸距离已经超过所属实体当前法向跨度的 60%,布尔延长可能较慢;" "建议优先小幅修改。" ) if risk in {"medium", "high"} and status != "blocked": status = "caution" if blockers: message = " ".join(blockers + warnings) elif warnings: message = " ".join(warnings) else: message = "可以尝试拉伸/切除该平面。" plane_origin = _tuple_or_none(info.get("plane_origin")) outward_direction = _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction"))) current_plane_position = None target_plane_position = None if plane_origin is not None and outward_direction is not None: current_plane_position = _tuple_dot(plane_origin, outward_direction) target_plane_position = current_plane_position + float(distance) return { "status": status, "risk": risk, "message": message, "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": info["part_id"], "solid_id": info["solid_id"], **first_level_fields, "distance": distance, "surface": info.get("surface"), "area": info.get("area"), "bbox_diagonal": info.get("bbox_diagonal"), "push_pull_distance_to_face_diagonal_ratio": face_distance_ratio, "push_pull_owning_axis_span": owning_axis_span, "push_pull_distance_to_owning_axis_span_ratio": distance_to_owning_axis_span_ratio, "plane_origin": plane_origin, "plane_direction": outward_direction, "current_plane_position": current_plane_position, "target_plane_position": target_plane_position, "push_pull_inward_material_depth": inward_material_depth, "push_pull_inward_cut_ratio": inward_cut_ratio, "cylindrical_cap_extension_old_height": ( cap_extension_info.get("old_height") if cap_extension_info is not None else None ), "cylindrical_cap_extension_new_height": ( cap_extension_info.get("new_height") if cap_extension_info is not None else None ), "cylindrical_cap_extension_radius": ( cap_extension_info.get("radius") if cap_extension_info is not None else None ), "cylindrical_cap_extension_inner_radius": ( cap_extension_info.get("inner_radius") if cap_extension_info is not None else None ), "cylindrical_cap_extension_kind": ( cap_extension_info.get("cap_extension_kind") if cap_extension_info is not None else None ), "cylindrical_cap_operation": ( cap_extension_info.get("cap_operation") if cap_extension_info is not None else None ), "cylindrical_cap_extension_height": ( cap_extension_info.get("extension_height") if cap_extension_info is not None else None ), "cylindrical_cap_extension_uses_local_segment": bool( cap_extension_info is not None and cap_extension_info.get("extension_shape") is not None ), "cylindrical_cap_extension_method": ( cap_extension_info.get("cap_extension_method") if cap_extension_info is not None else None ), "cap_extra_adjacent_face_ids": ( cap_extension_info.get("cap_extra_adjacent_face_ids") if cap_extension_info is not None else () ), "cap_extra_adjacent_face_count": ( cap_extension_info.get("cap_extra_adjacent_face_count") if cap_extension_info is not None else 0 ), "cap_scope_face_ids": ( cap_extension_info.get("cap_scope_face_ids") if cap_extension_info is not None else () ), "cap_profile_prism_target_parameter": ( cap_extension_info.get("cap_profile_prism_target_parameter") if cap_extension_info is not None else None ), "cap_profile_prism_retract_limit_parameter": ( cap_extension_info.get("cap_profile_prism_retract_limit_parameter") if cap_extension_info is not None else None ), "planar_cap_extension_kind": ( (boundary_shell_extension_info or {}).get("cap_extension_kind") ), "planar_cap_extension_method": ( (boundary_shell_extension_info or {}).get("cap_extension_method") ), "planar_cap_boundary_edge_count": int( (boundary_shell_extension_info or {}).get("cap_boundary_edge_count") or 0 ), "planar_cap_bridge_face_count": int( (boundary_shell_extension_info or {}).get("bridge_face_count") or 0 ), "planar_cap_inner_boundary_wires": int( (boundary_shell_extension_info or {}).get("inner_boundary_wires") or 0 ), "planar_cap_adjacent_face_count": int( (boundary_shell_extension_info or {}).get("adjacent_face_count") or 0 ), "outward_direction": outward_direction, "direction_confidence": direction_confidence, "direction_note": info.get("push_pull_note"), "push_pull_scope_face_ids": tuple(scope_face_ids), "push_pull_scope_face_count": len(scope_face_ids), "push_pull_scope_area": scope_area, "push_pull_scope_note": scope_note, } def face_plane_offset_frame( self, face_id: int, ) -> tuple[tuple[float, float, float], tuple[float, float, float], float] | None: if face_id < 0 or face_id >= len(self.faces): return None info = self.quick_face_info(face_id) if str(info.get("surface", "")) != "plane": return None origin = _tuple_or_none(info.get("plane_origin")) direction = ( _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction"))) or _tuple_normalized(_tuple_or_none(info.get("oriented_normal"))) or _tuple_normalized(_tuple_or_none(info.get("normal"))) ) if origin is None or direction is None: return None current_position = _tuple_dot(origin, direction) return origin, direction, current_position def face_plane_offset_local_plan(self, face_id: int, distance: float) -> dict[str, object]: try: distance = float(distance) except (TypeError, ValueError): distance = 0.0 frame = None blockers = ["目标偏移变换位置必须是数字。"] else: frame = self.face_plane_offset_frame(face_id) blockers = [] if face_id < 0 or face_id >= len(self.faces): blockers.append(f"Unknown face id {face_id}") if frame is None: blockers.append("当前 Face 缺少稳定平面方向或基准点,不能执行偏移变换(局部重建)。") current_center = None target_center = None if 0 <= face_id < len(self.faces): info = self.face_info(face_id) current_center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) if current_center is None: blockers.append("当前 Face 缺少稳定中心坐标。") plane_origin = None plane_direction = None current_position = None target_position = None if frame is not None: plane_origin, plane_direction, current_position = frame target_position = current_position + distance if current_center is not None and plane_direction is not None: move_vector = _tuple_scale(plane_direction, distance) target_center = _tuple_add(current_center, move_vector) else: move_vector = (0.0, 0.0, 0.0) if blockers: return { "status": "blocked", "risk": "blocked", "message": " ".join(blockers), "warnings": "", "blockers": ";".join(blockers), "face_id": face_id, "current_plane_position": current_position, "target_plane_position": target_position, "plane_origin": plane_origin, "plane_direction": plane_direction, "plane_offset_distance": distance, "current_face_center": current_center, "target_face_center": target_center, "face_center_move_vector": move_vector, "resize_strategy": "local-face-plane-offset-deform", "edit_strategy_label": "偏移变换(局部重建)", "edit_semantics": "把目标偏移变换位置换算成沿当前面垂直方向的移动量,移动当前 Face 并重建相邻平面。", } plan = self.face_center_local_move_plan(face_id, target_center) plan.update( { "current_plane_position": current_position, "target_plane_position": target_position, "plane_origin": plane_origin, "plane_direction": plane_direction, "plane_offset_distance": distance, "resize_strategy": "local-face-plane-offset-deform", "edit_strategy_label": "偏移变换(局部重建)", "edit_semantics": ( "把目标偏移变换位置换算成沿当前面垂直方向的移动量,移动当前 Face 的顶点并重建相邻平面;" "不拉伸/切除加料/切削,也不平移所属对象。" ), } ) return plan def face_plane_offset_owning_translation_plan(self, face_id: int, distance: float) -> dict[str, object]: try: distance = float(distance) except (TypeError, ValueError): distance = 0.0 frame = None blockers = ["目标偏移变换位置必须是数字。"] else: frame = self.face_plane_offset_frame(face_id) blockers = [] if face_id < 0 or face_id >= len(self.faces): blockers.append(f"Unknown face id {face_id}") info: dict[str, object] = {} else: info = self.face_info(face_id) if frame is None: blockers.append("当前 Face 缺少稳定平面方向或基准点,不能按偏移变换平移所属对象。") plane_origin = None plane_direction = None current_position = None target_position = None vector = (0.0, 0.0, 0.0) if frame is not None: plane_origin, plane_direction, current_position = frame target_position = current_position + distance vector = _tuple_scale(plane_direction, distance) part_id = int(info.get("part_id", -1)) if info else -1 solid_id = int(info.get("solid_id", -1)) if info else -1 part = self.part_by_id(part_id) if part_id >= 0 else None part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0 target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" if part is None: blockers.append("找不到当前 Face 所属特征。") if target_kind == "solid" and not (0 <= solid_id < len(self.solids)): blockers.append("找不到当前 Face 所属 Solid。") if blockers: return { "status": "blocked", "risk": "blocked", "message": " ".join(blockers), "warnings": "", "blockers": ";".join(blockers), "face_id": face_id, "part_id": part_id, "solid_id": solid_id, **self._face_first_level_plan_fields(face_id), "surface": info.get("surface"), "target_kind": target_kind, "current_plane_position": current_position, "target_plane_position": target_position, "plane_origin": plane_origin, "plane_direction": plane_direction, "plane_offset_distance": distance, "translation_vector": vector, "resize_strategy": "translate-owning-shape-from-plane-offset", "translate_strategy": f"translate-{target_kind}", "edit_strategy_label": "按偏移变换平移所属对象", "edit_semantics": "把目标偏移变换位置换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid。", } plan = self.translate_solid_plan(solid_id, vector) if target_kind == "solid" else self.translate_part_plan(part_id, vector) distance_ratio = 0.0 diagonal = float(plan.get("bbox_diagonal") or 0.0) if diagonal > 1e-9: distance_ratio = abs(distance) / diagonal if distance_ratio > 5.0: blocker = "目标偏移变换位置需要移动的距离超过所属对象尺寸的 5 倍,容易把特征移动到远离模型的位置。" blockers = [part for part in str(plan.get("blockers") or "").split(";") if part] blockers.append(blocker) plan["status"] = "blocked" plan["risk"] = "blocked" plan["blockers"] = ";".join(blockers) plan["message"] = " ".join(blockers) plan.update( { "face_id": face_id, **self._face_first_level_plan_fields(face_id), "surface": info.get("surface"), "current_plane_position": current_position, "target_plane_position": target_position, "plane_origin": plane_origin, "plane_direction": plane_direction, "plane_offset_distance": distance, "face_offset_distance_ratio": distance_ratio, "resize_strategy": "translate-owning-shape-from-plane-offset", "translate_strategy": f"translate-{target_kind}", "edit_strategy_label": "按偏移变换平移所属对象", "edit_semantics": "把目标偏移变换位置换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid;不拉伸/切除,不切削,也不补料。", } ) return plan def face_center_owning_translation_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.quick_face_info(face_id) target = _tuple_or_none(target_center) current_center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) part_id = int(info.get("part_id", -1)) solid_id = int(info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0 target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" blockers: list[str] = [] if current_center is None: blockers.append("当前 Face 缺少稳定中心坐标,不能按中心平移所属对象。") if target is None: blockers.append("目标 Face 中心必须是有效的 X/Y/Z 坐标。") if part is None: blockers.append("找不到当前 Face 所属特征。") if target_kind == "solid" and not (0 <= solid_id < len(self.solids)): blockers.append("找不到当前 Face 所属 Solid。") vector = (0.0, 0.0, 0.0) move_distance = 0.0 move_ratio = 0.0 diagonal = 0.0 if current_center is not None and target is not None: vector = _tuple_sub(target, current_center) move_distance = _vector_length(vector) source_shape = ( self.solids[solid_id][1] if target_kind == "solid" and 0 <= solid_id < len(self.solids) else (part.shape if part is not None else None) ) diagonal = _shape_diagonal(source_shape) if source_shape is not None else 0.0 if diagonal > 1e-9: move_ratio = move_distance / diagonal if move_distance <= max(diagonal * 1e-7, 1e-7): blockers.append("目标 Face 中心与当前中心几乎相同,不需要移动。") elif move_ratio > 5.0: blockers.append("目标 Face 中心移动距离超过所属对象尺寸的 5 倍,容易生成极端变形或把特征移到远离模型的位置。") if blockers: return { "status": "blocked", "risk": "blocked", "message": " ".join(blockers), "warnings": "", "blockers": ";".join(blockers), "face_id": face_id, "part_id": part_id, "solid_id": solid_id, **self._face_first_level_plan_fields(face_id), "surface": info.get("surface"), "target_kind": target_kind, "current_face_center": current_center, "target_face_center": target, "translation_vector": vector, "translation_distance": move_distance, "face_center_move_vector": vector, "face_center_move_distance": move_distance, "face_center_move_ratio": move_ratio, "bbox_diagonal": diagonal, "resize_strategy": "translate-owning-shape-from-face-center", "translate_strategy": f"translate-{target_kind}", "edit_strategy_label": "平移 Face 所属对象", "edit_semantics": "把目标 Face 的中心坐标换算成平移量,并平移所属特征或 Solid;不做单面局部扭曲。", } plan = self.translate_solid_plan(solid_id, vector) if target_kind == "solid" else self.translate_part_plan(part_id, vector) diagonal = float(plan.get("bbox_diagonal") or diagonal or 0.0) if diagonal > 1e-9: move_ratio = move_distance / diagonal plan.update( { "face_id": face_id, **self._face_first_level_plan_fields(face_id), "surface": info.get("surface"), "current_face_center": current_center, "target_face_center": target, "face_center_move_vector": vector, "face_center_move_distance": move_distance, "face_center_move_ratio": move_ratio, "resize_strategy": "translate-owning-shape-from-face-center", "translate_strategy": f"translate-{target_kind}", "edit_strategy_label": "平移 Face 所属对象", "edit_semantics": "把目标 Face 的中心坐标换算成平移量,并平移所属特征或 Solid;不做单面局部扭曲。", } ) return plan def move_face_center_owning( self, face_id: int, target_center: tuple[float, float, float], ) -> str: plan = self.face_center_owning_translation_plan(face_id, target_center) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) vector = tuple(plan["translation_vector"]) result, result_check = self._run_checked_face_edit( plan, lambda: ( self.translate_solid(int(plan["solid_id"]), vector) if plan.get("target_kind") == "solid" else self.translate_part(int(plan["part_id"]), vector) ), ) return ( "Face center move completed by owning-shape translation: " f"face {face_id}, " f"current_center={plan.get('current_face_center')}, " f"target_center={plan.get('target_face_center')}, " f"move={plan.get('face_center_move_vector')}, " f"target={plan.get('target_kind')}, " f"risk={plan['risk']}. {result_check} {result}" ) def move_face_plane_offset_local(self, face_id: int, distance: float) -> str: plan = self.face_plane_offset_local_plan(face_id, distance) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) _action_result, result_check = self._run_checked_face_edit( plan, lambda: self._apply_local_face_deform(plan), ) return ( "Face plane offset completed by local face-only deformation: " f"face {face_id}, " f"current_position={float(plan['current_plane_position']):g}, " f"target_position={float(plan['target_plane_position']):g}, " f"distance={float(plan['plane_offset_distance']):g}, " f"moved_points={plan.get('local_face_deform_moved_point_count')}, " f"rebuilt_faces={plan.get('local_face_deform_face_count')}, " f"target={plan.get('local_face_deform_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) def translate_face_plane_offset_owning(self, face_id: int, distance: float) -> str: plan = self.face_plane_offset_owning_translation_plan(face_id, distance) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) result, result_check = self._run_checked_face_edit( plan, lambda: ( self.translate_solid(int(plan["solid_id"]), tuple(plan["translation_vector"])) if plan.get("target_kind") == "solid" else self.translate_part(int(plan["part_id"]), tuple(plan["translation_vector"])) ), ) return ( "Face plane offset completed by owning-shape translation: " f"face {face_id}, " f"current_position={float(plan['current_plane_position']):g}, " f"target_position={float(plan['target_plane_position']):g}, " f"distance={float(plan['plane_offset_distance']):g}, " f"target={plan.get('target_kind')}, " f"risk={plan['risk']}. " f"{result_check} " f"{result}" ) def shell_thickness_plan(self, face_id: int, target_thickness: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.feature_info(face_id) blockers: list[str] = [] warnings: list[str] = [ "壳体厚度调整基于相对平面几何估算,会移动当前选中平面区域,保留相对平面不动。" ] risk = "low" status = "ready" first_level_fields = self._face_first_level_plan_fields(face_id) try: target_thickness = float(target_thickness) except (TypeError, ValueError): target_thickness = 0.0 blockers.append("目标壳体厚度必须是数字。") current_thickness = float(info.get("shell_thickness_estimate") or 0.0) signed_thickness = float(info.get("shell_signed_thickness") or 0.0) delta_thickness = target_thickness - current_thickness confidence = str(info.get("shell_confidence", "low")) overlap_ratio = float(info.get("shell_overlap_ratio_estimate", 0.0)) source_face_ids = tuple(_int_values(info.get("shell_source_face_ids")) or [face_id]) opposite_face_id = int(info.get("shell_opposite_face_id", -1)) if info.get("surface") != "plane": blockers.append("当前选中 Face 不是平面,不能调整壳体厚度。") if info.get("shell_region_status") != "candidate": blockers.append(str(info.get("shell_region_note", "当前平面没有识别到相对壳体平面。"))) if current_thickness <= 1e-9 or abs(signed_thickness) <= 1e-9: blockers.append("当前壳体厚度估算无效。") if target_thickness <= 1e-9: blockers.append("目标壳体厚度必须大于 0。") if abs(delta_thickness) <= max(current_thickness * 1e-5, 1e-6): blockers.append("目标厚度与当前估算厚度几乎相同,不需要修改。") if current_thickness > 1e-9: thickness_scale = target_thickness / current_thickness if thickness_scale < 0.05: blockers.append("目标壳体厚度会把当前厚度缩到 5% 以下,容易生成退化壳体或无效几何。") elif thickness_scale > 5.0: blockers.append("目标壳体厚度会把当前厚度放大到 5 倍以上,容易导致拉伸/切除布尔失败或大范围变形。") normal = _tuple_normalized(_tuple_or_none(info.get("normal"))) outward = _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction"))) desired_movement = None push_pull_distance = 0.0 movement_alignment = 0.0 if not blockers: if normal is None or outward is None: blockers.append("当前平面缺少稳定法向或拉伸/切除方向,不能换算厚度修改。") else: sign = 1.0 if signed_thickness >= 0.0 else -1.0 toward_opposite = _tuple_scale(normal, sign) desired_movement = _tuple_scale(toward_opposite, -delta_thickness) desired_unit = _tuple_normalized(desired_movement) if desired_unit is None: blockers.append("目标厚度变化量无效。") else: movement_alignment = abs(_tuple_dot(desired_unit, outward)) if movement_alignment < 0.82: blockers.append("当前平面拉伸/切除方向与壳体厚度方向不匹配,暂不执行自动厚度修改。") else: push_pull_distance = _tuple_dot(desired_movement, outward) if not blockers: delta_ratio = abs(delta_thickness) / max(current_thickness, 1e-9) if confidence == "low": risk = _max_risk(risk, "high") warnings.append("壳体相对面识别置信度较低。") elif confidence == "medium": risk = _max_risk(risk, "medium") warnings.append("壳体相对面识别置信度为 medium,执行后请检查周边。") if overlap_ratio < 0.25: risk = _max_risk(risk, "high") warnings.append("相对平面投影重叠率较低,可能不是稳定壳体区域。") elif overlap_ratio < 0.55: risk = _max_risk(risk, "medium") warnings.append("相对平面投影重叠率一般,厚度估算可能偏局部。") if delta_ratio > 0.8: risk = _max_risk(risk, "high") warnings.append("目标厚度变化超过当前厚度的 80%,容易导致布尔失败或周边变形。") elif delta_ratio > 0.35: risk = _max_risk(risk, "medium") warnings.append("目标厚度变化超过当前厚度的 35%,请确认预览。") push_plan = self.push_pull_plan(face_id, push_pull_distance) if push_plan["status"] == "blocked": blockers.append(str(push_plan["message"])) else: risk = _max_risk(risk, str(push_plan["risk"])) push_warnings = str(push_plan.get("warnings", "")) if push_warnings: warnings.append(push_warnings) if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers) push_plan = {} elif risk != "low": status = "caution" message = " ".join(warnings) else: message = "可以通过拉伸/切除当前平面区域调整壳体厚度。" return { "status": status, "risk": risk, "message": message, "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id"), **first_level_fields, "surface": info.get("surface"), "shell_region_kind": info.get("shell_region_kind"), "shell_confidence": confidence, "shell_source_face_ids": source_face_ids, "shell_opposite_face_id": opposite_face_id, "shell_current_thickness": current_thickness, "shell_target_thickness": target_thickness, "shell_delta_thickness": delta_thickness, "shell_delta_ratio": abs(delta_thickness) / max(current_thickness, 1e-9), "shell_signed_thickness": signed_thickness, "shell_overlap_ratio_estimate": overlap_ratio, "shell_opposite_normal_dot": info.get("shell_opposite_normal_dot"), "shell_desired_movement_vector": desired_movement, "shell_movement_alignment": movement_alignment, "push_pull_distance": push_pull_distance, "outward_direction": info.get("push_pull_outward_direction"), "push_pull_status": push_plan.get("status"), "push_pull_risk": push_plan.get("risk"), "push_pull_message": push_plan.get("message"), "push_pull_scope_face_ids": push_plan.get("push_pull_scope_face_ids", source_face_ids), "push_pull_scope_face_count": push_plan.get("push_pull_scope_face_count", len(source_face_ids)), "push_pull_scope_note": push_plan.get("push_pull_scope_note", ""), "resize_strategy": "push-pull-shell-source-plane-to-target-thickness", } def _local_face_plane_size_info( self, face_id: int, *, face: TopoDS_Shape | None = None, surf: BRepAdaptor_Surface | None = None, center: tuple[float, float, float] | None = None, tolerance: float | None = None, ) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): return {} face_shape = face or self.faces[face_id] surface = surf or BRepAdaptor_Surface(face_shape) if surface.GetType() != GeomAbs_Plane: return {} if center is None: try: center = _point_tuple(_surface_center(face_shape)) except Exception: center = None if center is None: return {} if tolerance is None: solid_id = self.face_solid_ids[face_id] if face_id < len(self.face_solid_ids) else -1 source_shape = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else face_shape tolerance = max(_shape_diagonal(source_shape) * 1e-7, 1e-6) points = self._local_deform_face_vertex_points(face_shape, tolerance) if len(points) < 3: return {} normal = _tuple_normalized(_dir_tuple(surface.Plane().Axis().Direction())) if normal is None: return {} if face_shape.Orientation() == TopAbs_REVERSED: normal = _tuple_scale(normal, -1.0) u_dir, v_dir = _plane_basis_dirs(gp_Dir(*normal)) width_dir = _tuple_normalized(_dir_tuple(u_dir)) height_dir = _tuple_normalized(_dir_tuple(v_dir)) if width_dir is None: return {} if height_dir is None: return {} def span_for(axis: tuple[float, float, float]) -> float: values = [_tuple_dot(_tuple_sub(point, center), axis) for point in points] return max(values) - min(values) width = span_for(width_dir) height = span_for(height_dir) if width <= max(tolerance, 1e-9) or height <= max(tolerance, 1e-9): return {} return { "local_face_width": width, "local_face_height": height, "local_face_width_direction": width_dir, "local_face_height_direction": height_dir, "local_face_size_center": center, "local_face_size_source_point_count": len(points), } def face_center_local_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) target = _tuple_or_none(target_center) current_center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) part_id = int(info.get("part_id", -1)) solid_id = int(info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None solid = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else None source_shape = solid or (part.shape if part is not None else None) diagonal = _shape_diagonal(source_shape) if source_shape is not None else 0.0 tolerance = max(diagonal * 1e-7, 1e-6) blockers: list[str] = [] warnings: list[str] = [ "`中心(局部重建)` 会移动这个 Face 的顶点并重建周边平面;相邻面可能变斜或被拆成三角面。" ] risk = "low" status = "ready" first_level_fields = self._face_first_level_plan_fields(face_id) if info.get("surface") != "plane": blockers.append("当前 Face 不是平面,不能执行“中心(局部重建)”。") if current_center is None: blockers.append("当前 Face 缺少稳定中心坐标。") if target is None: blockers.append("目标 Face 中心必须是有效的 X/Y/Z 坐标。") if part is None: blockers.append("找不到当前 Face 所属特征。") if solid is None: blockers.append("找不到当前 Face 所属 Solid。") move_vector = (0.0, 0.0, 0.0) move_distance = 0.0 move_ratio = 0.0 if current_center is not None and target is not None: move_vector = _tuple_sub(target, current_center) move_distance = _vector_length(move_vector) move_ratio = move_distance / max(diagonal, 1e-9) if move_distance <= max(diagonal * 1e-7, 1e-7): blockers.append("目标 Face 中心与当前中心几乎相同,不需要修改。") elif move_ratio > 5.0: blockers.append("目标 Face 中心移动距离超过所属对象尺寸的 5 倍,容易生成极端变形或无效几何。") elif move_ratio > 0.5: risk = _max_risk(risk, "high") warnings.append("Face 中心移动距离超过所属对象尺寸的 50%,局部形变风险很高。") elif move_ratio > 0.2: risk = _max_risk(risk, "medium") warnings.append("Face 中心移动距离超过所属对象尺寸的 20%,请确认相邻面变化是否符合预期。") source_points: tuple[tuple[float, float, float], ...] = () face_count = 0 part_solid_count = 0 if part is not None: part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" if not blockers and solid is not None: solid_faces = _explore(solid, TopAbs_FACE) face_count = len(solid_faces) if not solid_faces: blockers.append("局部 Face 移动不可用:所属 Solid 没有可重建 Face。") if face_count > 128: blockers.append("局部 Face 移动暂只对较简单的平面实体开放,复杂模型请使用拉伸/切除或整体平移。") for face in solid_faces: surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: blockers.append("局部 Face 移动暂只支持全平面实体;含曲面的模型请使用其它建模意图。") break if len(_explore(face, TopAbs_WIRE)) != 1: blockers.append("局部 Face 移动暂不处理带内孔的 Face;请使用孔/槽专门入口。") break if len(self._local_deform_face_vertex_points(face, tolerance)) < 3: blockers.append("局部 Face 移动不可用:部分 Face 顶点环无法稳定读取。") break if not blockers: blockers.extend( self._face_first_level_plan_blockers( first_level_fields, operation_label="中心(局部重建)", ) ) if not blockers: points = self._local_deform_face_vertex_points(self.faces[face_id], tolerance) if len(points) < 3: blockers.append("局部 Face 移动不可用:当前 Face 顶点环无法稳定读取。") else: source_points = tuple(points) if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers) elif risk != "low": status = "caution" message = " ".join(warnings) else: message = "可以执行中心(局部重建),并让相邻平面按新的顶点位置重建。" return { "status": status, "risk": risk, "message": message, "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": part_id, "solid_id": solid_id, **first_level_fields, "surface": info.get("surface"), "current_face_center": current_center, "target_face_center": target, "face_center_move_vector": move_vector, "face_center_move_distance": move_distance, "face_center_move_ratio": move_ratio, "bbox_diagonal": diagonal, "local_face_deform_target_kind": target_kind, "local_face_deform_face_count": face_count, "local_face_deform_source_points": source_points, "local_face_deform_moved_point_count": len(source_points), "part_solid_count": part_solid_count, "resize_strategy": "local-face-only-deform", "edit_strategy_label": "中心(局部重建)", "edit_semantics": ( "移动当前 Face 的顶点,周边相邻面按新顶点重建;这不是平移所属对象,也不是面积缩放。" ), } def face_area_local_resize_plan(self, face_id: int, target_area: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) current_area = _float_or_none(info.get("area")) center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) part_id = int(info.get("part_id", -1)) solid_id = int(info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None solid = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else None source_shape = solid or (part.shape if part is not None else None) diagonal = _shape_diagonal(source_shape) if source_shape is not None else 0.0 tolerance = max(diagonal * 1e-7, 1e-6) blockers: list[str] = [] warnings: list[str] = [ "`面积(局部重建)` 会在该平面内移动当前 Face 的顶点,并重建相邻平面。" ] risk = "low" status = "ready" first_level_fields = self._face_first_level_plan_fields(face_id) try: target_area = float(target_area) except (TypeError, ValueError): target_area = 0.0 blockers.append("目标面面积必须是数字。") if info.get("surface") != "plane": blockers.append("当前 Face 不是平面,不能执行“面积(局部重建)”。") if current_area is None or current_area <= 1e-9: blockers.append("当前 Face 缺少稳定面积。") if center is None: blockers.append("当前 Face 缺少稳定中心坐标。") if target_area <= 1e-9: blockers.append("目标面面积必须大于 0。") if part is None: blockers.append("找不到当前 Face 所属特征。") if solid is None: blockers.append("找不到当前 Face 所属 Solid。") scale = ( math.sqrt(target_area / max(float(current_area), 1e-9)) if target_area > 1e-9 and current_area is not None and current_area > 1e-9 else 1.0 ) area_delta = target_area - float(current_area or 0.0) area_delta_ratio = abs(area_delta) / max(float(current_area or 0.0), 1e-9) if scale < 0.05: blockers.append("目标面面积会把当前 Face 缩放到当前尺寸的 5% 以下,容易生成退化面或无效几何。") elif scale > 5.0: blockers.append("目标面面积会把当前 Face 放大到当前尺寸的 5 倍以上,容易穿过相邻几何或导致重建失败。") if current_area is not None and abs(area_delta) <= max(current_area * 1e-6, 1e-6): blockers.append("目标面面积与当前面积几乎相同,不需要修改。") if area_delta_ratio > 1.0: risk = _max_risk(risk, "high") warnings.append("目标面面积变化超过当前面积的 100%,相邻面形变风险很高。") elif area_delta_ratio > 0.35: risk = _max_risk(risk, "medium") warnings.append("目标面面积变化超过当前面积的 35%,请确认相邻面变化是否符合预期。") if scale < 0.2: risk = _max_risk(risk, "high") warnings.append("当前 Face 会被缩得很小,可能生成薄小面或退化边。") elif scale > 2.5: risk = _max_risk(risk, "high") warnings.append("当前 Face 会被放大很多,可能穿过相邻几何。") point_targets: tuple[tuple[tuple[float, float, float], tuple[float, float, float]], ...] = () face_count = 0 part_solid_count = 0 if part is not None: part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" if not blockers and solid is not None and center is not None: solid_faces = _explore(solid, TopAbs_FACE) face_count = len(solid_faces) if not solid_faces: blockers.append("局部 Face 面积缩放不可用:所属 Solid 没有可重建 Face。") if face_count > 128: blockers.append("局部 Face 面积缩放暂只对较简单的平面实体开放。") for face in solid_faces: surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: blockers.append("局部 Face 面积缩放暂只支持全平面实体;含曲面的模型请使用所属对象缩放。") break if len(_explore(face, TopAbs_WIRE)) != 1: blockers.append("局部 Face 面积缩放暂不处理带内孔的 Face。") break if len(self._local_deform_face_vertex_points(face, tolerance)) < 3: blockers.append("局部 Face 面积缩放不可用:部分 Face 顶点环无法稳定读取。") break if not blockers: blockers.extend( self._face_first_level_plan_blockers( first_level_fields, operation_label="面积(局部重建)", ) ) if not blockers: points = self._local_deform_face_vertex_points(self.faces[face_id], tolerance) if len(points) < 3: blockers.append("局部 Face 面积缩放不可用:当前 Face 顶点环无法稳定读取。") else: target_items: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] for point in points: relative = _tuple_sub(point, center) target_point = ( center[0] + relative[0] * scale, center[1] + relative[1] * scale, center[2] + relative[2] * scale, ) target_items.append((point, target_point)) point_targets = tuple(target_items) if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers) elif risk != "low": status = "caution" message = " ".join(warnings) else: message = "可以执行面积(局部重建),并让相邻平面按新的顶点位置重建。" return { "status": status, "risk": risk, "message": message, "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": part_id, "solid_id": solid_id, **first_level_fields, "surface": info.get("surface"), "current_area": current_area, "target_area": target_area, "area_delta": area_delta, "area_delta_ratio": area_delta_ratio, "area_center": center, "local_face_area_scale": scale, "bbox_diagonal": diagonal, "local_face_deform_target_kind": target_kind, "local_face_deform_face_count": face_count, "local_face_deform_source_point_targets": point_targets, "local_face_deform_moved_point_count": len(point_targets), "part_solid_count": part_solid_count, "resize_strategy": "local-face-area-only-deform", "edit_strategy_label": "面积(局部重建)", "edit_semantics": ( "围绕当前 Face 中心在该平面内缩放这个 Face 的顶点,周边相邻面按新顶点重建;" "这不是缩放所属对象。" ), } def face_size_local_resize_plan(self, face_id: int, target_size: float, axis: str = "width") -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") axis_key = "height" if str(axis).strip().lower() in {"height", "h", "v", "y"} else "width" axis_label = "V向尺寸" if axis_key == "height" else "U向尺寸" info = self.face_info(face_id) center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) part_id = int(info.get("part_id", -1)) solid_id = int(info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None solid = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else None source_shape = solid or (part.shape if part is not None else None) diagonal = _shape_diagonal(source_shape) if source_shape is not None else 0.0 tolerance = max(diagonal * 1e-7, 1e-6) blockers: list[str] = [] warnings: list[str] = [ f"{axis_label}(局部重建)会只沿选中 Face 自身平面内的一个方向缩放当前 Face 顶点,并重建相邻平面。" ] risk = "low" status = "ready" first_level_fields = self._face_first_level_plan_fields(face_id) try: target_size = float(target_size) except (TypeError, ValueError): target_size = 0.0 blockers.append(f"目标{axis_label}必须是数字。") if info.get("surface") != "plane": blockers.append(f"当前 Face 不是平面,不能执行“{axis_label}(局部重建)”。") if center is None: blockers.append("当前 Face 缺少稳定中心坐标。") if target_size <= 1e-9: blockers.append(f"目标{axis_label}必须大于 0。") if part is None: blockers.append("找不到当前 Face 所属特征。") if solid is None: blockers.append("找不到当前 Face 所属 Solid。") size_info = self._local_face_plane_size_info( face_id, center=center, tolerance=tolerance, ) current_width = _float_or_none(size_info.get("local_face_width")) current_height = _float_or_none(size_info.get("local_face_height")) width_dir = _tuple_or_none(size_info.get("local_face_width_direction")) height_dir = _tuple_or_none(size_info.get("local_face_height_direction")) current_size = current_height if axis_key == "height" else current_width axis_dir = height_dir if axis_key == "height" else width_dir if current_size is None or current_size <= 1e-9 or axis_dir is None: blockers.append(f"当前 Face 缺少稳定{axis_label}方向或尺寸。") scale = target_size / max(float(current_size or 1.0), 1e-9) delta_size = target_size - float(current_size or 0.0) delta_ratio = abs(delta_size) / max(float(current_size or 0.0), 1e-9) if scale < 0.05: blockers.append(f"目标{axis_label}会把当前 Face 沿该方向缩放到当前值的 5% 以下,容易生成退化边或无效几何。") elif scale > 5.0: blockers.append(f"目标{axis_label}会把当前 Face 沿该方向放大到当前值的 5 倍以上,容易穿过相邻几何或导致重建失败。") if current_size is not None and abs(delta_size) <= max(current_size * 1e-6, 1e-6): blockers.append(f"目标{axis_label}与当前值几乎相同,不需要修改。") if delta_ratio > 1.0: risk = _max_risk(risk, "high") warnings.append(f"目标{axis_label}变化超过当前值的 100%,相邻面形变风险很高。") elif delta_ratio > 0.35: risk = _max_risk(risk, "medium") warnings.append(f"目标{axis_label}变化超过当前值的 35%,请确认相邻面变化是否符合预期。") if scale < 0.2: risk = _max_risk(risk, "high") warnings.append("当前 Face 会沿一个方向被缩得很窄,可能生成薄小面或退化边。") elif scale > 2.5: risk = _max_risk(risk, "high") warnings.append("当前 Face 会沿一个方向被拉得很长,可能穿过相邻几何。") point_targets: tuple[tuple[tuple[float, float, float], tuple[float, float, float]], ...] = () face_count = 0 part_solid_count = 0 if part is not None: part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" if not blockers and solid is not None and center is not None and axis_dir is not None: solid_faces = _explore(solid, TopAbs_FACE) face_count = len(solid_faces) if not solid_faces: blockers.append(f"局部 Face {axis_label}修改不可用:所属 Solid 没有可重建 Face。") if face_count > 128: blockers.append(f"局部 Face {axis_label}修改暂只对较简单的平面实体开放。") for face in solid_faces: surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: blockers.append(f"局部 Face {axis_label}修改暂只支持全平面实体;含曲面的模型请使用所属对象缩放。") break if len(_explore(face, TopAbs_WIRE)) != 1: blockers.append(f"局部 Face {axis_label}修改暂不处理带内孔的 Face。") break if len(self._local_deform_face_vertex_points(face, tolerance)) < 3: blockers.append(f"局部 Face {axis_label}修改不可用:部分 Face 顶点环无法稳定读取。") break if not blockers: blockers.extend( self._face_first_level_plan_blockers( first_level_fields, operation_label=f"{axis_label}(局部重建)", ) ) if not blockers: points = self._local_deform_face_vertex_points(self.faces[face_id], tolerance) if len(points) < 3: blockers.append(f"局部 Face {axis_label}修改不可用:当前 Face 顶点环无法稳定读取。") else: target_items: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] for point in points: relative = _tuple_sub(point, center) along = _tuple_dot(relative, axis_dir) axial = _tuple_scale(axis_dir, along) rest = _tuple_sub(relative, axial) scaled = _tuple_add(rest, _tuple_scale(axis_dir, along * scale)) target_point = _tuple_add(center, scaled) target_items.append((point, target_point)) point_targets = tuple(target_items) if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers) elif risk != "low": status = "caution" message = " ".join(warnings) else: message = f"可以只修改当前平面 Face 的{axis_label},并让相邻平面按新的顶点位置重建。" return { "status": status, "risk": risk, "message": message, "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": part_id, "solid_id": solid_id, **first_level_fields, "surface": info.get("surface"), "face_size_axis": axis_key, "face_size_label": axis_label, "current_face_width": current_width, "target_face_width": target_size if axis_key == "width" else current_width, "current_face_height": current_height, "target_face_height": target_size if axis_key == "height" else current_height, "current_face_size": current_size, "target_face_size": target_size, "face_size_delta": delta_size, "face_size_delta_ratio": delta_ratio, "face_size_scale": scale, "face_size_center": center, "face_size_axis_direction": axis_dir, "face_width_direction": width_dir, "face_height_direction": height_dir, "bbox_diagonal": diagonal, "local_face_deform_target_kind": target_kind, "local_face_deform_face_count": face_count, "local_face_deform_source_point_targets": point_targets, "local_face_deform_moved_point_count": len(point_targets), "local_face_deform_distance_hint": abs(delta_size), "part_solid_count": part_solid_count, "resize_strategy": f"local-face-{axis_key}-only-deform", "edit_strategy_label": f"{axis_label}(局部重建)", "edit_semantics": ( f"围绕当前 Face 中心,只沿 Face 平面内的{axis_label}方向缩放该 Face 顶点;" "另一方向尺寸保持不主动缩放,周边相邻面按新顶点重建;这不是缩放所属对象。" ), } def face_size_owning_scale_plan(self, face_id: int, target_size: float, axis: str = "width") -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") axis_key = "height" if str(axis).strip().lower() in {"height", "h", "v", "y"} else "width" axis_label = "V向尺寸" if axis_key == "height" else "U向尺寸" info = self.face_info(face_id) center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) part_id = int(info.get("part_id", -1)) solid_id = int(info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None solid = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else None source_shape = solid or (part.shape if part is not None else None) diagonal = _shape_diagonal(source_shape) if source_shape is not None else 0.0 tolerance = max(diagonal * 1e-7, 1e-6) blockers: list[str] = [] warnings: list[str] = [ f"{axis_label}(缩放特征)会沿选中 Face 自身平面内的一个方向缩放所属特征或 Solid;同一对象上的其它几何会跟着改变。" ] risk = "medium" first_level_fields = self._face_first_level_plan_fields(face_id) try: target_size = float(target_size) except (TypeError, ValueError): target_size = 0.0 blockers.append(f"目标{axis_label}必须是数字。") if info.get("surface") != "plane": blockers.append(f"当前 Face 不是平面,不能执行“{axis_label}(缩放特征)”。") if center is None: blockers.append("当前 Face 缺少稳定中心坐标,不能确定缩放基准。") if target_size <= 1e-9: blockers.append(f"目标{axis_label}必须大于 0。") if part is None: blockers.append("找不到当前 Face 所属特征。") if source_shape is None: blockers.append("找不到当前 Face 可缩放的所属对象。") size_info = self._local_face_plane_size_info( face_id, center=center, tolerance=tolerance, ) current_width = _float_or_none(size_info.get("local_face_width")) current_height = _float_or_none(size_info.get("local_face_height")) width_dir = _tuple_or_none(size_info.get("local_face_width_direction")) height_dir = _tuple_or_none(size_info.get("local_face_height_direction")) current_size = current_height if axis_key == "height" else current_width axis_dir = height_dir if axis_key == "height" else width_dir if current_size is None or current_size <= 1e-9 or axis_dir is None: blockers.append(f"当前 Face 缺少稳定{axis_label}方向或尺寸。") scale = target_size / max(float(current_size or 1.0), 1e-9) delta_size = target_size - float(current_size or 0.0) delta_ratio = abs(delta_size) / max(float(current_size or 0.0), 1e-9) if scale < 0.05: blockers.append(f"目标{axis_label}会把所属对象沿该方向缩放到当前值的 5% 以下,容易生成退化几何。") elif scale > 5.0: blockers.append(f"目标{axis_label}会把所属对象沿该方向放大到当前值的 5 倍以上,风险过高。") if current_size is not None and abs(delta_size) <= max(current_size * 1e-6, 1e-6): blockers.append(f"目标{axis_label}与当前值几乎相同,不需要修改。") if delta_ratio > 1.0: risk = _max_risk(risk, "high") warnings.append(f"目标{axis_label}变化超过当前值的 100%,所属对象会发生很大的单向缩放。") elif delta_ratio > 0.35: risk = _max_risk(risk, "high") warnings.append(f"目标{axis_label}变化超过当前值的 35%,请重点检查同一对象上的孔、槽、凸台和厚度。") elif delta_ratio > 0.15: risk = _max_risk(risk, "medium") warnings.append(f"目标{axis_label}变化超过当前值的 15%,其它特征会跟随缩放。") if scale < 0.2: risk = _max_risk(risk, "high") warnings.append("所属对象会沿一个方向被缩得很窄,可能生成退化边或薄小面。") elif scale > 2.5: risk = _max_risk(risk, "high") warnings.append("所属对象会沿一个方向被拉得很长,可能明显扭曲其它特征间距。") part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0 target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" target_label = "Solid" if target_kind == "solid" else "特征" if not blockers: warnings.append(f"当前会缩放所属{target_label},不是局部重建当前 Face 顶点。") owning_rebuild_mode = "affine-transform" point_targets: tuple[tuple[tuple[float, float, float], tuple[float, float, float]], ...] = () face_count = 0 if not blockers and solid is not None and center is not None and axis_dir is not None: solid_faces = _explore(solid, TopAbs_FACE) face_count = len(solid_faces) can_rebuild_planar = bool(solid_faces) and face_count <= 128 if not solid_faces: warnings.append("所属对象没有可重建 Face,将使用通用仿射缩放。") elif face_count > 128: warnings.append("所属对象 Face 数较多,将使用通用仿射缩放。") if can_rebuild_planar: for face in solid_faces: surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: can_rebuild_planar = False warnings.append("所属对象含曲面,将使用通用仿射缩放;部分解析几何可能变为 B-spline。") break if len(_explore(face, TopAbs_WIRE)) != 1: can_rebuild_planar = False warnings.append("所属对象包含带内孔的 Face,将使用通用仿射缩放。") break if len(self._local_deform_face_vertex_points(face, tolerance)) < 3: can_rebuild_planar = False warnings.append("所属对象部分 Face 顶点环无法稳定读取,将使用通用仿射缩放。") break if can_rebuild_planar: target_by_key: dict[tuple[int, int, int], tuple[tuple[float, float, float], tuple[float, float, float]]] = {} for face in solid_faces: for point in self._local_deform_face_vertex_points(face, tolerance): key = self._local_point_key(point, tolerance) if key in target_by_key: continue relative = _tuple_sub(point, center) along = _tuple_dot(relative, axis_dir) axial = _tuple_scale(axis_dir, along) rest = _tuple_sub(relative, axial) scaled = _tuple_add(rest, _tuple_scale(axis_dir, along * scale)) target_by_key[key] = (point, _tuple_add(center, scaled)) point_targets = tuple(target_by_key.values()) if point_targets: owning_rebuild_mode = "planar-rebuild" warnings.append("当前所属对象是简单全平面实体,会优先重建平面 Face,减少仿射后变成样条面的风险。") status = "blocked" if blockers else ("caution" if risk != "low" else "ready") if blockers: risk = "blocked" message = " ".join(blockers) elif warnings: message = " ".join(warnings) else: message = f"可以沿当前 Face 的{axis_label}方向缩放所属{target_label}。" return { "status": status, "risk": risk, "message": message, "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": part_id, "solid_id": solid_id, **first_level_fields, "surface": info.get("surface"), "face_size_axis": axis_key, "face_size_label": axis_label, "current_face_width": current_width, "target_face_width": target_size if axis_key == "width" else current_width, "current_face_height": current_height, "target_face_height": target_size if axis_key == "height" else current_height, "current_face_size": current_size, "target_face_size": target_size, "face_size_delta": delta_size, "face_size_delta_ratio": delta_ratio, "face_size_scale": scale, "face_size_center": center, "face_size_axis_direction": axis_dir, "face_width_direction": width_dir, "face_height_direction": height_dir, "bbox_diagonal": diagonal, "resize_strategy": f"axis-scale-owning-shape-from-face-{axis_key}", "edit_strategy_label": f"{axis_label}(缩放特征)", "edit_semantics": ( f"围绕当前 Face 中心,沿 Face 平面内的{axis_label}方向对所属{target_label}做单向仿射缩放;" "同一对象上的其它几何会跟随变化,这不是只改当前 Face。" ), "affine_scale": scale, "affine_transform_kind": "axis-affine", "affine_transform_label": f"按当前 Face{axis_label}方向缩放所属{target_label}", "affine_transform_note": "单向仿射缩放可能把部分解析几何转换成 B-spline,并会改变同一对象上的其它特征间距。", "affine_axis_point": center, "affine_axis_direction": axis_dir, "affine_axis_source": f"selected face {axis_key} direction", "affine_anchor_source": "selected face center", "affine_target_kind": target_kind, "part_solid_count": part_solid_count, "owning_face_size_rebuild_mode": owning_rebuild_mode, "local_face_deform_target_kind": target_kind, "local_face_deform_face_count": face_count, "local_face_deform_source_point_targets": point_targets, "local_face_deform_moved_point_count": len(point_targets), "local_face_deform_distance_hint": abs(delta_size), } def cylindrical_boss_height_plan( self, face_id: int, target_height: float, *, require_boss: bool = True, ) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) feature = self.feature_info(face_id) topology_fields = ( self._cylindrical_feature_first_level_plan_fields(face_id) if info.get("surface") == "cylinder" else {} ) blockers: list[str] = [] edit_label = "凸台高度" if require_boss else "圆柱高度" warnings: list[str] = [ f"{edit_label}调整会拉伸/切除识别到的圆柱端盖 Face;这是 B-Rep 几何编辑,不是 CAD 历史特征参数。" ] risk = "low" if info.get("surface") != "cylinder": blockers.append(f"当前选中 Face 不是圆柱面,不能调整{edit_label}。") if require_boss and str(info.get("feature_guess", "")) != "boss/outer-round candidate": blockers.append("凸台高度调整当前版本只支持明确的圆柱凸台候选。") cylinder_identity = {**info, **feature} angular_span = _effective_cylinder_angular_span(cylinder_identity) or 0.0 if not _is_effectively_full_cylinder(cylinder_identity): blockers.append(f"{edit_label}调整当前版本只支持接近完整圆柱的圆柱面。") if target_height <= 1e-9: blockers.append(f"目标{edit_label}必须大于 0。") axis_range: dict[str, object] = { "span": 0.0, "same_domain_face_ids": (), "same_domain_face_count": 0, } if info.get("surface") == "cylinder": axis_range = self._cylindrical_axis_range( face_id, BRepAdaptor_Surface(self.faces[face_id]), _int_values(feature.get("feature_side_face_ids")), ) current_height = float(axis_range.get("span", 0.0)) delta_height = float(target_height) - current_height if current_height <= 1e-9: blockers.append(f"当前{edit_label}估算无效。") elif abs(delta_height) <= max(current_height * 1e-5, 1e-6): blockers.append(f"目标{edit_label}与当前估算高度几乎相同,不需要修改。") if str(info.get("confidence", "low")) != "high": risk = _max_risk(risk, "medium") warnings.append(f"{edit_label}识别置信度不是 high,修改后请重点检查结果。") if current_height > 1e-9: delta_ratio = abs(delta_height) / current_height if delta_ratio > 0.8: risk = _max_risk(risk, "high") warnings.append("目标高度变化超过当前高度的 80%,可能导致周边几何异常。") elif delta_ratio > 0.3: risk = _max_risk(risk, "medium") warnings.append("目标高度变化超过当前高度的 30%,请确认预览方向和范围。") cap_candidates: list[tuple[tuple[int, int, float, int], dict[str, object]]] = [] if not blockers: endpoint_groups = [ ("start", "起点端盖", _int_values(feature.get("feature_start_end_face_ids")), -1.0, bool(info.get("start_end_open"))), ("end", "终点端盖", _int_values(feature.get("feature_end_end_face_ids")), 1.0, bool(info.get("end_end_open"))), ] axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis"))) if axis_direction is None: blockers.append(f"当前{edit_label}缺少稳定轴线方向,不能换算高度修改。") else: for endpoint_role, endpoint_label, cap_face_ids, axis_sign, is_open_end in endpoint_groups: desired_movement = _tuple_scale(axis_direction, axis_sign * delta_height) desired_unit = _tuple_normalized(desired_movement) if desired_unit is None: continue for cap_face_id in cap_face_ids: if cap_face_id < 0 or cap_face_id >= len(self.faces): continue try: cap_info = self.face_info(cap_face_id) except Exception: continue if cap_info.get("surface") != "plane": continue outward = _tuple_normalized(_tuple_or_none(cap_info.get("push_pull_outward_direction"))) if outward is None: continue movement_alignment = abs(_tuple_dot(desired_unit, outward)) if movement_alignment < 0.82: continue push_pull_distance = _tuple_dot(desired_movement, outward) if abs(push_pull_distance) <= 1e-9: continue push_plan = self.push_pull_plan(cap_face_id, push_pull_distance) if push_plan["status"] == "blocked": warnings.append(f"{endpoint_label} Face {cap_face_id} 不能拉伸/切除:{push_plan.get('message', '')}") continue candidate_risk = str(push_plan.get("risk", "medium")) score = ( 0 if is_open_end else 1, {"low": 0, "medium": 1, "high": 2}.get(candidate_risk, 3), -movement_alignment, cap_face_id, ) cap_candidates.append( ( score, { "boss_height_cap_face_id": cap_face_id, "boss_height_endpoint_role": endpoint_role, "boss_height_endpoint_label": endpoint_label, "boss_height_open_end": is_open_end, "boss_height_movement_alignment": movement_alignment, "boss_height_desired_movement_vector": desired_movement, "push_pull_distance": push_pull_distance, "push_pull_status": push_plan.get("status"), "push_pull_risk": push_plan.get("risk"), "push_pull_message": push_plan.get("message"), "push_pull_scope_face_ids": push_plan.get("push_pull_scope_face_ids", (cap_face_id,)), "push_pull_scope_face_count": push_plan.get("push_pull_scope_face_count", 1), "push_pull_scope_note": push_plan.get("push_pull_scope_note", ""), }, ) ) selected_cap: dict[str, object] = {} if not blockers: if not cap_candidates: blockers.append("没有找到可拉伸/切除的圆柱端盖 Face,暂不能直接调整凸台高度。") else: cap_candidates.sort(key=lambda item: item[0]) selected_cap = cap_candidates[0][1] risk = _max_risk(risk, str(selected_cap.get("push_pull_risk", "medium"))) if not selected_cap.get("boss_height_open_end"): risk = _max_risk(risk, "medium") warnings.append("未能确认所选端盖是凸台外端,执行后请重点检查是否移动了正确端面。") if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers) elif risk != "low": status = "caution" message = " ".join(warnings) else: status = "ready" message = f"可以通过拉伸/切除圆柱端盖调整{edit_label}。" return { "status": status, "risk": risk, "message": message, "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id"), "feature_type": feature.get("feature_type"), "feature_guess": info.get("feature_guess"), "confidence": info.get("confidence"), "current_height": current_height, "target_height": float(target_height), "delta_height": delta_height, "height_delta_ratio": abs(delta_height) / max(current_height, 1e-9), "diameter": info.get("diameter"), "radius": info.get("radius"), "axis": info.get("axis"), "axis_point": info.get("axis_point"), "same_domain_face_ids": axis_range.get("same_domain_face_ids"), "same_domain_face_count": axis_range.get("same_domain_face_count"), "feature_start_end_face_ids": feature.get("feature_start_end_face_ids"), "feature_end_end_face_ids": feature.get("feature_end_end_face_ids"), **topology_fields, "resize_strategy": ( "push-pull-cylindrical-boss-end-cap-to-target-height" if require_boss else "push-pull-cylindrical-end-cap-to-target-height" ), "edit_strategy_label": "端盖拉伸/切除调整高度" if require_boss else "圆柱端盖拉伸/切除调整高度", "edit_semantics": ( f"通过拉伸/切除识别到的{edit_label}端盖 Face 改变高度;这是局部端面移动,不是整体缩放。" ), **selected_cap, } def cylindrical_height_plan(self, face_id: int, target_height: float) -> dict[str, object]: return self.cylindrical_boss_height_plan(face_id, target_height, require_boss=False) def cylindrical_boss_height_preview_polydata( self, face_id: int, target_height: float, deflection: float = 0.8, ): plan = self.cylindrical_boss_height_plan(face_id, target_height) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) return self.push_pull_preview_polydata( int(plan["boss_height_cap_face_id"]), float(plan["push_pull_distance"]), deflection, ) def resize_cylindrical_boss_height(self, face_id: int, target_height: float) -> str: plan = self.cylindrical_boss_height_plan(face_id, target_height) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) part_id = int(plan.get("part_id", self.face_part_ids[face_id])) part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") old_part_shape = part.shape verification: dict[str, object] = {} try: push_result = self.push_pull_face( int(plan["boss_height_cap_face_id"]), float(plan["push_pull_distance"]), ) verification = self._verify_cylindrical_height_result(plan, part_id) if not verification.get("matched"): detail = str(verification.get("detail", "cylindrical height result verification failed")) raise RuntimeError( "圆柱凸台高度修改返回了结果,但没有检测到达到目标高度且保留一级关系的圆柱面," "已回滚到修改前状态。" f"{detail}" ) except Exception: part.shape = old_part_shape self.refresh_topology() raise return ( "Cylindrical boss height resize completed by end-cap push/pull: " f"face {face_id}, cap_face={plan.get('boss_height_cap_face_id')}, " f"current_height={float(plan['current_height']):g}, " f"target_height={float(plan['target_height']):g}, " f"delta={float(plan['delta_height']):g}, " f"push_pull_distance={float(plan['push_pull_distance']):g}, " f"risk={plan['risk']}, verified_face={verification.get('face_id', '')}. {push_result}" ) def shell_thickness_preview_polydata( self, face_id: int, target_thickness: float, deflection: float = 0.8, ): plan = self.shell_thickness_plan(face_id, target_thickness) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) return self.push_pull_preview_polydata(face_id, float(plan["push_pull_distance"]), deflection) def resize_shell_thickness(self, face_id: int, target_thickness: float) -> str: plan = self.shell_thickness_plan(face_id, target_thickness) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) push_result, result_check = self._run_checked_face_edit( plan, lambda: self.push_pull_face(face_id, float(plan["push_pull_distance"])), ) return ( "Shell thickness resize completed by planar push/pull: " f"face {face_id}, current_thickness={float(plan['shell_current_thickness']):g}, " f"target_thickness={float(plan['shell_target_thickness']):g}, " f"delta={float(plan['shell_delta_thickness']):g}, " f"opposite_face={plan.get('shell_opposite_face_id')}, " f"push_pull_distance={float(plan['push_pull_distance']):g}, " f"risk={plan['risk']}. {result_check} {push_result}" ) def shell_thickness_owning_scale_plan(self, face_id: int, target_thickness: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.feature_info(face_id) blockers: list[str] = [] warnings: list[str] = [ "该方式会沿壳体厚度方向缩放所属特征或 Solid;它不是拉伸/切除当前平面,也不是局部壳命令参数。" ] risk = "medium" first_level_fields = self._face_first_level_plan_fields(face_id) try: target_thickness = float(target_thickness) except (TypeError, ValueError): target_thickness = 0.0 blockers.append("目标壳体厚度必须是数字。") current_thickness = _float_or_none(info.get("shell_thickness_estimate")) signed_thickness = _float_or_none(info.get("shell_signed_thickness")) normal = _tuple_normalized(_tuple_or_none(info.get("normal"))) plane_origin = _tuple_or_none(info.get("plane_origin")) if info.get("surface") != "plane": blockers.append("当前选中 Face 不是平面,不能按壳体厚度整体缩放。") if info.get("shell_region_status") != "candidate": blockers.append(str(info.get("shell_region_note", "当前平面没有识别到相对壳体平面。"))) if current_thickness is None or current_thickness <= 1e-9: blockers.append("当前壳体厚度估算无效。") if signed_thickness is None or abs(signed_thickness) <= 1e-9: blockers.append("当前壳体厚度方向无效。") if normal is None: blockers.append("当前平面缺少稳定法向,不能按厚度方向整体缩放。") if target_thickness <= 1e-9: blockers.append("目标壳体厚度必须大于 0。") current_value = float(current_thickness or 0.0) scale = target_thickness / max(current_value, 1e-9) delta_thickness = target_thickness - current_value delta_ratio = abs(delta_thickness) / max(current_value, 1e-9) if current_thickness is not None and abs(delta_thickness) <= max(current_thickness * 1e-6, 1e-6): blockers.append("目标壳体厚度与当前值几乎相同,不需要修改。") if scale < 0.05: blockers.append("目标壳体厚度会把所属对象沿厚度方向缩放到当前值的 5% 以下,容易生成退化几何。") elif scale > 5.0: blockers.append("目标壳体厚度会把所属对象沿厚度方向放大到当前值的 5 倍以上,风险过高。") if delta_ratio > 0.75: risk = _max_risk(risk, "high") warnings.append("壳体厚度变化超过 75%,会明显影响所属对象上的其它厚度方向尺寸。") elif delta_ratio > 0.3: warnings.append("壳体厚度变化超过 30%,修改后请重点检查壁厚和相邻特征。") confidence = str(info.get("shell_confidence", "low")) overlap_ratio = _float_or_none(info.get("shell_overlap_ratio_estimate")) if confidence == "low": risk = _max_risk(risk, "high") warnings.append("壳体相对面识别置信度较低。") elif confidence == "medium": risk = _max_risk(risk, "medium") warnings.append("壳体相对面识别置信度为 medium,执行后请检查周边。") if overlap_ratio is not None: if overlap_ratio < 0.25: risk = _max_risk(risk, "high") warnings.append("相对平面投影重叠率较低,可能不是稳定壳体区域。") elif overlap_ratio < 0.55: risk = _max_risk(risk, "medium") warnings.append("相对平面投影重叠率一般,厚度估算可能偏局部。") scale_center = None if plane_origin is not None and normal is not None and signed_thickness is not None: scale_center = ( plane_origin[0] + normal[0] * signed_thickness * 0.5, plane_origin[1] + normal[1] * signed_thickness * 0.5, plane_origin[2] + normal[2] * signed_thickness * 0.5, ) scale_center = scale_center or _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) if scale_center is None: blockers.append("当前壳体区域缺少稳定缩放中心,不能整体缩放所属对象。") part_id = int(info.get("part_id", -1)) solid_id = int(info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None if part is None: blockers.append("找不到当前壳体区域所属特征。") part_solid_count = 0 else: part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" target_label = "Solid" if target_kind == "solid" else "特征" warnings.append(f"当前会沿壳体厚度方向缩放所属{target_label};同一对象上的其它尺寸会跟随变化。") owning_rebuild_mode = "affine-transform" point_targets: tuple[tuple[tuple[float, float, float], tuple[float, float, float]], ...] = () face_count = 0 solid = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else None if not blockers and solid is not None and scale_center is not None and normal is not None: solid_faces = _explore(solid, TopAbs_FACE) face_count = len(solid_faces) can_rebuild_planar = bool(solid_faces) and face_count <= 128 if not solid_faces: warnings.append("所属对象没有可重建 Face,将使用通用仿射缩放。") elif face_count > 128: warnings.append("所属对象 Face 数较多,将使用通用仿射缩放。") if can_rebuild_planar: tolerance = max(_shape_diagonal(solid) * 1e-7, abs(delta_thickness) * 1e-7, 1e-6) for face in solid_faces: surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: can_rebuild_planar = False warnings.append("所属对象含曲面,将使用通用仿射缩放;部分解析几何可能变为 B-spline。") break if len(_explore(face, TopAbs_WIRE)) != 1: can_rebuild_planar = False warnings.append("所属对象包含带内孔的 Face,将使用通用仿射缩放。") break if len(self._local_deform_face_vertex_points(face, tolerance)) < 3: can_rebuild_planar = False warnings.append("所属对象部分 Face 顶点环无法稳定读取,将使用通用仿射缩放。") break if can_rebuild_planar: target_by_key: dict[tuple[int, int, int], tuple[tuple[float, float, float], tuple[float, float, float]]] = {} tolerance = max(_shape_diagonal(solid) * 1e-7, abs(delta_thickness) * 1e-7, 1e-6) for face in solid_faces: for point in self._local_deform_face_vertex_points(face, tolerance): key = self._local_point_key(point, tolerance) if key in target_by_key: continue relative = _tuple_sub(point, scale_center) along = _tuple_dot(relative, normal) axial = _tuple_scale(normal, along) rest = _tuple_sub(relative, axial) moved = _tuple_add(scale_center, _tuple_add(rest, _tuple_scale(normal, along * scale))) target_by_key[key] = (point, moved) point_targets = tuple(target_by_key.values()) if point_targets: owning_rebuild_mode = "planar-rebuild" warnings.append("当前所属对象是简单全平面实体,会优先重建平面 Face,避免整体缩放后变成样条面。") status = "blocked" if blockers else "caution" if blockers: risk = "blocked" return { "status": status, "risk": risk, "message": " ".join(blockers + warnings), "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": part_id, "solid_id": solid_id, **first_level_fields, "surface": info.get("surface"), "shell_region_kind": info.get("shell_region_kind"), "shell_confidence": confidence, "shell_source_face_ids": tuple(_int_values(info.get("shell_source_face_ids")) or [face_id]), "shell_opposite_face_id": info.get("shell_opposite_face_id"), "shell_current_thickness": current_thickness, "shell_target_thickness": target_thickness, "shell_delta_thickness": delta_thickness, "shell_delta_ratio": delta_ratio, "shell_signed_thickness": signed_thickness, "shell_overlap_ratio_estimate": overlap_ratio, "shell_opposite_normal_dot": info.get("shell_opposite_normal_dot"), "resize_strategy": "axis-scale-owning-shape-from-shell-thickness", "edit_strategy_label": "壳体厚度(缩放特征)", "edit_semantics": ( "按目标壳体厚度和当前厚度的比例,沿厚度方向缩放所属特征或 Solid;" "这会改变同一对象上的其它尺寸,不是局部拉伸/切除当前平面。" ), "affine_scale": scale, "affine_transform_kind": "axis-affine", "affine_transform_label": f"按壳体厚度方向缩放所属{target_label}", "affine_transform_note": "单向仿射缩放可能把部分解析几何转换成 B-spline,并会改变同一对象上的其它尺寸。", "affine_axis_point": scale_center, "affine_axis_direction": normal or (0.0, 0.0, 1.0), "affine_axis_source": "shell thickness normal", "affine_anchor_source": "midpoint between source and opposite plane", "affine_target_kind": target_kind, "part_solid_count": part_solid_count, "owning_shell_thickness_rebuild_mode": owning_rebuild_mode, "local_face_deform_target_kind": target_kind, "local_face_deform_face_count": face_count, "local_face_deform_source_point_targets": point_targets, "local_face_deform_moved_point_count": len(point_targets), "local_face_deform_distance_hint": abs(delta_thickness), } def resize_shell_thickness_owning_scale(self, face_id: int, target_thickness: float) -> str: plan = self.shell_thickness_owning_scale_plan(face_id, target_thickness) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) _action_result, result_check = self._run_checked_face_edit( plan, lambda: ( self._apply_local_face_deform(plan) if plan.get("owning_shell_thickness_rebuild_mode") == "planar-rebuild" else self._apply_edge_length_affine_transform(plan) ), ) return ( "Shell thickness resize completed by axis owning-shape scaling: " f"face {face_id}, " f"thickness={float(plan['shell_current_thickness']):g}->{float(plan['shell_target_thickness']):g}, " f"scale={float(plan['affine_scale']):g}, " f"rebuild_mode={plan.get('owning_shell_thickness_rebuild_mode')}, " f"target={plan.get('affine_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) def resize_cylindrical_height(self, face_id: int, target_height: float) -> str: plan = self.cylindrical_height_plan(face_id, target_height) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) part_id = int(plan.get("part_id", self.face_part_ids[face_id])) part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") old_part_shape = part.shape verification: dict[str, object] = {} try: push_result = self.push_pull_face( int(plan["boss_height_cap_face_id"]), float(plan["push_pull_distance"]), ) verification = self._verify_cylindrical_height_result(plan, part_id) if not verification.get("matched"): detail = str(verification.get("detail", "cylindrical height result verification failed")) raise RuntimeError( "圆柱高度修改返回了结果,但没有检测到达到目标高度且保留一级关系的圆柱面," "已回滚到修改前状态。" f"{detail}" ) except Exception: part.shape = old_part_shape self.refresh_topology() raise return ( "Cylindrical height resize completed by end-cap push/pull: " f"face {face_id}, cap_face={plan.get('boss_height_cap_face_id')}, " f"current_height={float(plan['current_height']):g}, " f"target_height={float(plan['target_height']):g}, " f"delta={float(plan['delta_height']):g}, " f"push_pull_distance={float(plan['push_pull_distance']):g}, " f"risk={plan['risk']}, verified_face={verification.get('face_id', '')}. {push_result}" ) def cylindrical_height_owning_scale_plan(self, face_id: int, target_height: 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) try: feature = self.feature_info(face_id) except Exception: feature = {} topology_fields = ( self._cylindrical_feature_first_level_plan_fields(face_id) if info.get("surface") == "cylinder" else {} ) blockers: list[str] = [] warnings: list[str] = [ "该方式会沿当前圆柱轴向缩放所属特征或 Solid;它不是拉伸/切除某个端盖,也不是只修改单个圆柱面。" ] risk = "medium" try: target_height = float(target_height) except (TypeError, ValueError): target_height = 0.0 blockers.append("目标圆柱高度必须是数字。") axis_point = _tuple_or_none(info.get("axis_point")) axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis"))) current_height = _float_or_none(info.get("same_domain_height_estimate")) if current_height is None: current_height = _float_or_none(info.get("height_estimate")) axis_range_value = info.get("same_domain_v_range") or info.get("v_range") if ( current_height is None and info.get("surface") == "cylinder" and 0 <= face_id < len(self.faces) ): try: axis_range = self._cylindrical_axis_range( face_id, BRepAdaptor_Surface(self.faces[face_id]), _int_values(feature.get("feature_side_face_ids")), ) current_height = _float_or_none(axis_range.get("span")) axis_range_value = (axis_range.get("v_min"), axis_range.get("v_max")) except Exception: pass if info.get("surface") != "cylinder": blockers.append("当前选中 Face 不是圆柱面。") cylinder_identity = {**info, **feature} angular_span = _effective_cylinder_angular_span(cylinder_identity) if angular_span is not None and not _is_effectively_full_cylinder(cylinder_identity): risk = _max_risk(risk, "high") warnings.append("当前圆柱面不是完整圆柱;轴向整体缩放会影响所属对象,但不等于稳定的局部槽/半孔高度编辑。") if current_height is None or current_height <= 1e-9: blockers.append("当前圆柱缺少稳定高度估算,不能按高度整体缩放。") if axis_point is None or axis_direction is None: blockers.append("当前圆柱缺少稳定轴线,不能沿轴向整体缩放。") if target_height <= 1e-9: blockers.append("目标圆柱高度必须大于 0。") scale_center = None if ( axis_point is not None and axis_direction is not None and isinstance(axis_range_value, (list, tuple)) and len(axis_range_value) >= 2 ): v_min = _float_or_none(axis_range_value[0]) v_max = _float_or_none(axis_range_value[1]) if v_min is not None and v_max is not None: v_mid = (v_min + v_max) * 0.5 scale_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, ) scale_center = scale_center or _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) if scale_center is None: blockers.append("当前圆柱缺少稳定缩放中心,不能整体缩放所属对象。") current_height_value = float(current_height or 0.0) scale = target_height / max(current_height_value, 1e-9) delta_height = target_height - current_height_value delta_ratio = abs(delta_height) / max(current_height_value, 1e-9) if current_height is not None and abs(delta_height) <= max(current_height * 1e-6, 1e-6): blockers.append("目标圆柱高度与当前值几乎相同,不需要修改。") if delta_ratio > 0.6: risk = _max_risk(risk, "high") warnings.append("圆柱高度变化超过 60%,会明显影响同一对象上的其它几何位置。") elif delta_ratio > 0.25: warnings.append("圆柱高度变化超过 25%,修改后请重点检查相邻特征。") part_id = int(info.get("part_id", -1)) solid_id = int(info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None if part is None: blockers.append("找不到当前圆柱面所属特征。") part_solid_count = 0 else: part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" target_label = "Solid" if target_kind == "solid" else "特征" warnings.append(f"当前会沿圆柱轴向缩放所属{target_label};同一对象上的孔距、台阶位置和其它轴向尺寸会跟随变化。") status = "blocked" if blockers else "caution" if blockers: risk = "blocked" return { "status": status, "risk": risk, "message": " ".join(blockers + warnings), "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": part_id, "solid_id": solid_id, "surface": info.get("surface"), "feature_type": feature.get("feature_type"), "feature_guess": info.get("feature_guess"), "confidence": info.get("confidence"), "current_height": current_height, "target_height": target_height, "delta_height": delta_height, "height_delta_ratio": delta_ratio, "diameter": info.get("diameter"), "radius": info.get("radius"), "axis": axis_direction, "axis_point": axis_point, "scale_center": scale_center, "resize_strategy": "axis-scale-owning-shape-from-cylinder-height", "edit_strategy_label": "高度(缩放特征)", "edit_semantics": ( "按目标圆柱高度和当前高度的比例,沿圆柱轴向缩放所属特征或 Solid;" "这会改变同一对象上的其它轴向尺寸,不是端盖拉伸/切除。" ), "affine_scale": scale, "affine_transform_kind": "axis-affine", "affine_transform_label": f"按圆柱高度轴向缩放所属{target_label}", "affine_transform_note": "单向仿射缩放可能把部分解析几何转换成 B-spline,并会改变同一对象上的其它轴向尺寸。", "affine_axis_point": scale_center, "affine_axis_direction": axis_direction or (0.0, 0.0, 1.0), "affine_axis_source": "cylinder axis", "affine_anchor_source": "cylinder axis center", "affine_target_kind": target_kind, "part_solid_count": part_solid_count, **topology_fields, } def resize_cylindrical_height_owning_scale(self, face_id: int, target_height: float) -> str: plan = self.cylindrical_height_owning_scale_plan(face_id, target_height) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) part_id = int(plan.get("part_id", self.face_part_ids[face_id])) part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") old_part_shape = part.shape verification: dict[str, object] = {} try: self._apply_edge_length_affine_transform(plan) verification = self._verify_axis_height_span_result(plan, part_id) if not verification.get("matched"): detail = str(verification.get("detail", "axis height span verification failed")) raise RuntimeError( "圆柱高度整体缩放返回了结果,但所属对象的轴向跨度没有达到目标高度," "已回滚到修改前状态。" f"{detail}" ) except Exception: part.shape = old_part_shape self.refresh_topology() raise return ( "Cylindrical height resize completed by axis owning-shape scaling: " f"face {face_id}, " f"height={float(plan['current_height']):g}->{float(plan['target_height']):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " f"risk={plan['risk']}, verified_axis_span={_format_result_number(verification.get('axis_span'))}." ) def cylindrical_depth_owning_scale_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) try: feature = self.feature_info(face_id) except Exception: feature = {} blockers: list[str] = [] warnings: list[str] = [ "该方式会沿盲孔/盲槽轴向缩放所属特征或 Solid;它不是加深切削,也不是变浅补料。" ] risk = "medium" try: target_depth = float(target_depth) except (TypeError, ValueError): target_depth = 0.0 blockers.append("目标盲孔/盲槽深度必须是数字。") if info.get("surface") != "cylinder": blockers.append("当前选中 Face 不是圆柱面。") if str(info.get("feature_guess", "")) != "hole/groove candidate": blockers.append("深度(缩放特征)当前只对孔/槽候选开放。") if target_depth <= 1e-9: blockers.append("目标盲孔/盲槽深度必须大于 0。") context: dict[str, object] = {} if info.get("surface") == "cylinder": try: context = self._blind_cylindrical_depth_context( face_id, info, feature, max(target_depth, 1e-6), bottom_face_id=bottom_face_id, ) except Exception as exc: context = {"context_status": "blocked", "context_message": str(exc)} if context.get("context_status") == "blocked": blockers.append(str(context.get("context_message") or "当前盲孔/盲槽深度方向不稳定。")) current_depth = _float_or_none(context.get("depth_current_depth")) if current_depth is None: current_depth = _float_or_none(info.get("hole_depth_estimate")) if current_depth is None: current_depth = _float_or_none(info.get("same_domain_height_estimate")) if current_depth is None or current_depth <= 1e-9: blockers.append("当前对象缺少稳定深度估算,不能按深度整体缩放。") axis_direction = _tuple_normalized(_tuple_or_none(context.get("depth_axis_direction"))) if axis_direction is None: axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis"))) if axis_direction is None: blockers.append("当前对象缺少稳定轴线方向,不能按深度整体缩放。") scale_center = None open_point = _tuple_or_none(context.get("depth_open_point")) bottom_point = _tuple_or_none(context.get("depth_current_bottom_point")) if open_point is not None and bottom_point is not None: scale_center = ( (open_point[0] + bottom_point[0]) * 0.5, (open_point[1] + bottom_point[1]) * 0.5, (open_point[2] + bottom_point[2]) * 0.5, ) scale_center = scale_center or _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) if scale_center is None: blockers.append("当前对象缺少稳定缩放中心,不能整体缩放所属对象。") current_depth_value = float(current_depth or 0.0) scale = target_depth / max(current_depth_value, 1e-9) delta_depth = target_depth - current_depth_value delta_ratio = abs(delta_depth) / max(current_depth_value, 1e-9) if current_depth is not None and abs(delta_depth) <= max(current_depth * 1e-6, 1e-6): blockers.append("目标盲孔/盲槽深度与当前值几乎相同,不需要修改。") if delta_ratio > 0.75: risk = _max_risk(risk, "high") warnings.append("盲孔/盲槽深度变化超过 75%,会明显影响所属对象上的其它轴向尺寸。") elif delta_ratio > 0.3: warnings.append("盲孔/盲槽深度变化超过 30%,修改后请重点检查相邻特征和壁厚。") cylinder_identity = {**info, **feature} angular_span = _effective_cylinder_angular_span(cylinder_identity) if angular_span is not None and not _is_effectively_full_cylinder(cylinder_identity): risk = _max_risk(risk, "high") warnings.append("当前是局部圆柱槽/半孔;整体缩放会改变所属对象,不等于稳定的局部槽底调整。") part_id = int(info.get("part_id", -1)) solid_id = int(info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None if part is None: blockers.append("找不到当前对象所属特征。") part_solid_count = 0 else: part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" target_label = "Solid" if target_kind == "solid" else "特征" warnings.append(f"当前会沿盲孔/盲槽方向缩放所属{target_label};孔距、壁厚和其它同向尺寸会跟随变化。") status = "blocked" if blockers else "caution" if blockers: risk = "blocked" return { "status": status, "risk": risk, "message": " ".join(blockers + warnings), "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": part_id, "solid_id": solid_id, "surface": info.get("surface"), "feature_type": feature.get("feature_type"), "feature_guess": info.get("feature_guess"), "confidence": info.get("confidence"), "current_depth": current_depth, "target_depth": target_depth, "delta_depth": delta_depth, "depth_delta_ratio": delta_ratio, "diameter": info.get("diameter"), "radius": info.get("radius"), "angular_span": info.get("angular_span"), "cylinder_end_type": info.get("cylinder_end_type"), "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", "" if bottom_face_id is None else bottom_face_id), "manual_bottom_face_used": bool(context.get("manual_bottom_face_used", bottom_face_id is not None)), "feature_opening_face_ids": feature.get("feature_opening_face_ids"), "depth_open_point": context.get("depth_open_point"), "depth_current_bottom_point": context.get("depth_current_bottom_point"), "depth_current_depth_source": context.get("depth_current_depth_source", ""), "resize_strategy": "axis-scale-owning-shape-from-blind-depth", "edit_strategy_label": "盲孔/盲槽深度(缩放特征)", "edit_semantics": ( "按目标盲孔/盲槽深度和当前深度的比例,沿孔/槽轴向缩放所属特征或 Solid;" "这会改变同一对象上的其它轴向尺寸,不是局部切削或补料。" ), "affine_scale": scale, "affine_transform_kind": "axis-affine", "affine_transform_label": f"按盲孔/盲槽深度轴向缩放所属{target_label}", "affine_transform_note": "单向仿射缩放可能把部分解析几何转换成 B-spline,并会改变同一对象上的其它轴向尺寸。", "affine_axis_point": scale_center, "affine_axis_direction": axis_direction or (0.0, 0.0, 1.0), "affine_axis_source": "blind depth direction", "affine_anchor_source": "blind depth midpoint", "affine_target_kind": target_kind, "part_solid_count": part_solid_count, } def resize_cylindrical_depth_owning_scale( self, face_id: int, target_depth: float, bottom_face_id: int | None = None, ) -> str: plan = self.cylindrical_depth_owning_scale_plan( face_id, target_depth, bottom_face_id=bottom_face_id, ) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) part_id = int(plan.get("part_id", self.face_part_ids[face_id])) part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") old_part_shape = part.shape verification: dict[str, object] = {} try: self._apply_edge_length_affine_transform(plan) verification = self._verify_cylindrical_depth_result(plan, part_id) if not verification.get("matched"): detail = str(verification.get("detail", "blind cylindrical depth verification failed")) raise RuntimeError( "Blind cylindrical depth owning-scale returned a shape, but no target-depth blind " f"cylindrical feature was detected after the edit; rolled back to the previous model. {detail}" ) except Exception: part.shape = old_part_shape self.refresh_topology() raise return ( "Blind cylindrical depth resize completed by axis owning-shape scaling: " f"face {face_id}, " f"depth={float(plan['current_depth']):g}->{float(plan['target_depth']):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " f"risk={plan['risk']}, verified_face={verification.get('face_id', '')}." ) def cylindrical_owning_scale_plan(self, face_id: int, target_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) blockers: list[str] = [] warnings: list[str] = [ "该方式会按目标直径比例均匀缩放所属特征或 Solid;它不是重切孔壁,也不是只修改单个圆柱面。" ] risk = "medium" try: target_diameter = float(target_diameter) except (TypeError, ValueError): target_diameter = 0.0 blockers.append("目标圆柱直径必须是数字。") current_diameter = _float_or_none(info.get("diameter")) current_radius = _float_or_none(info.get("radius")) axis_point = _tuple_or_none(info.get("axis_point")) axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis"))) topology_fields = ( self._cylindrical_feature_first_level_plan_fields(face_id) if info.get("surface") == "cylinder" else {} ) if info.get("surface") != "cylinder": blockers.append("当前选中 Face 不是圆柱面。") if current_diameter is None or current_diameter <= 1e-9: blockers.append("当前圆柱面缺少有效直径。") axis_range_value = info.get("same_domain_v_range") or info.get("v_range") scale_center = None if ( axis_point is not None and axis_direction is not None and isinstance(axis_range_value, (list, tuple)) and len(axis_range_value) >= 2 ): v_min = _float_or_none(axis_range_value[0]) v_max = _float_or_none(axis_range_value[1]) if v_min is not None and v_max is not None: v_mid = (v_min + v_max) * 0.5 scale_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, ) scale_center = scale_center or _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) if scale_center is None: blockers.append("当前圆柱面缺少稳定缩放中心,不能缩放所属对象。") if target_diameter <= 1e-9: blockers.append("目标圆柱直径必须大于 0。") scale = target_diameter / max(current_diameter or 1.0, 1e-9) delta_diameter = target_diameter - float(current_diameter or 0.0) delta_ratio = abs(delta_diameter) / max(float(current_diameter or 0.0), 1e-9) if current_diameter is not None and abs(delta_diameter) <= max(current_diameter * 1e-6, 1e-6): blockers.append("目标圆柱直径与当前值几乎相同,不需要修改。") if delta_ratio > 0.5: risk = _max_risk(risk, "high") warnings.append("圆柱直径变化超过 50%,会明显影响同一对象上的高度、厚度和其它尺寸。") elif delta_ratio > 0.2: warnings.append("圆柱直径变化超过 20%,修改后请重点检查相邻特征。") part_id = int(info.get("part_id", -1)) solid_id = int(info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None if part is None: blockers.append("找不到当前圆柱面所属特征。") part_solid_count = 0 else: part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" target_label = "Solid" if target_kind == "solid" else "特征" warnings.append(f"当前会均匀缩放所属{target_label},同一对象上的高度、厚度和其它尺寸会同比例变化。") status = "blocked" if blockers else "caution" if blockers: risk = "blocked" return { "status": status, "risk": risk, "message": " ".join(blockers + warnings), "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": part_id, "solid_id": solid_id, "surface": info.get("surface"), "feature_type": info.get("feature_type"), "feature_guess": info.get("feature_guess"), "confidence": info.get("confidence"), "current_diameter": current_diameter, "target_diameter": target_diameter, "current_radius": current_radius, "target_radius": target_diameter * 0.5, "delta_diameter": delta_diameter, "diameter_delta_ratio": delta_ratio, "scale_center": scale_center, "axis_point": axis_point, "axis": axis_direction, "cutter_axis_point": axis_point, "cutter_axis_direction": axis_direction, "resize_strategy": "uniform-scale-owning-shape-from-cylinder-diameter", "edit_strategy_label": "按圆柱直径缩放所属对象", "edit_semantics": ( "按目标圆柱直径和当前直径的比例,围绕当前圆柱面的轴向中心均匀缩放所属特征或 Solid;" "高度、厚度和同一对象上的其它尺寸会同比例变化。" ), "affine_scale": scale, "affine_transform_kind": "uniform", "affine_transform_label": "按圆柱直径均匀缩放所属对象", "affine_axis_point": scale_center, "affine_axis_direction": axis_direction or (0.0, 0.0, 1.0), "affine_axis_source": "cylinder axis center", "affine_anchor_source": "cylinder axis center", "affine_target_kind": target_kind, "part_solid_count": part_solid_count, **topology_fields, } def resize_cylindrical_owning_scale(self, face_id: int, target_diameter: float) -> str: plan = self.cylindrical_owning_scale_plan(face_id, target_diameter) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) part_id = int(plan.get("part_id", self.face_part_ids[face_id])) part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") old_part_shape = part.shape verification: dict[str, object] = {} try: self._apply_edge_length_affine_transform(plan) verification = self._verify_cylindrical_resize_result(plan, part_id) if not verification.get("matched"): detail = str(verification.get("detail", "cylindrical diameter verification failed")) raise RuntimeError( "Cylindrical owning-scale returned a shape, but no target-diameter cylindrical Face " f"was detected after the edit; rolled back to the previous model. {detail}" ) except Exception: part.shape = old_part_shape self.refresh_topology() raise return ( "Cylindrical diameter resize completed by uniform owning-shape scaling: " f"face {face_id}, " f"diameter={float(plan['current_diameter']):g}->{float(plan['target_diameter']):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " f"risk={plan['risk']}, verified_face={verification.get('face_id', '')}." ) def conical_reference_radius_plan(self, face_id: int, target_radius: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) blockers: list[str] = [] warnings: list[str] = [ "圆锥面参考半径修改会围绕圆锥轴径向缩放所属零件/Solid;这是 B-Rep 几何缩放,不是 CAD 历史参数。" ] risk = "medium" try: target_radius = float(target_radius) except (TypeError, ValueError): target_radius = 0.0 blockers.append("目标圆锥参考半径必须是数字。") current_radius = _float_or_none(info.get("reference_radius")) axis_point = _tuple_or_none(info.get("axis_point")) axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis"))) if info.get("surface") != "cone": blockers.append("当前选中 Face 不是圆锥面。") if current_radius is None or current_radius <= 1e-9: blockers.append("当前圆锥面缺少有效参考半径。") if axis_point is None or axis_direction is None: blockers.append("当前圆锥面缺少稳定轴线,不能做径向缩放。") if target_radius <= 1e-9: blockers.append("目标圆锥参考半径必须大于 0。") scale = target_radius / max(current_radius or 1.0, 1e-9) current_semi_angle = _float_or_none(info.get("semi_angle")) current_semi_angle_degrees = None target_semi_angle = None target_semi_angle_degrees = None if current_semi_angle is not None: current_semi_angle_degrees = abs(math.degrees(current_semi_angle)) current_tangent = abs(math.tan(current_semi_angle)) if current_tangent > 1e-9 and scale > 0: target_semi_angle = math.atan(current_tangent * scale) target_semi_angle_degrees = math.degrees(target_semi_angle) delta_radius = target_radius - float(current_radius or 0.0) delta_ratio = abs(delta_radius) / max(float(current_radius or 0.0), 1e-9) if current_radius is not None and abs(delta_radius) <= max(current_radius * 1e-6, 1e-6): blockers.append("目标圆锥参考半径与当前值几乎相同,不需要修改。") if delta_ratio > 0.6: risk = _max_risk(risk, "high") warnings.append("圆锥参考半径变化超过 60%,可能明显影响周边几何。") elif delta_ratio > 0.25: risk = _max_risk(risk, "high") warnings.append("圆锥参考半径变化超过 25%,修改后请重点检查相邻面。") part_id = int(info.get("part_id", -1)) solid_id = int(info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None if part is None: blockers.append("找不到当前圆锥面所属零件。") part_solid_count = 0 else: part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" if target_kind == "part": warnings.append("当前会缩放所属零件 shape,可能影响同一零件上的其它尺寸。") else: warnings.append("当前会缩放所属 Solid,可能影响同一 Solid 上的其它尺寸。") status = "blocked" if blockers else "caution" if blockers: risk = "blocked" plan = { "status": status, "risk": risk, "message": " ".join(blockers + warnings), "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": part_id, "solid_id": solid_id, "surface": info.get("surface"), "current_reference_radius": current_radius, "target_reference_radius": target_radius, "current_reference_diameter": None if current_radius is None else current_radius * 2.0, "target_reference_diameter": target_radius * 2.0, "delta_reference_radius": delta_radius, "reference_radius_delta_ratio": delta_ratio, "semi_angle": current_semi_angle, "semi_angle_degrees": current_semi_angle_degrees, "target_semi_angle": target_semi_angle, "target_semi_angle_degrees": target_semi_angle_degrees, "axis_point": axis_point, "axis": axis_direction, "resize_strategy": "radial-affine-scale-cone-reference-radius", "edit_strategy_label": "围绕圆锥轴径向缩放", "edit_semantics": "按目标参考半径围绕圆锥轴径向缩放所属对象;会影响同一对象上的其它径向尺寸。", "affine_scale": scale, "affine_transform_kind": "radial-affine", "affine_transform_label": "围绕圆锥轴径向缩放", "affine_axis_point": axis_point, "affine_axis_direction": axis_direction, "affine_axis_source": "cone axis", "affine_anchor_source": "cone axis point", "affine_target_kind": target_kind, "part_solid_count": part_solid_count, } self._annotate_simple_conical_rebuild_plan(plan, "reference-radius") self._block_obvious_complex_conical_fallback_plan(plan, "reference-radius") self._annotate_embedded_conical_recut_plan(plan, "reference-radius") self._block_unstable_conical_reference_radius_plan(plan) return plan def conical_semi_angle_plan(self, face_id: int, target_angle_degrees: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.quick_face_info(face_id) blockers: list[str] = [] warnings: list[str] = [ "圆锥半角修改会换算为围绕圆锥轴的径向缩放;高度不变,两个端面半径会按同一比例变化。" ] try: target_angle_degrees = float(target_angle_degrees) except (TypeError, ValueError): target_angle_degrees = 0.0 blockers.append("目标圆锥半角必须是数字。") current_radius = _float_or_none(info.get("reference_radius")) current_angle = _float_or_none(info.get("semi_angle")) if info.get("surface") != "cone": blockers.append("当前选中 Face 不是圆锥面。") if current_radius is None or current_radius <= 1e-9: blockers.append("当前圆锥面缺少有效参考半径。") if current_angle is None: blockers.append("当前圆锥面缺少稳定半角。") if target_angle_degrees <= 0 or target_angle_degrees >= 89.0: blockers.append("目标圆锥半角必须大于 0 且小于 89 度。") current_tangent = abs(math.tan(current_angle)) if current_angle is not None else 0.0 target_tangent = math.tan(math.radians(target_angle_degrees)) if target_angle_degrees > 0 else 0.0 if current_angle is not None and current_tangent <= 1e-9: blockers.append("当前圆锥半角过小,不能稳定换算参考半径。") if target_tangent <= 1e-9: blockers.append("目标圆锥半角过小,不能稳定换算参考半径。") target_radius = ( float(current_radius) * target_tangent / current_tangent if current_radius is not None and current_radius > 0 and current_tangent > 1e-9 and target_tangent > 1e-9 else 0.0 ) base_plan = self.conical_reference_radius_plan(face_id, target_radius) base_strategy = str(base_plan.get("resize_strategy") or "") ignore_reference_fallback_blocker = base_strategy.startswith("blocked-cone-reference-radius") or base_strategy.startswith( "blocked-complex-cone-reference-radius" ) base_blockers = str(base_plan.get("blockers") or "") blocker_parts = [part for part in blockers if part] if base_blockers and not ignore_reference_fallback_blocker: blocker_parts.extend(part for part in base_blockers.split(";") if part and part not in blocker_parts) base_warnings = str(base_plan.get("warnings") or "") warning_parts = [part for part in warnings if part] if base_warnings: warning_parts.extend(part for part in base_warnings.split(";") if part and part not in warning_parts) base_status = "caution" if ignore_reference_fallback_blocker else str(base_plan.get("status") or "caution") base_risk = "medium" if ignore_reference_fallback_blocker else str(base_plan.get("risk") or "medium") status = "blocked" if blocker_parts else base_status risk = "blocked" if blocker_parts else base_risk base_plan.update( { "status": status, "risk": risk, "message": " ".join(blocker_parts + warning_parts), "warnings": ";".join(warning_parts), "blockers": ";".join(blocker_parts), "target_semi_angle": math.radians(target_angle_degrees) if target_angle_degrees > 0 else None, "target_semi_angle_degrees": target_angle_degrees if target_angle_degrees > 0 else None, "target_reference_radius": target_radius if target_radius > 0 else None, "target_reference_diameter": target_radius * 2.0 if target_radius > 0 else None, "resize_strategy": "radial-affine-scale-cone-semi-angle", "edit_strategy_label": "按圆锥半角径向缩放", "edit_semantics": "按目标半角换算径向缩放比例;圆锥高度不变,端面半径和相邻径向尺寸会跟随变化。", } ) self._annotate_simple_conical_rebuild_plan(base_plan, "semi-angle") self._block_obvious_complex_conical_fallback_plan(base_plan, "semi-angle") self._annotate_embedded_conical_recut_plan(base_plan, "semi-angle") self._block_unstable_conical_semi_angle_plan(base_plan) return base_plan def _annotate_simple_conical_rebuild_plan(self, plan: dict[str, object], mode: str) -> None: if str(plan.get("status")) == "blocked": return try: spec = self._simple_conical_rebuild_spec(plan) except Exception: spec = None if spec is None: plan["analytic_rebuild_available"] = False return if mode == "reference-radius" and not bool(spec.get("reference_radius_matches_current", False)): plan["analytic_rebuild_available"] = False plan["analytic_rebuild_skip_reason"] = "cone-reference-radius-does-not-match-a-cap" return plan["analytic_rebuild_available"] = True plan["analytic_cone_rebuild_height"] = spec.get("height") plan["analytic_cone_rebuild_reference_radius"] = spec.get("reference_radius") plan["analytic_cone_rebuild_other_radius"] = spec.get("other_radius") plan["analytic_cone_reference_radius_matches_current"] = spec.get("reference_radius_matches_current") plan["fallback_resize_strategy"] = plan.get("resize_strategy") if mode == "semi-angle": plan["resize_strategy"] = "analytic-cone-rebuild-semi-angle" else: plan["resize_strategy"] = "analytic-cone-rebuild-reference-radius" def _block_obvious_complex_conical_fallback_plan(self, plan: dict[str, object], mode: str) -> None: if str(plan.get("status")) == "blocked": return if bool(plan.get("analytic_rebuild_available", False)): return if bool(plan.get("embedded_cone_recut_available", False)): return expected_strategy = { "reference-radius": "radial-affine-scale-cone-reference-radius", "semi-angle": "radial-affine-scale-cone-semi-angle", }.get(mode) if expected_strategy is None or str(plan.get("resize_strategy") or "") != expected_strategy: return current_angle = _float_or_none(plan.get("semi_angle_degrees")) current_radius = _float_or_none(plan.get("current_reference_radius")) face_id = int(plan.get("face_id", -1)) if current_angle is None or current_angle > 3.0 or current_radius is None: return face_diagonal = 0.0 if 0 <= face_id < len(self.faces): try: face_diagonal = _shape_diagonal(self.faces[face_id]) except Exception: face_diagonal = 0.0 owning_diagonal = 0.0 part_id = int(plan.get("part_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None if part is not None: try: owning_diagonal = _shape_diagonal(part.shape) except Exception: owning_diagonal = 0.0 if current_radius <= max(face_diagonal * 4.0, owning_diagonal * 2.0, 1.0): return current_angle_text = _format_result_number(current_angle) current_radius_text = _format_result_number(current_radius) if mode == "reference-radius": target_radius = _float_or_none(plan.get("target_reference_radius")) target_radius_text = _format_result_number(target_radius) reason = ( "当前对象仍然是 Face,但底层是复杂浅锥/拔模面;" f"半角约 {current_angle_text}°,参考半径约 {current_radius_text},已经远大于当前 Face/所属特征尺寸。" f"把参考半径改到 {target_radius_text} 这类操作不适合走整体径向缩放,也不应该在界面线程里继续做昂贵的锥孔识别。" "当前版本只对简单圆锥解析重建,或可识别的锥孔/沉孔局部重切开放参考半径/直径修改;" "复杂浅锥/拔模面会提前阻止,避免界面卡死或生成无效 B-Rep。" ) blocked_strategy = "blocked-complex-cone-reference-radius-shallow-far-axis" label = "暂不开放复杂浅锥参考半径修改" else: target_angle = _float_or_none(plan.get("target_semi_angle_degrees")) target_radius = _float_or_none(plan.get("target_reference_radius")) target_angle_text = _format_result_number(target_angle) target_radius_text = _format_result_number(target_radius) reason = ( "当前对象仍然是 Face,但底层是复杂浅锥/拔模面;" f"半角约 {current_angle_text}°,参考半径约 {current_radius_text},已经远大于当前 Face/所属特征尺寸。" f"把半角改到 {target_angle_text}° 会换算出约 {target_radius_text} 的参考半径," "不适合走整体径向缩放,也不应该在界面线程里继续做昂贵的锥孔识别。" "当前版本只对简单圆锥解析重建,或可识别的锥孔/沉孔局部重切开放半角修改;" "复杂浅锥/拔模面会提前阻止,避免界面卡死或生成无效 B-Rep。" ) blocked_strategy = "blocked-complex-cone-semi-angle-shallow-far-axis" label = "暂不开放复杂浅锥半角修改" existing_blockers = [part for part in str(plan.get("blockers") or "").split(";") if part] if reason not in existing_blockers: existing_blockers.append(reason) plan.update( { "status": "blocked", "risk": "blocked", "blockers": ";".join(existing_blockers), "message": reason, "resize_strategy": blocked_strategy, "edit_strategy_label": label, "edit_semantics": reason, "cone_fast_block_face_diagonal": face_diagonal, "cone_fast_block_owning_diagonal": owning_diagonal, } ) def _block_unstable_conical_reference_radius_plan(self, plan: dict[str, object]) -> None: if str(plan.get("status")) == "blocked": return if bool(plan.get("analytic_rebuild_available", False)): return if bool(plan.get("embedded_cone_recut_available", False)): return if str(plan.get("resize_strategy") or "") != "radial-affine-scale-cone-reference-radius": return if str(plan.get("analytic_rebuild_skip_reason") or "") == "cone-reference-radius-does-not-match-a-cap": reason = ( "当前圆锥面的参考半径不在可识别的圆形端面上;当前版本不能稳定保留这个参考位置来直接修改参考半径," "否则容易出现目标值无法回读、圆锥面退化或编辑结果被回滚。" "当前只对简单圆锥解析重建,或可识别的锥孔/沉孔局部重切开放参考半径/直径修改。" ) strategy = "blocked-cone-reference-radius-non-cap" label = "暂不开放非端面参考半径" else: reason = ( "当前对象仍然是 Face,但它的底层曲面类型是复杂圆锥面/拔模面;" "当前版本没有把这个 Face 识别为简单圆锥,也没有识别成双圆边界的锥孔/沉孔," "因此不再使用整体径向缩放兜底修改参考半径/直径。整体缩放会影响所属特征的其它尺寸," "并且在复杂 STEP 上容易生成无效 B-Rep。" ) strategy = "blocked-complex-cone-reference-radius-unsupported-fallback" label = "暂不开放复杂圆锥参考半径兜底修改" existing_blockers = [part for part in str(plan.get("blockers") or "").split(";") if part] if reason not in existing_blockers: existing_blockers.append(reason) warnings = [part for part in str(plan.get("warnings") or "").split(";") if part] plan.update( { "status": "blocked", "risk": "blocked", "blockers": ";".join(existing_blockers), "message": reason, "resize_strategy": strategy, "edit_strategy_label": label, "edit_semantics": reason, } ) def _block_unstable_conical_semi_angle_plan(self, plan: dict[str, object]) -> None: if str(plan.get("status")) == "blocked": return if bool(plan.get("analytic_rebuild_available", False)): return if bool(plan.get("embedded_cone_recut_available", False)): return if str(plan.get("resize_strategy") or "") != "radial-affine-scale-cone-semi-angle": return current_angle = _float_or_none(plan.get("semi_angle_degrees")) target_angle = _float_or_none(plan.get("target_semi_angle_degrees")) current_radius = _float_or_none(plan.get("current_reference_radius")) target_radius = _float_or_none(plan.get("target_reference_radius")) scale = _float_or_none(plan.get("affine_scale")) face_id = int(plan.get("face_id", -1)) face_diagonal = 0.0 if 0 <= face_id < len(self.faces): face_diagonal = _shape_diagonal(self.faces[face_id]) owning_diagonal = 0.0 try: _target_kind, source_shape, _part, _solid = self._edge_length_affine_target(plan) owning_diagonal = _shape_diagonal(source_shape) except Exception: try: owning_diagonal = _shape_diagonal(self.shape) except Exception: owning_diagonal = 0.0 model_size = max(face_diagonal, owning_diagonal, 1.0) angle_delta = ( abs(float(target_angle) - float(current_angle)) if current_angle is not None and target_angle is not None else 0.0 ) shallow_far_axis = ( current_angle is not None and current_angle <= 3.0 and current_radius is not None and current_radius > max(face_diagonal * 4.0, owning_diagonal * 2.0, 1.0) ) extreme_scale = scale is None or scale <= 0.0 or scale > 3.0 or scale < (1.0 / 3.0) extreme_reference = target_radius is not None and target_radius > model_size * 8.0 large_angle_jump = angle_delta >= 3.0 current_angle_text = _format_result_number(current_angle) target_angle_text = _format_result_number(target_angle) current_radius_text = _format_result_number(current_radius) target_radius_text = _format_result_number(target_radius) scale_text = _format_result_number(scale) is_extreme_shallow = shallow_far_axis and (extreme_scale or extreme_reference or large_angle_jump) if is_extreme_shallow: reason = ( "当前对象仍然是 Face,但它的底层曲面类型是复杂浅锥/拔模面;" "1° 左右的浅锥面视觉上很像平面,不适合用整体径向缩放直接修改半角。" f"半角从 {current_angle_text}° 改到 {target_angle_text}° 会把参考半径从 " f"{current_radius_text} 放大到 {target_radius_text},缩放比例约 {scale_text};" "这类结果在复杂 STEP 上容易生成无效 B-Rep。当前版本只对简单圆锥解析重建," "或可识别的锥孔/沉孔局部重切开放大幅半角修改。" ) blocked_strategy = "blocked-complex-cone-semi-angle-extreme-scale" label = "暂不开放复杂浅锥半角大幅修改" else: reason = ( "当前对象仍然是 Face,但它的底层曲面类型是圆锥面/拔模面;" "当前版本没有把这个 Face 识别为简单圆锥,也没有识别成双圆边界的锥孔/沉孔," "因此不再使用整体径向缩放兜底修改半角。整体缩放会影响所属特征的其它尺寸," "并且在复杂 STEP 上容易生成无效 B-Rep。" ) blocked_strategy = "blocked-complex-cone-semi-angle-unsupported-fallback" label = "暂不开放复杂圆锥半角兜底修改" existing_blockers = [part for part in str(plan.get("blockers") or "").split(";") if part] if reason not in existing_blockers: existing_blockers.append(reason) warnings = [part for part in str(plan.get("warnings") or "").split(";") if part] plan.update( { "status": "blocked", "risk": "blocked", "blockers": ";".join(existing_blockers), "message": reason, "resize_strategy": blocked_strategy, "edit_strategy_label": label, "edit_semantics": reason, "cone_semi_angle_block_face_diagonal": face_diagonal, "cone_semi_angle_block_owning_diagonal": owning_diagonal, } ) def _annotate_embedded_conical_recut_plan(self, plan: dict[str, object], mode: str) -> None: if str(plan.get("status")) == "blocked": return if bool(plan.get("analytic_rebuild_available", False)): return try: spec = self._embedded_conical_recut_spec(plan, mode) except Exception: spec = None if spec is None: plan["embedded_cone_recut_available"] = False return recut_mode = str(spec.get("embedded_cone_recut_mode") or "enlarge") if mode == "reference-radius": recut_action = "先补料封回旧锥孔,再按目标参考半径重切" if recut_mode == "shrink" else "按目标参考半径局部扩大重切" strategy = "bounded-cone-recut-preserve-angle-reference-radius" semantics = ( "保持锥孔当前半角和轴向深度不变,按目标参考半径局部重切锥孔;" "不会整体缩放所属对象,也不会自动联动相连的圆柱孔直径。" ) else: recut_action = "先补料封回旧锥孔,再按目标半角重切" if recut_mode == "shrink" else "按目标半角局部扩大重切" strategy = "bounded-cone-recut-fixed-small-radius-semi-angle" semantics = ( "保持锥孔较小端半径和轴向深度不变;放大半角时直接用目标圆锥 cutter 扩大开口," "缩小半角时先补料封回旧锥孔再按目标半角重切。" ) warning = ( "检测到嵌入式锥孔/沉孔类圆锥 Face;本次会优先局部重切圆锥开口," f"不再围绕圆锥轴缩放整个所属对象。执行方式:{recut_action}。" ) warnings = [ part for part in str(plan.get("warnings") or "").split(";") if part and "径向缩放" not in part and "缩放所属" not in part and "同一零件上的其它尺寸" not in part and "同一 Solid 上的其它尺寸" not in part ] if warning not in warnings: warnings.append(warning) plan.update( { **spec, "embedded_cone_recut_available": True, "fallback_resize_strategy": plan.get("resize_strategy"), "resize_strategy": strategy, "edit_strategy_label": "锥孔局部重切", "edit_semantics": semantics, "risk": _max_risk(str(plan.get("risk") or "medium"), "high"), "status": "caution", "warnings": ";".join(warnings), "message": " ".join([part for part in str(plan.get("blockers") or "").split(";") if part] + warnings), } ) def _embedded_conical_recut_spec(self, plan: dict[str, object], mode: str) -> dict[str, object] | None: if mode not in {"semi-angle", "reference-radius"}: return None face_id = int(plan.get("face_id", -1)) if face_id < 0 or face_id >= len(self.faces): return None axis_point = _tuple_or_none(plan.get("axis_point")) axis_direction = _tuple_normalized(_tuple_or_none(plan.get("axis"))) if axis_point is None or axis_direction is None: return None circles = self._conical_face_circle_boundaries(face_id, axis_point, axis_direction) if len(circles) != 2: return None circles = sorted(circles, key=lambda item: float(item["radius"])) small = circles[0] large = circles[1] small_radius = float(small["radius"]) large_radius = float(large["radius"]) small_center = tuple(small["center"]) large_center = tuple(large["center"]) height = _vector_length(_tuple_sub(large_center, small_center)) if small_radius <= 1e-9 or large_radius <= small_radius or height <= 1e-9: return None current_tangent = (large_radius - small_radius) / height if current_tangent <= 1e-9: return None tolerance = max(_shape_diagonal(self.faces[face_id]) * 1e-6, large_radius * 1e-5, 1e-5) if mode == "semi-angle": target_angle_degrees = _float_or_none(plan.get("target_semi_angle_degrees")) if target_angle_degrees is None or target_angle_degrees <= 0.0 or target_angle_degrees >= 89.0: return None target_tangent = math.tan(math.radians(target_angle_degrees)) if target_tangent <= 1e-9: return None target_small_radius = small_radius target_large_radius = small_radius + height * target_tangent if abs(target_large_radius - large_radius) <= tolerance: return None edit_mode = "enlarge" if target_large_radius > large_radius else "shrink" else: target_reference_radius = _float_or_none(plan.get("target_reference_radius")) current_reference_radius = _float_or_none(plan.get("current_reference_radius")) if ( target_reference_radius is None or current_reference_radius is None or target_reference_radius <= 1e-9 or current_reference_radius <= 1e-9 ): return None reference_parameter = (current_reference_radius - small_radius) / current_tangent reference_matches_small = abs(reference_parameter) <= max(tolerance / current_tangent, tolerance) reference_matches_large = abs(reference_parameter - height) <= max(tolerance / current_tangent, tolerance) target_small_radius = target_reference_radius - reference_parameter * current_tangent target_large_radius = target_small_radius + height * current_tangent if target_small_radius <= tolerance or target_large_radius <= target_small_radius + tolerance: return None radial_delta = target_reference_radius - current_reference_radius if abs(radial_delta) <= tolerance: return None target_tangent = current_tangent target_angle_degrees = math.degrees(math.atan(current_tangent)) edit_mode = "enlarge" if radial_delta > 0.0 else "shrink" target_kind, source_shape, _part, source_solid = self._edge_length_affine_target(plan) classifier_solid = source_solid or source_shape if not self._embedded_conical_face_is_hole_like(classifier_solid, small, large, tolerance): return None tool_direction = _tuple_normalized(_tuple_sub(large_center, small_center)) if tool_direction is None: return None if target_large_radius <= target_small_radius + tolerance: return None end_margin = ( min(max(height * 0.01, target_large_radius * 0.005, 0.02), max(height * 0.05, 0.05)) if edit_mode == "enlarge" else 0.0 ) tool_height = height + end_margin tool_end_radius = target_large_radius + target_tangent * end_margin diagonal = max(_shape_diagonal(source_shape), target_large_radius, 1.0) if tool_end_radius > max(diagonal * 1.2, large_radius * 6.0): return None return { "embedded_cone_recut_mode": edit_mode, "embedded_cone_fixed_small_radius": small_radius if mode == "semi-angle" else None, "embedded_cone_current_small_radius": small_radius, "embedded_cone_target_small_radius": target_small_radius, "embedded_cone_current_large_radius": large_radius, "embedded_cone_target_large_radius": target_large_radius, "embedded_cone_current_angle_degrees": math.degrees(math.atan(current_tangent)), "embedded_cone_target_angle_degrees": target_angle_degrees, "embedded_cone_reference_parameter": reference_parameter if mode == "reference-radius" else None, "embedded_cone_reference_matches_small": reference_matches_small if mode == "reference-radius" else None, "embedded_cone_reference_matches_large": reference_matches_large if mode == "reference-radius" else None, "embedded_cone_height": height, "embedded_cone_tool_start_point": small_center, "embedded_cone_tool_direction": tool_direction, "embedded_cone_tool_start_radius": target_small_radius, "embedded_cone_tool_end_radius": tool_end_radius, "embedded_cone_tool_height": tool_height, "embedded_cone_tool_end_margin": end_margin, "embedded_cone_fill_start_radius": small_radius, "embedded_cone_fill_end_radius": large_radius, "embedded_cone_fill_height": height, "embedded_cone_target_kind": target_kind, } def _conical_face_circle_boundaries( self, face_id: int, axis_point: tuple[float, float, float], axis_direction: tuple[float, float, float], ) -> list[dict[str, object]]: face = self.faces[face_id] axis_origin = gp_Pnt(*axis_point) axis_dir = gp_Dir(*axis_direction) tolerance = max(_shape_diagonal(face) * 1e-6, 1e-6) circles: list[dict[str, object]] = [] for edge in _explore(face, TopAbs_EDGE): try: curve = BRepAdaptor_Curve(edge) if curve.GetType() != GeomAbs_Circle: continue circle = curve.Circle() center = circle.Location() radius = float(circle.Radius()) if radius <= 1e-9: continue if abs(_direction_dot(circle.Axis().Direction(), axis_dir)) < 0.95: continue if _point_axis_distance(axis_origin, axis_dir, center) > max(radius * 1e-5, tolerance): continue parameter = _axis_parameter(axis_origin, axis_dir, center) sample = curve.Value(curve.FirstParameter()) item = { "radius": radius, "center": _point_tuple(center), "axis_parameter": parameter, "sample_point": _point_tuple(sample), } duplicate = False for existing in circles: if ( abs(float(existing["radius"]) - radius) <= tolerance and _vector_length(_tuple_sub(tuple(existing["center"]), item["center"])) <= tolerance ): duplicate = True break if not duplicate: circles.append(item) except Exception: continue return circles def _embedded_conical_face_is_hole_like( self, solid: TopoDS_Shape, small: dict[str, object], large: dict[str, object], tolerance: float, ) -> bool: small_center = tuple(small["center"]) large_center = tuple(large["center"]) large_sample = tuple(large["sample_point"]) radial = _tuple_normalized(_tuple_sub(large_sample, large_center)) if radial is None: return False small_radius = float(small["radius"]) large_radius = float(large["radius"]) mid_center = ( (small_center[0] + large_center[0]) * 0.5, (small_center[1] + large_center[1]) * 0.5, (small_center[2] + large_center[2]) * 0.5, ) mid_radius = (small_radius + large_radius) * 0.5 sample_point = _tuple_add(mid_center, _tuple_scale(radial, mid_radius)) offset = max((large_radius - small_radius) * 0.08, large_radius * 0.02, tolerance * 10.0, 0.02) toward_axis = gp_Pnt(*_tuple_sub(sample_point, _tuple_scale(radial, offset))) away_axis = gp_Pnt(*_tuple_add(sample_point, _tuple_scale(radial, offset))) return _solid_state(solid, toward_axis) == "outside" and _solid_state(solid, away_axis) == "inside" def _apply_conical_analytic_rebuild_if_simple(self, plan: dict[str, object]) -> bool: if not bool(plan.get("analytic_rebuild_available", False)): return False spec = self._simple_conical_rebuild_spec(plan) if spec is None: return False target_kind, _source_shape, part, source_solid = self._edge_length_affine_target(plan) maker = BRepPrimAPI_MakeCone( float(spec["reference_radius"]), float(spec["other_radius"]), float(spec["height"]), ) rebuilt = maker.Shape() if rebuilt.IsNull(): raise RuntimeError("Analytic cone rebuild produced an empty shape.") transform = self._axis_placement_transform( spec["reference_center"], spec["reference_to_other_direction"], ) builder = BRepBuilderAPI_Transform(rebuilt, transform, True) builder.Build() if not builder.IsDone(): raise RuntimeError("Analytic cone placement transform failed.") transformed = builder.Shape() if transformed.IsNull(): raise RuntimeError("Analytic cone placement transform produced an empty shape.") transformed = _ensure_valid_or_repaired_shape(transformed, "analytic cone rebuild") if target_kind == "part": part.shape = transformed else: part_solids = _explore(part.shape, TopAbs_SOLID) replaced = False shapes: list[TopoDS_Shape] = [] for item in part_solids: if not replaced and source_solid is not None and _same_shape(item, source_solid): shapes.append(transformed) replaced = True else: shapes.append(item) if not replaced: raise RuntimeError(f"Could not locate solid {plan.get('solid_id')} inside part {plan.get('part_id')}.") part.shape = _compound_from_shapes(shapes) _ensure_valid_shape(part.shape) self.refresh_topology() return True def _apply_embedded_conical_recut_if_available(self, plan: dict[str, object]) -> bool: if not bool(plan.get("embedded_cone_recut_available", False)): return False target_kind, source_shape, part, source_solid = self._edge_length_affine_target(plan) start = _tuple_or_none(plan.get("embedded_cone_tool_start_point")) direction = _tuple_normalized(_tuple_or_none(plan.get("embedded_cone_tool_direction"))) start_radius = _float_or_none(plan.get("embedded_cone_tool_start_radius")) end_radius = _float_or_none(plan.get("embedded_cone_tool_end_radius")) height = _float_or_none(plan.get("embedded_cone_tool_height")) recut_mode = str(plan.get("embedded_cone_recut_mode") or "enlarge") if ( start is None or direction is None or start_radius is None or end_radius is None or height is None or start_radius <= 1e-9 or end_radius <= start_radius or height <= 1e-9 ): raise RuntimeError("Embedded conical recut plan is missing a valid cutter.") source_for_cut = source_shape if recut_mode == "shrink": fill_start_radius = _float_or_none(plan.get("embedded_cone_fill_start_radius")) fill_end_radius = _float_or_none(plan.get("embedded_cone_fill_end_radius")) fill_height = _float_or_none(plan.get("embedded_cone_fill_height")) if ( fill_start_radius is None or fill_end_radius is None or fill_height is None or fill_start_radius <= 1e-9 or fill_end_radius <= fill_start_radius or fill_height <= 1e-9 ): raise RuntimeError("Embedded conical recut shrink plan is missing a valid filler.") filler = BRepPrimAPI_MakeCone( gp_Ax2(gp_Pnt(*start), gp_Dir(*direction)), float(fill_start_radius), float(fill_end_radius), float(fill_height), ).Shape() if filler.IsNull(): raise RuntimeError("Embedded conical recut produced an empty filler.") fuse = BRepAlgoAPI_Fuse(source_shape, filler) source_for_cut = _finalize_boolean_result(fuse, "embedded conical recut fill old cone", use_glue=False) cutter = BRepPrimAPI_MakeCone( gp_Ax2(gp_Pnt(*start), gp_Dir(*direction)), float(start_radius), float(end_radius), float(height), ).Shape() if cutter.IsNull(): raise RuntimeError("Embedded conical recut produced an empty cutter.") op = BRepAlgoAPI_Cut(source_for_cut, cutter) result = _finalize_boolean_result(op, "embedded conical recut") if target_kind == "part": part.shape = result else: part_solids = _explore(part.shape, TopAbs_SOLID) replaced = False shapes: list[TopoDS_Shape] = [] for item in part_solids: if not replaced and source_solid is not None and _same_shape(item, source_solid): shapes.append(result) replaced = True else: shapes.append(item) if not replaced: raise RuntimeError(f"Could not locate solid {plan.get('solid_id')} inside part {plan.get('part_id')}.") part.shape = _compound_from_shapes(shapes) _ensure_valid_shape(part.shape) self.refresh_topology() return True def _simple_conical_rebuild_spec(self, plan: dict[str, object]) -> dict[str, object] | None: target_kind, source_shape, _part, _source_solid = self._edge_length_affine_target(plan) if len(_explore(source_shape, TopAbs_SOLID)) != 1: return None current_reference_radius = _float_or_none(plan.get("current_reference_radius")) target_reference_radius = _float_or_none(plan.get("target_reference_radius")) scale = _float_or_none(plan.get("affine_scale")) if ( current_reference_radius is None or target_reference_radius is None or scale is None or current_reference_radius <= 1e-9 or target_reference_radius <= 1e-9 or scale <= 1e-9 ): return None faces = _explore(source_shape, TopAbs_FACE) cone_faces: list[TopoDS_Shape] = [] cap_specs: list[dict[str, object]] = [] for face in faces: try: surf = BRepAdaptor_Surface(face) surface_type = surf.GetType() except Exception: return None if surface_type == GeomAbs_Cone: cone_faces.append(face) continue if surface_type != GeomAbs_Plane: return None edge_count = len(_explore(face, TopAbs_EDGE)) if edge_count != 1: return None props = GProp_GProps() try: brepgprop.SurfaceProperties(face, props) except Exception: return None area = float(props.Mass()) if area <= 1e-9: return None radius = math.sqrt(area / math.pi) center = _point_tuple(props.CentreOfMass()) cap_specs.append({"radius": radius, "center": center}) if target_kind not in {"part", "solid"} or len(cone_faces) != 1 or len(cap_specs) not in {1, 2}: return None length_reference = max( _shape_diagonal(source_shape), current_reference_radius, target_reference_radius, 1.0, ) radius_tolerance = max(length_reference * 1e-5, 1e-5) if len(cap_specs) == 1: reference_cap = cap_specs[0] reference_center = tuple(reference_cap["center"]) apex = self._simple_conical_apex_point(source_shape, reference_center, radius_tolerance, plan) if apex is None: return None direction = _tuple_normalized(_tuple_sub(apex, reference_center)) height = _vector_length(_tuple_sub(apex, reference_center)) if direction is None or height <= radius_tolerance: return None reference_radius = float(reference_cap["radius"]) * scale if reference_radius <= 1e-9: return None return { "reference_center": reference_center, "reference_to_other_direction": direction, "reference_radius": reference_radius, "other_radius": 0.0, "height": height, "reference_radius_matches_current": abs(float(reference_cap["radius"]) - current_reference_radius) <= radius_tolerance, } matching_caps = [ cap for cap in cap_specs if abs(float(cap["radius"]) - current_reference_radius) <= radius_tolerance ] reference_radius_matches_current = len(matching_caps) == 1 if reference_radius_matches_current: reference_cap = matching_caps[0] other_cap = cap_specs[0] if reference_cap is cap_specs[1] else cap_specs[1] else: reference_cap = cap_specs[0] other_cap = cap_specs[1] reference_center = tuple(reference_cap["center"]) other_center = tuple(other_cap["center"]) direction = _tuple_normalized(_tuple_sub(other_center, reference_center)) height = _vector_length(_tuple_sub(other_center, reference_center)) if direction is None or height <= radius_tolerance: return None reference_radius = float(reference_cap["radius"]) * scale other_radius = float(other_cap["radius"]) * scale if reference_radius <= 1e-9 or other_radius <= 1e-9: return None return { "reference_center": reference_center, "reference_to_other_direction": direction, "reference_radius": reference_radius, "other_radius": other_radius, "height": height, "reference_radius_matches_current": reference_radius_matches_current, } def _simple_conical_apex_point( self, shape: TopoDS_Shape, cap_center: tuple[float, float, float], tolerance: float, plan: dict[str, object], ) -> tuple[float, float, float] | None: axis_direction = _tuple_normalized(_tuple_or_none(plan.get("axis"))) unique_points: list[tuple[float, float, float]] = [] for vertex in _explore(shape, TopAbs_VERTEX): try: point = _point_tuple(BRep_Tool.Pnt(topods.Vertex(vertex))) except Exception: continue if any(_vector_length(_tuple_sub(point, existing)) <= tolerance for existing in unique_points): continue unique_points.append(point) if not unique_points: return None best_point: tuple[float, float, float] | None = None best_score = -math.inf for point in unique_points: vector = _tuple_sub(point, cap_center) if axis_direction is not None: score = abs(_tuple_dot(vector, axis_direction)) else: score = _vector_length(vector) if score > best_score: best_score = score best_point = point if best_point is None or best_score <= tolerance: return None return best_point def _axis_placement_transform( self, origin: tuple[float, float, float], z_direction: tuple[float, float, float], ) -> gp_Trsf: w = _tuple_normalized(z_direction) if w is None: raise ValueError("Missing analytic cone placement direction.") helper = (1.0, 0.0, 0.0) if abs(w[0]) < 0.85 else (0.0, 1.0, 0.0) u = _tuple_normalized(_tuple_cross(helper, w)) if u is None: helper = (0.0, 0.0, 1.0) u = _tuple_normalized(_tuple_cross(helper, w)) if u is None: raise ValueError("Could not build analytic cone placement basis.") v = _tuple_cross(w, u) transform = gp_Trsf() transform.SetValues( u[0], v[0], w[0], origin[0], u[1], v[1], w[1], origin[1], u[2], v[2], w[2], origin[2], ) return transform def resize_conical_reference_radius(self, face_id: int, target_radius: float) -> str: plan = self.conical_reference_radius_plan(face_id, target_radius) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) if bool(plan.get("analytic_rebuild_available", False)): applied, result_check = self._run_checked_face_edit( plan, lambda: self._apply_conical_analytic_rebuild_if_simple(plan), ) if not applied: raise RuntimeError("Analytic cone rebuild was planned but not applied.") return ( "Conical face reference radius resize completed by analytic cone rebuild: " f"face {face_id}, " f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) if bool(plan.get("embedded_cone_recut_available", False)): applied, result_check = self._run_checked_face_edit( plan, lambda: self._apply_embedded_conical_recut_if_available(plan), ) if not applied: raise RuntimeError("Embedded conical recut was planned but not applied.") return ( "Conical face reference radius resize completed by embedded local cone recut: " f"face {face_id}, " f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, " f"mode={plan.get('embedded_cone_recut_mode')}, " f"small_radius={float(plan['embedded_cone_current_small_radius']):g}->{float(plan['embedded_cone_target_small_radius']):g}, " f"large_radius={float(plan['embedded_cone_current_large_radius']):g}->{float(plan['embedded_cone_target_large_radius']):g}, " f"height={float(plan['embedded_cone_height']):g}, " f"target={plan.get('embedded_cone_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) _action_result, result_check = self._run_checked_face_edit( plan, lambda: self._apply_edge_length_affine_transform(plan), ) return ( "Conical face reference radius resize completed by radial affine scaling: " f"face {face_id}, " f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) def resize_conical_semi_angle(self, face_id: int, target_angle_degrees: float) -> str: plan = self.conical_semi_angle_plan(face_id, target_angle_degrees) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) if bool(plan.get("analytic_rebuild_available", False)): applied, result_check = self._run_checked_face_edit( plan, lambda: self._apply_conical_analytic_rebuild_if_simple(plan), ) if not applied: raise RuntimeError("Analytic cone rebuild was planned but not applied.") return ( "Conical face semi-angle resize completed by analytic cone rebuild: " f"face {face_id}, " f"semi_angle={float(plan['semi_angle_degrees']):g}deg->{float(plan['target_semi_angle_degrees']):g}deg, " f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) if bool(plan.get("embedded_cone_recut_available", False)): applied, result_check = self._run_checked_face_edit( plan, lambda: self._apply_embedded_conical_recut_if_available(plan), ) if not applied: raise RuntimeError("Embedded conical recut was planned but not applied.") return ( "Conical face semi-angle resize completed by embedded local cone recut: " f"face {face_id}, " f"semi_angle={float(plan['semi_angle_degrees']):g}deg->{float(plan['target_semi_angle_degrees']):g}deg, " f"mode={plan.get('embedded_cone_recut_mode')}, " f"fixed_small_radius={float(plan['embedded_cone_fixed_small_radius']):g}, " f"large_radius={float(plan['embedded_cone_current_large_radius']):g}->{float(plan['embedded_cone_target_large_radius']):g}, " f"height={float(plan['embedded_cone_height']):g}, " f"target={plan.get('embedded_cone_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) _action_result, result_check = self._run_checked_face_edit( plan, lambda: self._apply_edge_length_affine_transform(plan), ) return ( "Conical face semi-angle resize completed by radial affine scaling: " f"face {face_id}, " f"semi_angle={float(plan['semi_angle_degrees']):g}deg->{float(plan['target_semi_angle_degrees']):g}deg, " f"reference_radius={float(plan['current_reference_radius']):g}->{float(plan['target_reference_radius']):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) def spherical_radius_plan(self, face_id: int, target_radius: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) blockers: list[str] = [] warnings: list[str] = [ "球面半径修改会围绕球心缩放所属零件/Solid;这是 B-Rep 几何缩放,不是 CAD 历史参数。" ] risk = "medium" try: target_radius = float(target_radius) except (TypeError, ValueError): target_radius = 0.0 blockers.append("目标球面半径必须是数字。") current_radius = _float_or_none(info.get("radius")) center = _tuple_or_none(info.get("center")) if info.get("surface") != "sphere": blockers.append("当前选中 Face 不是球面。") if current_radius is None or current_radius <= 1e-9: blockers.append("当前球面缺少有效半径。") if center is None: blockers.append("当前球面缺少稳定球心,不能缩放。") if target_radius <= 1e-9: blockers.append("目标球面半径必须大于 0。") scale = target_radius / max(current_radius or 1.0, 1e-9) delta_radius = target_radius - float(current_radius or 0.0) delta_ratio = abs(delta_radius) / max(float(current_radius or 0.0), 1e-9) if current_radius is not None and abs(delta_radius) <= max(current_radius * 1e-6, 1e-6): blockers.append("目标球面半径与当前值几乎相同,不需要修改。") if delta_ratio > 0.6: risk = _max_risk(risk, "high") warnings.append("球面半径变化超过 60%,可能明显影响周边几何。") elif delta_ratio > 0.25: risk = _max_risk(risk, "high") warnings.append("球面半径变化超过 25%,修改后请重点检查相邻面。") part_id = int(info.get("part_id", -1)) solid_id = int(info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None if part is None: blockers.append("找不到当前球面所属零件。") part_solid_count = 0 else: part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" warnings.append(f"当前会缩放所属 {target_kind},可能影响同一对象上的其它尺寸。") status = "blocked" if blockers else "caution" if blockers: risk = "blocked" return { "status": status, "risk": risk, "message": " ".join(blockers + warnings), "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": part_id, "solid_id": solid_id, "surface": info.get("surface"), "current_radius": current_radius, "target_radius": target_radius, "current_diameter": None if current_radius is None else current_radius * 2.0, "target_diameter": target_radius * 2.0, "delta_radius": delta_radius, "radius_delta_ratio": delta_ratio, "center": center, "resize_strategy": "uniform-scale-sphere-radius", "edit_strategy_label": "围绕球心均匀缩放", "edit_semantics": "按目标球面半径围绕球心均匀缩放所属对象;不是只替换单个球面历史参数。", "affine_scale": scale, "affine_transform_kind": "uniform", "affine_transform_label": "围绕球心均匀缩放", "affine_axis_point": center, "affine_axis_direction": (0.0, 0.0, 1.0), "affine_axis_source": "sphere center", "affine_anchor_source": "sphere center", "affine_target_kind": target_kind, "part_solid_count": part_solid_count, } def resize_spherical_radius(self, face_id: int, target_radius: float) -> str: plan = self.spherical_radius_plan(face_id, target_radius) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) _action_result, result_check = self._run_checked_face_edit( plan, lambda: self._apply_edge_length_affine_transform(plan), ) return ( "Spherical face radius resize completed by uniform scaling: " f"face {face_id}, " f"radius={float(plan['current_radius']):g}->{float(plan['target_radius']):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) def toroidal_radius_plan(self, face_id: int, target_radius: float, mode: str = "minor") -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") mode_key = "major" if str(mode).lower() in {"major", "main", "major_radius"} else "minor" info = self.face_info(face_id) blockers: list[str] = [] warnings: list[str] = [ "环面半径修改会围绕环面中心均匀缩放所属零件/Solid;主半径和小半径会等比例变化。" ] risk = "medium" try: target_radius = float(target_radius) except (TypeError, ValueError): target_radius = 0.0 blockers.append("目标环面半径必须是数字。") current_major = _float_or_none(info.get("major_radius")) current_minor = _float_or_none(info.get("minor_radius")) current_radius = current_major if mode_key == "major" else current_minor center = _tuple_or_none(info.get("center")) axis = _tuple_normalized(_tuple_or_none(info.get("axis"))) or (0.0, 0.0, 1.0) if info.get("surface") != "torus": blockers.append("当前选中 Face 不是环面。") if current_major is None or current_major <= 1e-9 or current_minor is None or current_minor <= 1e-9: blockers.append("当前环面缺少有效主半径或小半径。") if center is None: blockers.append("当前环面缺少稳定中心,不能缩放。") if target_radius <= 1e-9: blockers.append("目标环面半径必须大于 0。") scale = target_radius / max(current_radius or 1.0, 1e-9) target_major = None if current_major is None else current_major * scale target_minor = None if current_minor is None else current_minor * scale delta_radius = target_radius - float(current_radius or 0.0) delta_ratio = abs(delta_radius) / max(float(current_radius or 0.0), 1e-9) if current_radius is not None and abs(delta_radius) <= max(current_radius * 1e-6, 1e-6): blockers.append("目标环面半径与当前值几乎相同,不需要修改。") if delta_ratio > 0.6: risk = _max_risk(risk, "high") warnings.append("环面半径变化超过 60%,可能明显影响周边几何。") elif delta_ratio > 0.25: risk = _max_risk(risk, "high") warnings.append("环面半径变化超过 25%,修改后请重点检查相邻面。") part_id = int(info.get("part_id", -1)) solid_id = int(info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None if part is None: blockers.append("找不到当前环面所属零件。") part_solid_count = 0 else: part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" warnings.append(f"当前会缩放所属 {target_kind},可能影响同一对象上的其它尺寸。") status = "blocked" if blockers else "caution" if blockers: risk = "blocked" return { "status": status, "risk": risk, "message": " ".join(blockers + warnings), "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": part_id, "solid_id": solid_id, "surface": info.get("surface"), "torus_radius_mode": mode_key, "current_major_radius": current_major, "target_major_radius": target_major, "current_minor_radius": current_minor, "target_minor_radius": target_minor, "target_radius": target_radius, "delta_radius": delta_radius, "radius_delta_ratio": delta_ratio, "center": center, "axis": axis, "resize_strategy": "uniform-scale-torus-radius", "edit_strategy_label": "围绕环面中心均匀缩放", "edit_semantics": "按目标环面半径围绕环面中心均匀缩放所属对象,主半径和小半径会等比例变化。", "affine_scale": scale, "affine_transform_kind": "uniform", "affine_transform_label": "围绕环面中心均匀缩放", "affine_axis_point": center, "affine_axis_direction": axis, "affine_axis_source": "torus center", "affine_anchor_source": "torus center", "affine_target_kind": target_kind, "part_solid_count": part_solid_count, } def resize_toroidal_radius(self, face_id: int, target_radius: float, mode: str = "minor") -> str: plan = self.toroidal_radius_plan(face_id, target_radius, mode) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) _action_result, result_check = self._run_checked_face_edit( plan, lambda: self._apply_edge_length_affine_transform(plan), ) mode_label = "major" if plan.get("torus_radius_mode") == "major" else "minor" current = plan.get("current_major_radius") if mode_label == "major" else plan.get("current_minor_radius") target = plan.get("target_major_radius") if mode_label == "major" else plan.get("target_minor_radius") return ( "Toroidal face radius resize completed by uniform scaling: " f"face {face_id}, {mode_label}_radius={float(current):g}->{float(target):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) def face_area_scale_plan(self, face_id: int, target_area: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) blockers: list[str] = [] warnings: list[str] = [ "目标面面积不是 STEP 原始 CAD 历史参数;当前采用围绕当前面的面积中心均匀缩放所属特征或 Solid 的语义。" ] risk = "high" first_level_fields = self._face_first_level_plan_fields(face_id) try: target_area = float(target_area) except (TypeError, ValueError): target_area = 0.0 blockers.append("目标面面积必须是数字。") current_area = _float_or_none(info.get("area")) center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) if current_area is None or current_area <= 1e-9: blockers.append("当前面缺少有效面积。") if center is None: blockers.append("当前面缺少稳定面积中心,不能缩放。") if target_area <= 1e-9: blockers.append("目标面面积必须大于 0。") if current_area is not None and abs(target_area - current_area) <= max(current_area * 1e-6, 1e-6): blockers.append("目标面面积与当前值几乎相同,不需要修改。") scale = ( math.sqrt(target_area / max(float(current_area), 1e-9)) if target_area > 1e-9 and current_area is not None and current_area > 1e-9 else 1.0 ) area_delta = target_area - float(current_area or 0.0) area_delta_ratio = abs(area_delta) / max(float(current_area or 0.0), 1e-9) if scale < 0.05: blockers.append("目标面面积会把所属对象整体缩放到当前尺寸的 5% 以下,容易生成退化几何。") elif scale > 5.0: blockers.append("目标面面积会把所属对象整体放大到当前尺寸的 5 倍以上,风险过高。") if area_delta_ratio <= 0.15: risk = "medium" elif area_delta_ratio > 0.8: warnings.append("目标面积变化超过 80%,很可能明显影响周边几何。") else: warnings.append("目标面积变化较大,修改后请重点检查周边尺寸。") part_id = int(info.get("part_id", -1)) solid_id = int(info.get("solid_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None if part is None: blockers.append("找不到当前面所属零件。") part_solid_count = 0 else: part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) target_kind = "solid" if solid_id >= 0 and part_solid_count > 1 else "part" target_label = "Solid" if target_kind == "solid" else "特征" warnings.append(f"当前会缩放所属{target_label},该对象上的其它尺寸会一起变化。") status = "blocked" if blockers else "caution" if blockers: risk = "blocked" return { "status": status, "risk": risk, "message": " ".join(blockers + warnings), "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": part_id, "solid_id": solid_id, **first_level_fields, "surface": info.get("surface"), "current_area": current_area, "target_area": target_area, "area_delta": area_delta, "area_delta_ratio": area_delta_ratio, "area_center": center, "resize_strategy": "uniform-scale-face-area-fallback", "edit_strategy_label": "按目标面面积缩放所属对象", "edit_semantics": "围绕当前面的面积中心均匀缩放所属特征或 Solid;目标 Face 和同一对象上的其它尺寸会一起变化。", "affine_scale": scale, "affine_transform_kind": "uniform", "affine_transform_label": "按目标面面积缩放所属对象", "affine_transform_note": "围绕当前面面积中心做均匀缩放;目标 Face 和同一所属对象上的其它尺寸会一起变化。", "affine_axis_point": center, "affine_axis_direction": (0.0, 0.0, 1.0), "affine_axis_source": "face area center", "affine_anchor_source": "face area center", "affine_target_kind": target_kind, "part_solid_count": part_solid_count, } def resize_face_area(self, face_id: int, target_area: float) -> str: plan = self.face_area_scale_plan(face_id, target_area) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) _action_result, result_check = self._run_checked_face_edit( plan, lambda: self._apply_edge_length_affine_transform(plan), ) return ( "Face area resize completed by uniform scaling fallback: " f"face {face_id}, " f"area={float(plan['current_area']):g}->{float(plan['target_area']):g}, " f"scale={float(plan['affine_scale']):g}, " f"target={plan.get('affine_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) def cylindrical_resize_preview_polydata( self, face_id: int, new_diameter: float, deflection: float = 0.8, ) -> list[dict[str, object]]: plan = self.cylindrical_resize_plan(face_id, new_diameter) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: raise ValueError("Cylinder resize preview currently supports cylindrical faces only.") direction = surf.Cylinder().Axis().Direction() previews: list[dict[str, object]] = [] if plan["resize_mode"] == "shrink" and "fill_start_point" in plan: fill_start = gp_Pnt(*plan["fill_start_point"]) fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) filler = BRepPrimAPI_MakeCylinder( fill_axis, float(plan["fill_radius"]), float(plan["fill_height"]), ).Shape() BRepMesh_IncrementalMesh(filler, deflection) previews.append( { "role": "fill", "label": "补料预览", "polydata": _shape_faces_polydata(filler), } ) cutter_start = gp_Pnt(*plan["cutter_start_point"]) cutter_axis = gp_Ax2(cutter_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) cutter = BRepPrimAPI_MakeCylinder( cutter_axis, float(plan["cutter_radius"]), float(plan["cutter_height"]), ).Shape() BRepMesh_IncrementalMesh(cutter, deflection) previews.append( { "role": "cutter", "label": "切削预览", "polydata": _shape_faces_polydata(cutter), } ) return previews def cylindrical_boss_resize_preview_polydata( self, face_id: int, new_diameter: float, deflection: float = 0.8, ) -> list[dict[str, object]]: plan = self.cylindrical_boss_resize_plan(face_id, new_diameter) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) start = gp_Pnt(*plan["boss_tool_start_point"]) direction = gp_Dir(*plan["boss_tool_axis_direction"]) axis = gp_Ax2(start, direction) height = float(plan["boss_tool_height"]) if plan["resize_mode"] == "enlarge": tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape() BRepMesh_IncrementalMesh(tool, deflection) return [ { "role": "fill", "label": "凸台扩大补料预览", "polydata": _shape_faces_polydata(tool), } ] removal = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape() replacement = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape() BRepMesh_IncrementalMesh(removal, deflection) BRepMesh_IncrementalMesh(replacement, deflection) return [ { "role": "cutter", "label": "凸台缩小移除范围预览", "polydata": _shape_faces_polydata(removal), }, { "role": "fill", "label": "凸台缩小重建目标预览", "polydata": _shape_faces_polydata(replacement), }, ] def cylindrical_suppress_preview_polydata( self, face_id: int, deflection: float = 0.8, ) -> list[dict[str, object]]: plan = self.cylindrical_suppress_plan(face_id) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: raise ValueError("Cylinder suppress preview currently supports cylindrical faces only.") direction = surf.Cylinder().Axis().Direction() fill_start = gp_Pnt(*plan["fill_start_point"]) fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) filler = BRepPrimAPI_MakeCylinder( fill_axis, float(plan["fill_radius"]), float(plan["fill_height"]), ).Shape() BRepMesh_IncrementalMesh(filler, deflection) return [ { "role": "fill", "label": "封堵补料预览", "polydata": _shape_faces_polydata(filler), } ] def cylindrical_depth_preview_polydata( self, face_id: int, target_depth: float, bottom_face_id: int | None = None, deflection: float = 0.8, ) -> list[dict[str, object]]: plan = self.cylindrical_depth_plan( face_id, target_depth, bottom_face_id=bottom_face_id, ) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) start = gp_Pnt(*plan["depth_tool_start_point"]) direction = gp_Dir(*plan["depth_axis_direction"]) axis = gp_Ax2(start, direction) tool = BRepPrimAPI_MakeCylinder( axis, float(plan["depth_tool_radius"]), float(plan["depth_tool_height"]), ).Shape() BRepMesh_IncrementalMesh(tool, deflection) role = str(plan["depth_tool_role"]) return [ { "role": role, "label": "切削预览" if role == "cutter" else "补料预览", "polydata": _shape_faces_polydata(tool), } ] def existing_fillet_resize_preview_polydata( self, face_id: int, target_radius: float, deflection: float = 0.8, ) -> list[dict[str, object]]: plan = self.existing_fillet_resize_plan(face_id, target_radius) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) return [ { "role": "remove", "label": "将移除并重建的已有圆角面", "polydata": self.build_face_polydata(face_ids=[face_id], deflection=deflection), } ] def push_pull_preview_polydata(self, face_id: int, distance: float, deflection: float = 0.8): plan = self.push_pull_plan(face_id, distance) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) face = self.faces[face_id] scope_face_ids = _int_values(plan.get("push_pull_scope_face_ids")) or [face_id] profile_shape = self._push_pull_profile_shape(scope_face_ids) surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: raise ValueError("Push/pull preview currently supports planar faces only.") outward = plan["outward_direction"] vec = gp_Vec( float(outward[0]) * distance, float(outward[1]) * distance, float(outward[2]) * distance, ) preview_shape = BRepPrimAPI_MakePrism(profile_shape, vec).Shape() BRepMesh_IncrementalMesh(preview_shape, deflection) return _shape_faces_polydata(preview_shape) def straight_edge_length_preview_polydata( self, edge_id: int, target_length: float, deflection: float = 0.8, anchor_mode: str = "auto", strategy_mode: str = "auto", ): plan = self.general_edge_length_plan( edge_id, target_length, anchor_mode=anchor_mode, strategy_mode=strategy_mode, ) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) if plan.get("resize_strategy") == "local-edge-only-deform": preview_shape = self._local_edge_deform_shape(plan) BRepMesh_IncrementalMesh(preview_shape, deflection) return _shape_faces_polydata(preview_shape) if plan.get("resize_strategy") == "move-edge-end-plane-by-push-pull": return self.push_pull_preview_polydata(int(plan["end_face_id"]), float(plan["push_pull_distance"]), deflection) if plan.get("resize_strategy") == "resize-adjacent-cylinder-from-circular-edge-length": face_id = int(plan["cylinder_resize_face_id"]) target_diameter = float(plan["cylinder_resize_target_diameter"]) if plan.get("circular_edge_cylinder_mode") == "boss": return self.cylindrical_boss_resize_preview_polydata(face_id, target_diameter, deflection) return self.cylindrical_resize_preview_polydata(face_id, target_diameter, deflection) preview_shape = self._edge_length_affine_preview_shape(plan) BRepMesh_IncrementalMesh(preview_shape, deflection) return _shape_faces_polydata(preview_shape) def resize_straight_edge_length( self, edge_id: int, target_length: float, anchor_mode: str = "auto", strategy_mode: str = "auto", ) -> str: return self.resize_general_edge_length( edge_id, target_length, anchor_mode=anchor_mode, strategy_mode=strategy_mode, ) def resize_general_edge_length( self, edge_id: int, target_length: float, anchor_mode: str = "auto", strategy_mode: str = "auto", ) -> str: plan = self.general_edge_length_plan( edge_id, target_length, anchor_mode=anchor_mode, strategy_mode=strategy_mode, ) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = int(plan.get("part_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None old_part_shape = part.shape if part is not None else None if plan.get("resize_strategy") == "local-edge-only-deform": try: self._apply_local_edge_deform(plan) result_check = self._edge_length_result_summary_or_raise(plan) return ( "Edge length resize completed by local edge-only deformation: " f"edge {edge_id}, current_length={float(plan['current_length']):g}, " f"target_length={float(plan['target_length']):g}, " f"delta={float(plan['delta_length']):g}, " f"anchor={plan.get('local_edge_deform_anchor')}, " f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, " f"target={plan.get('local_edge_deform_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) except Exception: if part is not None and old_part_shape is not None: part.shape = old_part_shape self.refresh_topology() raise if plan.get("resize_strategy") == "move-edge-end-plane-by-push-pull": try: push_result = self.push_pull_face(int(plan["end_face_id"]), float(plan["push_pull_distance"])) result_check = self._edge_length_result_summary_or_raise(plan) return ( "Edge length resize completed by end-face push/pull: " f"edge {edge_id}, current_length={float(plan['current_length']):g}, " f"target_length={float(plan['target_length']):g}, " f"delta={float(plan['delta_length']):g}, " f"end_face={int(plan['end_face_id'])}, " f"push_pull_distance={float(plan['push_pull_distance']):g}, " f"anchor={plan.get('edge_length_anchor_label')}, " f"risk={plan['risk']}. {result_check} {push_result}" ) except Exception: if part is not None and old_part_shape is not None: part.shape = old_part_shape self.refresh_topology() raise if plan.get("resize_strategy") == "resize-adjacent-cylinder-from-circular-edge-length": try: face_id = int(plan["cylinder_resize_face_id"]) target_diameter = float(plan["cylinder_resize_target_diameter"]) if plan.get("circular_edge_cylinder_mode") == "boss": resize_result = self.resize_cylindrical_boss(face_id, target_diameter) else: resize_result = self.resize_cylindrical_hole(face_id, target_diameter) result_check = self._edge_length_result_summary_or_raise(plan) return ( "Edge length resize completed by adjacent cylinder diameter edit: " f"edge {edge_id}, current_length={float(plan['current_length']):g}, " f"target_length={float(plan['target_length']):g}, " f"delta={float(plan['delta_length']):g}, " f"cylinder_face={face_id}, " f"target_diameter={target_diameter:g}, " f"mode={plan.get('circular_edge_cylinder_mode_label')}, " f"risk={plan['risk']}. {result_check} {resize_result}" ) except Exception: if part is not None and old_part_shape is not None: part.shape = old_part_shape self.refresh_topology() raise try: self._apply_edge_length_affine_transform(plan) result_check = self._edge_length_result_summary_or_raise(plan) return ( "Edge length resize completed by geometric scale fallback: " f"edge {edge_id}, current_length={float(plan['current_length']):g}, " f"target_length={float(plan['target_length']):g}, " f"delta={float(plan['delta_length']):g}, " f"scale={float(plan['affine_scale']):g}, " f"transform={plan.get('affine_transform_label') or plan.get('affine_transform_kind')}, " f"predicted_edge_length={float(plan.get('affine_predicted_edge_length') or 0.0):g}, " f"axis_source={plan.get('affine_axis_source')}, " f"anchor={plan.get('edge_length_anchor_label')}, " f"target={plan.get('affine_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) except Exception: if part is not None and old_part_shape is not None: part.shape = old_part_shape self.refresh_topology() raise def move_edge_endpoint( self, edge_id: int, endpoint_role: str, target_point: tuple[float, float, float], ) -> str: plan = self.edge_endpoint_move_plan(edge_id, endpoint_role, target_point) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) if plan.get("resize_strategy") != "local-edge-endpoint-deform": raise ValueError("Unsupported Edge endpoint move strategy.") part_id = int(plan.get("part_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None old_part_shape = part.shape if part is not None else None try: self._apply_local_edge_deform(plan) result_check = self._edge_length_result_summary_or_raise(plan) return ( "Edge endpoint move completed by local edge deformation: " f"edge {edge_id}, " f"endpoint={plan.get('edge_endpoint_role')}, " f"current_endpoint={plan.get('current_endpoint_point')}, " f"target_endpoint={plan.get('target_endpoint_point')}, " f"move={plan.get('moved_endpoint_delta')}, " f"current_length={float(plan['current_length']):g}, " f"target_length={float(plan['target_length']):g}, " f"delta={float(plan['delta_length']):g}, " f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, " f"target={plan.get('local_edge_deform_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) except Exception: if part is not None and old_part_shape is not None: part.shape = old_part_shape self.refresh_topology() raise def move_edge_center( self, edge_id: int, target_center: tuple[float, float, float], ) -> str: plan = self.edge_center_move_plan(edge_id, target_center) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) if plan.get("resize_strategy") != "local-edge-center-deform": raise ValueError("Unsupported Edge center move strategy.") part_id = int(plan.get("part_id", -1)) part = self.part_by_id(part_id) if part_id >= 0 else None old_part_shape = part.shape if part is not None else None try: self._apply_local_edge_deform(plan) result_check = self._edge_length_result_summary_or_raise(plan) return ( "Edge center move completed by local edge deformation: " f"edge {edge_id}, " f"current_center={plan.get('current_edge_center')}, " f"target_center={plan.get('target_edge_center')}, " f"move={plan.get('moved_edge_center_delta')}, " f"edge_length={float(plan['current_length']):g}, " f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, " f"target={plan.get('local_edge_deform_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) except Exception: if part is not None and old_part_shape is not None: part.shape = old_part_shape self.refresh_topology() raise def face_center_local_move_preview_polydata( self, face_id: int, target_center: tuple[float, float, float], deflection: float = 0.8, ): plan = self.face_center_local_move_plan(face_id, target_center) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) preview_shape = self._local_face_deform_shape(plan) BRepMesh_IncrementalMesh(preview_shape, deflection) return _shape_faces_polydata(preview_shape) def move_face_center_local( self, face_id: int, target_center: tuple[float, float, float], ) -> str: plan = self.face_center_local_move_plan(face_id, target_center) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) _action_result, result_check = self._run_checked_face_edit( plan, lambda: self._apply_local_face_deform(plan), ) return ( "Face center move completed by local face-only deformation: " f"face {face_id}, " f"current_center={plan.get('current_face_center')}, " f"target_center={plan.get('target_face_center')}, " f"move={plan.get('face_center_move_vector')}, " f"moved_points={plan.get('local_face_deform_moved_point_count')}, " f"rebuilt_faces={plan.get('local_face_deform_face_count')}, " f"target={plan.get('local_face_deform_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) def face_area_local_resize_preview_polydata( self, face_id: int, target_area: float, deflection: float = 0.8, ): plan = self.face_area_local_resize_plan(face_id, target_area) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) preview_shape = self._local_face_deform_shape(plan) BRepMesh_IncrementalMesh(preview_shape, deflection) return _shape_faces_polydata(preview_shape) def resize_face_area_local(self, face_id: int, target_area: float) -> str: plan = self.face_area_local_resize_plan(face_id, target_area) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) _action_result, result_check = self._run_checked_face_edit( plan, lambda: self._apply_local_face_deform(plan), ) return ( "Face area resize completed by local face-only deformation: " f"face {face_id}, " f"current_area={float(plan['current_area']):g}, " f"target_area={float(plan['target_area']):g}, " f"delta={float(plan['area_delta']):g}, " f"area_scale={float(plan['local_face_area_scale']):g}, " f"moved_points={plan.get('local_face_deform_moved_point_count')}, " f"rebuilt_faces={plan.get('local_face_deform_face_count')}, " f"target={plan.get('local_face_deform_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) def face_size_local_resize_preview_polydata( self, face_id: int, target_size: float, axis: str = "width", deflection: float = 0.8, ): plan = self.face_size_local_resize_plan(face_id, target_size, axis) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) preview_shape = self._local_face_deform_shape(plan) BRepMesh_IncrementalMesh(preview_shape, deflection) return _shape_faces_polydata(preview_shape) def resize_face_size_local(self, face_id: int, target_size: float, axis: str = "width") -> str: plan = self.face_size_local_resize_plan(face_id, target_size, axis) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) _action_result, result_check = self._run_checked_face_edit( plan, lambda: self._apply_local_face_deform(plan), ) return ( "Face local size resize completed by local face-only deformation: " f"face {face_id}, " f"axis={plan.get('face_size_axis')}, " f"current_size={float(plan['current_face_size']):g}, " f"target_size={float(plan['target_face_size']):g}, " f"delta={float(plan['face_size_delta']):g}, " f"scale={float(plan['face_size_scale']):g}, " f"moved_points={plan.get('local_face_deform_moved_point_count')}, " f"rebuilt_faces={plan.get('local_face_deform_face_count')}, " f"target={plan.get('local_face_deform_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) def face_size_owning_scale_preview_polydata( self, face_id: int, target_size: float, axis: str = "width", deflection: float = 0.8, ): plan = self.face_size_owning_scale_plan(face_id, target_size, axis) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) if plan.get("owning_face_size_rebuild_mode") == "planar-rebuild": preview_shape = self._local_face_deform_shape(plan) else: preview_shape = self._edge_length_affine_preview_shape(plan) BRepMesh_IncrementalMesh(preview_shape, deflection) return _shape_faces_polydata(preview_shape) def resize_face_size_owning_scale(self, face_id: int, target_size: float, axis: str = "width") -> str: plan = self.face_size_owning_scale_plan(face_id, target_size, axis) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) self._remember_face_target_logical_id(plan, face_id) _action_result, result_check = self._run_checked_face_edit( plan, lambda: ( self._apply_local_face_deform(plan) if plan.get("owning_face_size_rebuild_mode") == "planar-rebuild" else self._apply_edge_length_affine_transform(plan) ), ) return ( "Face owning size resize completed by axis-affine scaling: " f"face {face_id}, " f"axis={plan.get('face_size_axis')}, " f"current_size={float(plan['current_face_size']):g}, " f"target_size={float(plan['target_face_size']):g}, " f"delta={float(plan['face_size_delta']):g}, " f"scale={float(plan['face_size_scale']):g}, " f"rebuild_mode={plan.get('owning_face_size_rebuild_mode')}, " f"target={plan.get('affine_target_kind')}, " f"risk={plan['risk']}. {result_check}" ) def _remember_face_target_logical_id(self, plan: dict[str, object], face_id: int) -> None: try: if 0 <= int(face_id) < len(self.faces): plan["target_logical_id"] = self.face_region_logical_id(int(face_id)) except Exception: pass def _face_edit_snapshot(self, plan: dict[str, object]) -> list[tuple[object, TopoDS_Shape]]: part_id = _int_or_none(plan.get("part_id")) snapshot: list[tuple[object, TopoDS_Shape]] = [] if part_id is not None: part = self.part_by_id(part_id) if part is not None: snapshot.append((part, part.shape)) if snapshot: return snapshot for part in self.display_parts(): snapshot.append((part, part.shape)) return snapshot def _restore_face_edit_snapshot(self, snapshot: list[tuple[object, TopoDS_Shape]]) -> None: for part, shape in snapshot: part.shape = shape self.refresh_topology() def _checked_face_edit_result_summary(self, plan: dict[str, object]) -> str: part_id = _int_or_none(plan.get("part_id")) part = self.part_by_id(part_id) if part_id is not None else None if part is not None: _ensure_valid_shape(part.shape) else: _ensure_valid_shape(self.shape) check = self._face_edit_result_check(plan) if check is None: return "Face result check unavailable." metric = check.get("metric") scope = check.get("scope") if int(check.get("face_id", -1)) < 0: raise RuntimeError(f"Face edit result has no matching target Face, metric={metric}, scope={scope}.") error = float(check.get("error", math.inf)) tolerance = float(check.get("tolerance", 0.0)) if not math.isfinite(error) or error > tolerance: target = check.get("target") actual = check.get("actual") target_text = _format_tuple(target) if isinstance(target, tuple) else _format_result_number(target) actual_text = _format_tuple(actual) if isinstance(actual, tuple) else _format_result_number(actual) raise RuntimeError( "Face edit result target check failed: " f"metric={metric}, actual={actual_text}, target={target_text}, " f"error={error:g}, tolerance={tolerance:g}, scope={scope}." ) first_level_summary = "" if str(plan.get("surface") or "") == "plane": matched_face_id = int(check["face_id"]) topology = self.face_first_level_topology(matched_face_id) boundary_edges = int(topology.get("first_level_boundary_edge_count", 0) or 0) boundary_vertices = int(topology.get("first_level_boundary_vertex_count", 0) or 0) adjacent_faces = int(topology.get("first_level_adjacent_face_count", 0) or 0) expected_edges = int(plan.get("first_level_boundary_edge_count", 0) or 0) expected_vertices = int(plan.get("first_level_boundary_vertex_count", 0) or 0) expected_adjacent = int(plan.get("first_level_adjacent_face_count", 0) or 0) expected_inner_wires = int(plan.get("selected_inner_boundary_wires", 0) or 0) matched_info = self.quick_face_info(matched_face_id) boundary_wires = int(matched_info.get("boundary_wires", 0) or 0) inner_boundary_wires = int(matched_info.get("inner_boundary_wires", 0) or 0) expected_scope_area = _float_or_none(plan.get("push_pull_scope_area")) matched_area = _float_or_none(matched_info.get("area")) same_domain_count = int(plan.get("same_domain_face_count", 1) or 1) polygonal_plan = expected_edges >= 3 and expected_vertices >= 3 area_summary = "" if ( expected_scope_area is not None and expected_scope_area > 1e-9 and matched_area is not None and str(check.get("metric") or "") == "plane_position" ): area_tolerance = max(expected_scope_area * 0.02, 1e-4) if abs(matched_area - expected_scope_area) > area_tolerance: raise RuntimeError( "Face edit result lost part of the pushed/pulled Face region area; " f"expected_area={expected_scope_area:g}, actual_area={matched_area:g}, " f"tolerance={area_tolerance:g}." ) area_summary = f"area={matched_area:g}, expected_area={expected_scope_area:g}; " if polygonal_plan: if same_domain_count > 1: minimum_edges = 3 minimum_vertices = 3 minimum_adjacent = min(max(expected_adjacent, 1), 3) if expected_adjacent > 0 else 0 else: minimum_edges = expected_edges minimum_vertices = expected_vertices minimum_adjacent = expected_adjacent else: minimum_edges = 1 if expected_edges > 0 else 0 minimum_vertices = 0 minimum_adjacent = 1 if expected_adjacent > 0 else 0 if boundary_edges <= 0: raise RuntimeError( "Face edit result first-level topology check failed: " f"boundary_edges={boundary_edges}, boundary_vertices={boundary_vertices}." ) if minimum_vertices > 0 and boundary_vertices < minimum_vertices: raise RuntimeError( "Face edit result lost required first-level boundary vertices; " f"required_after>={minimum_vertices}, expected_before={expected_vertices}, " f"actual_after={boundary_vertices}." ) if minimum_edges > 0 and boundary_edges < minimum_edges: raise RuntimeError( "Face edit result lost required first-level boundary edges; " f"required_after>={minimum_edges}, expected_before={expected_edges}, " f"actual_after={boundary_edges}." ) if minimum_adjacent > 0 and adjacent_faces < minimum_adjacent: raise RuntimeError( "Face edit result lost required direct shared-edge adjacent Faces; " f"required_after>={minimum_adjacent}, expected_before={expected_adjacent}, " f"actual_after={adjacent_faces}." ) if expected_inner_wires > 0 and inner_boundary_wires < expected_inner_wires: raise RuntimeError( "Face edit result lost required inner boundary wire(s); " f"required_after>={expected_inner_wires}, actual_after={inner_boundary_wires}, " f"boundary_wires={boundary_wires}." ) try: facts = self.face_first_level_facts(matched_face_id, scope="face") except Exception as exc: raise RuntimeError( "Face edit result first-level fact graph check failed: " f"could not rebuild facts for Face {matched_face_id}: {exc}" ) from exc fact_status = str(facts.get("first_level_fact_status") or "") fact_scope = str(facts.get("first_level_fact_scope") or "") fact_boundary = str(facts.get("first_level_fact_relation_boundary") or "") fact_depth = int(facts.get("first_level_fact_relation_depth", 0) or 0) fact_subject = int(facts.get("first_level_fact_subject_face_count", 0) or 0) fact_boundary_edges = int(facts.get("first_level_fact_boundary_edge_count", 0) or 0) fact_boundary_vertices = int(facts.get("first_level_fact_boundary_vertex_count", 0) or 0) fact_adjacent = int(facts.get("first_level_fact_adjacent_face_count", 0) or 0) fact_included = int(facts.get("first_level_fact_included_face_count", 0) or 0) expected_fact_edges = int(plan.get("first_level_fact_boundary_edge_count", expected_edges) or 0) expected_fact_vertices = int(plan.get("first_level_fact_boundary_vertex_count", expected_vertices) or 0) expected_fact_adjacent = int(plan.get("first_level_fact_adjacent_face_count", expected_adjacent) or 0) fact_ignored_depths = tuple(str(item) for item in facts.get("first_level_fact_ignored_relation_depths", ()) or ()) fact_role_groups = tuple(facts.get("first_level_fact_role_groups") or ()) if fact_status != "ready" or fact_scope != "face" or fact_depth != 1 or fact_boundary != "shared-edge": raise RuntimeError( "Face edit result first-level fact graph is not ready: " f"status={fact_status}, scope={fact_scope}, depth={fact_depth}, boundary={fact_boundary}." ) if fact_subject <= 0 or fact_included < fact_subject: raise RuntimeError( "Face edit result first-level fact graph lost the edited subject region: " f"subject_faces={fact_subject}, included_faces={fact_included}." ) if "second-level" not in fact_ignored_depths or "third-level" not in fact_ignored_depths: raise RuntimeError( "Face edit result first-level fact graph no longer records deferred deeper relations: " f"ignored_depths={fact_ignored_depths}." ) if not any(isinstance(item, dict) and item.get("role") == "selected-same-domain-region" for item in fact_role_groups): raise RuntimeError("Face edit result first-level fact graph lost the selected-region role group.") if expected_fact_adjacent > 0 and not any( isinstance(item, dict) and item.get("role") == "direct-adjacent" for item in fact_role_groups ): raise RuntimeError("Face edit result first-level fact graph lost the direct-adjacent role group.") if polygonal_plan: if same_domain_count > 1: minimum_fact_edges = 3 minimum_fact_vertices = 3 minimum_fact_adjacent = min(max(expected_fact_adjacent, 1), 3) if expected_fact_adjacent > 0 else 0 else: minimum_fact_edges = expected_fact_edges minimum_fact_vertices = expected_fact_vertices minimum_fact_adjacent = expected_fact_adjacent else: minimum_fact_edges = 1 if expected_fact_edges > 0 else 0 minimum_fact_vertices = 0 minimum_fact_adjacent = 1 if expected_fact_adjacent > 0 else 0 if minimum_fact_edges > 0 and fact_boundary_edges < minimum_fact_edges: raise RuntimeError( "Face edit result first-level fact graph lost boundary Edges; " f"required_after>={minimum_fact_edges}, expected_before={expected_fact_edges}, " f"actual_after={fact_boundary_edges}." ) if minimum_fact_vertices > 0 and fact_boundary_vertices < minimum_fact_vertices: raise RuntimeError( "Face edit result first-level fact graph lost boundary Vertices; " f"required_after>={minimum_fact_vertices}, expected_before={expected_fact_vertices}, " f"actual_after={fact_boundary_vertices}." ) if minimum_fact_adjacent > 0 and fact_adjacent < minimum_fact_adjacent: raise RuntimeError( "Face edit result first-level fact graph lost direct adjacent Faces; " f"required_after>={minimum_fact_adjacent}, expected_before={expected_fact_adjacent}, " f"actual_after={fact_adjacent}." ) first_level_summary = ( " First-level check: " f"boundary_edges={boundary_edges}, boundary_vertices={boundary_vertices}, " f"adjacent_faces={adjacent_faces}, inner_wires={inner_boundary_wires}; " f"{area_summary}" f"fact_subject_faces={fact_subject}, fact_included_faces={fact_included}." ) return self._face_edit_result_summary(plan, check) + first_level_summary def _run_checked_face_edit( self, plan: dict[str, object], action: Callable[[], object], ) -> tuple[object, str]: snapshot = self._face_edit_snapshot(plan) previous_logical_ids = tuple(getattr(self, "face_logical_ids", ())) previous_faces = tuple(self.faces) target_logical_id = _int_or_none(plan.get("target_logical_id")) if target_logical_id is None: plan_face_id = _int_or_none(plan.get("face_id")) if plan_face_id is not None and 0 <= plan_face_id < len(self.faces): try: target_logical_id = self.face_region_logical_id(plan_face_id) except Exception: target_logical_id = None excluded_logical_ids = (target_logical_id,) try: action_result = action() self._apply_preserved_face_logical_ids_by_shape_identity( previous_faces, previous_logical_ids, excluded_logical_ids=excluded_logical_ids, ) result_check = self._checked_face_edit_result_summary(plan) return action_result, result_check except Exception as exc: self._restore_face_edit_snapshot(snapshot) self._restore_face_logical_ids_if_count_matches(previous_logical_ids) raise RuntimeError(f"Face edit failed and the model was restored: {exc}") from exc def _face_edit_result_summary( self, plan: dict[str, object], check: dict[str, object] | None = None, ) -> str: if check is None: check = self._face_edit_result_check(plan) if check is None: return "Face result check unavailable." if int(check.get("face_id", -1)) < 0: return f"Face result check: no matching Face found, metric={check.get('metric')}, scope={check.get('scope')}." target = check.get("target") actual = check.get("actual") target_text = _format_tuple(target) if isinstance(target, tuple) else _format_result_number(target) actual_text = _format_tuple(actual) if isinstance(actual, tuple) else _format_result_number(actual) return ( "Face result check: " f"nearest_face={check['face_id']}, " f"metric={check['metric']}, " f"actual={actual_text}, " f"target={target_text}, " f"error={float(check['error']):g}, " f"tolerance={float(check['tolerance']):g}, " f"scope={check['scope']}." ) def _face_edit_result_check(self, plan: dict[str, object]) -> dict[str, object] | None: face_ids, scope = self._face_edit_result_candidate_ids(plan) if not face_ids: return None metric = "" target: float | tuple[float, ...] | None = None tolerance = 1e-4 getter: Callable[[int], float | tuple[float, ...] | None] | None = None target_area = _float_or_none(plan.get("target_area")) target_size = _float_or_none(plan.get("target_face_size")) target_center = _tuple_or_none(plan.get("target_face_center")) target_position = _float_or_none(plan.get("target_plane_position")) target_thickness = _float_or_none(plan.get("shell_target_thickness")) embedded_small_radius = _float_or_none(plan.get("embedded_cone_target_small_radius")) embedded_large_radius = _float_or_none(plan.get("embedded_cone_target_large_radius")) if target_area is not None and target_area > 0: metric = "area" target = target_area tolerance = max(abs(target_area) * 0.02, 1e-4) getter = lambda face_id: _float_or_none(self.quick_face_info(face_id).get("area")) elif target_size is not None and target_size > 0: axis_key = "height" if str(plan.get("face_size_axis") or "").lower() == "height" else "width" info_key = "local_face_height" if axis_key == "height" else "local_face_width" metric = info_key target = target_size tolerance = max(abs(target_size) * 0.02, 1e-4) getter = lambda face_id, key=info_key: _float_or_none(self.quick_face_info(face_id).get(key)) elif target_center is not None: metric = "center" target = target_center reference = max(max(abs(item) for item in target_center), 1.0) tolerance = max(reference * 1e-5, 1e-4) def center_getter(face_id: int) -> tuple[float, float, float] | None: info = self.quick_face_info(face_id) return _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center")) getter = center_getter elif target_position is not None: direction = ( _tuple_normalized(_tuple_or_none(plan.get("plane_direction"))) or _tuple_normalized(_tuple_or_none(plan.get("outward_direction"))) ) if direction is None: return None metric = "plane_position" target = target_position bbox_diagonal = _float_or_none(plan.get("bbox_diagonal")) or _shape_diagonal(self.shape) tolerance = max(bbox_diagonal * 1e-4, abs(target_position) * 1e-5, 1e-4) def plane_position_getter(face_id: int) -> float | None: if face_id < 0 or face_id >= len(self.faces): return None try: surf = BRepAdaptor_Surface(self.faces[face_id]) except Exception: return None if surf.GetType() != GeomAbs_Plane: return None origin = _point_tuple(surf.Plane().Location()) return _tuple_dot(origin, direction) getter = plane_position_getter elif target_thickness is not None and target_thickness > 0: metric = "shell_thickness" target = target_thickness tolerance = max(abs(target_thickness) * 0.03, 1e-4) getter = lambda face_id: _float_or_none(self.feature_info(face_id).get("shell_thickness_estimate")) elif ( embedded_small_radius is not None and embedded_small_radius > 0 and embedded_large_radius is not None and embedded_large_radius > embedded_small_radius ): metric = "cone_boundary_radii" target = (embedded_small_radius, embedded_large_radius) tolerance = max(abs(embedded_large_radius) * 0.01, abs(embedded_small_radius) * 0.01, 1e-4) def cone_boundary_getter(face_id: int) -> tuple[float, float] | None: info = self.face_info(face_id) axis_point = _tuple_or_none(info.get("axis_point")) axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis"))) if axis_point is None or axis_direction is None: return None circles = self._conical_face_circle_boundaries(face_id, axis_point, axis_direction) radii = sorted(float(circle["radius"]) for circle in circles) if len(radii) != 2: return None return (radii[0], radii[1]) getter = cone_boundary_getter else: resize_strategy = str(plan.get("resize_strategy") or "") surface = str(plan.get("surface") or "") if "semi-angle" in resize_strategy: numeric_specs = ( ("semi_angle_degrees", "target_semi_angle_degrees", 0.01, 0.05), ("reference_radius", "target_reference_radius", 0.01, 1e-4), ("radius", "target_radius", 0.01, 1e-4), ) elif surface == "torus": if str(plan.get("torus_radius_mode") or "") == "major": numeric_specs = ( ("major_radius", "target_major_radius", 0.01, 1e-4), ("minor_radius", "target_minor_radius", 0.01, 1e-4), ) else: numeric_specs = ( ("minor_radius", "target_minor_radius", 0.01, 1e-4), ("major_radius", "target_major_radius", 0.01, 1e-4), ) else: numeric_specs = ( ("reference_radius", "target_reference_radius", 0.01, 1e-4), ("major_radius", "target_major_radius", 0.01, 1e-4), ("minor_radius", "target_minor_radius", 0.01, 1e-4), ("radius", "target_radius", 0.01, 1e-4), ("semi_angle_degrees", "target_semi_angle_degrees", 0.01, 0.05), ) for info_key, target_key, ratio, floor in numeric_specs: value = _float_or_none(plan.get(target_key)) if value is None or value <= 0: continue metric = info_key target = value tolerance = max(abs(value) * ratio, floor) if info_key == "semi_angle_degrees": getter = lambda face_id: _angle_degrees_or_none(self.quick_face_info(face_id).get("semi_angle")) else: getter = lambda face_id, key=info_key: _float_or_none(self.quick_face_info(face_id).get(key)) break if getter is None or target is None: return None preferred_ids = _int_values(plan.get("result_candidate_face_ids")) logical_id = _int_or_none(plan.get("target_logical_id")) if logical_id is not None: try: preferred_ids.extend(self.face_ids_for_logical_id(logical_id)) except Exception: pass plan_face_id = _int_or_none(plan.get("face_id")) if plan_face_id is not None: preferred_ids.append(plan_face_id) if preferred_ids: preferred_best: dict[str, object] | None = None for face_id in preferred_ids: if face_id not in face_ids: continue try: actual = getter(face_id) except Exception: actual = None if actual is None: continue error = _result_value_error(actual, target) if preferred_best is None or error < float(preferred_best["error"]): preferred_best = { "face_id": face_id, "metric": metric, "actual": actual, "target": target, "error": error, "tolerance": tolerance, "scope": scope, } if preferred_best is not None and float(preferred_best["error"]) <= tolerance: return preferred_best best: dict[str, object] | None = None for face_id in face_ids: try: actual = getter(face_id) except Exception: actual = None if actual is None: continue error = _result_value_error(actual, target) if best is None or error < float(best["error"]): best = { "face_id": face_id, "metric": metric, "actual": actual, "target": target, "error": error, "tolerance": tolerance, "scope": scope, } if best is not None: return best return { "face_id": -1, "metric": metric, "actual": "", "target": target, "error": math.inf, "tolerance": tolerance, "scope": scope, } def _face_edit_result_candidate_ids(self, plan: dict[str, object]) -> tuple[list[int], str]: part_id = _int_or_none(plan.get("part_id")) solid_id = _int_or_none(plan.get("solid_id")) surface = str(plan.get("surface") or "") target_kind = str( plan.get("local_face_deform_target_kind") or plan.get("affine_target_kind") or plan.get("target_kind") or ("solid" if solid_id is not None and solid_id >= 0 else "part") ) scope = f"part {part_id}" if part_id is not None else "model" if target_kind == "solid" and solid_id is not None and solid_id >= 0: scope = f"solid {solid_id}" primary: list[int] = [] logical_id = _int_or_none(plan.get("target_logical_id")) if logical_id is not None: try: primary.extend(self.face_ids_for_logical_id(logical_id)) except Exception: pass plan_face_id = _int_or_none(plan.get("face_id")) if plan_face_id is not None: primary.append(plan_face_id) primary.extend(_int_values(plan.get("result_candidate_face_ids"))) def filtered(source_ids: Iterable[int], *, require_solid: bool = True) -> list[int]: result: list[int] = [] for candidate_id in source_ids: try: face_id = int(candidate_id) except (TypeError, ValueError): continue if not (0 <= face_id < len(self.faces)): continue if part_id is not None and int(self.face_part_ids[face_id]) != part_id: continue if ( require_solid and solid_id is not None and solid_id >= 0 and int(self.face_solid_ids[face_id]) != solid_id ): continue if surface in {"plane", "cylinder", "cone", "sphere", "torus"}: try: if self.face_surface_kind(face_id) != surface: continue except Exception: continue if face_id not in result: result.append(face_id) return result primary_ids = filtered(primary) result_candidate_ids = filtered(_int_values(plan.get("result_candidate_face_ids"))) if result_candidate_ids: combined: list[int] = [] for face_id in [*result_candidate_ids, *primary_ids]: if face_id not in combined: combined.append(face_id) return combined, scope all_ids = filtered(range(len(self.faces))) if not all_ids and solid_id is not None and solid_id >= 0: all_ids = filtered(range(len(self.faces)), require_solid=False) combined: list[int] = [] for face_id in [*primary_ids, *all_ids]: if face_id not in combined: combined.append(face_id) return combined, scope def _edge_length_result_summary(self, plan: dict[str, object]) -> str: check = self._edge_length_result_check(plan) if check is None: return "Result check unavailable." return ( "Result check: " f"match={check['match_method']}, " f"nearest_edge={check['edge_id']}, " f"nearest_length={float(check['nearest_length']):g}, " f"target_error={float(check['target_error']):g}, " f"relative_error={float(check['relative_error']):g}, " f"endpoint_error={float(check['endpoint_error']):g}, " f"scope={check['scope']}." ) def _edge_length_result_summary_or_raise(self, plan: dict[str, object]) -> str: check = self._edge_length_result_check(plan) if check is None: raise RuntimeError("Edge长度编辑结果无法校验,模型已恢复到修改前状态。") target_length = float(plan.get("target_length", 0.0) or 0.0) length_tolerance = max(_shape_diagonal(self.shape) * 1e-5, target_length * 1e-4, 1e-5) if float(check.get("target_error", 0.0) or 0.0) > length_tolerance: raise RuntimeError( "Edge长度编辑结果没有达到目标值,模型已恢复到修改前状态。" f"最近Edge {check.get('edge_id')} 的长度约 {float(check.get('nearest_length', 0.0) or 0.0):g}," f"目标长度 {target_length:g},误差 {float(check.get('target_error', 0.0) or 0.0):g}。" ) expected = self._edge_length_expected_endpoints(plan) endpoint_error = float(check.get("endpoint_error", -1.0) or -1.0) if expected is not None and endpoint_error >= 0.0: endpoint_tolerance = max(_shape_diagonal(self.shape) * 1e-4, target_length * 1e-3, 1e-4) if endpoint_error > endpoint_tolerance: raise RuntimeError( "Edge长度编辑后的目标端点位置不符合所选建模意图,模型已恢复到修改前状态。" f"最近Edge {check.get('edge_id')} 的端点误差约 {endpoint_error:g}," f"允许误差 {endpoint_tolerance:g}。" ) return ( "Result check: " f"match={check['match_method']}, " f"nearest_edge={check['edge_id']}, " f"nearest_length={float(check['nearest_length']):g}, " f"target_error={float(check['target_error']):g}, " f"relative_error={float(check['relative_error']):g}, " f"endpoint_error={float(check['endpoint_error']):g}, " f"scope={check['scope']}." ) def _edge_length_result_check(self, plan: dict[str, object]) -> dict[str, object] | None: try: target_length = float(plan.get("target_length", 0.0)) except (TypeError, ValueError): return None if target_length <= 1e-9 or not self.edges: return None try: part_id = int(plan.get("part_id", -1)) except (TypeError, ValueError): part_id = -1 try: solid_id = int(plan.get("solid_id", -1)) except (TypeError, ValueError): solid_id = -1 target_kind = str( plan.get("local_edge_deform_target_kind") or plan.get("affine_target_kind") or ("solid" if solid_id >= 0 else "part") ) edge_ids = [edge_id for edge_id in range(len(self.edges)) if part_id < 0 or self.edge_part_ids[edge_id] == part_id] scope = f"零件 {part_id}" if part_id >= 0 else "model" if target_kind == "solid" and solid_id >= 0: solid_edge_ids = [edge_id for edge_id in edge_ids if self.edge_solid_ids[edge_id] == solid_id] if solid_edge_ids: edge_ids = solid_edge_ids scope = f"solid {solid_id}" if not edge_ids: edge_ids = list(range(len(self.edges))) scope = "model" expected = self._edge_length_expected_endpoints(plan) best_length: tuple[float, int, float, float | None] | None = None best_endpoint: tuple[float, float, int, float] | None = None for edge_id in edge_ids: try: length = float(self.edge_info(edge_id).get("length", 0.0)) except Exception: continue if length <= 1e-9: continue error = abs(length - target_length) endpoint_error = None if expected is not None: endpoint_error = self._edge_endpoint_pair_error(edge_id, expected[0], expected[1]) if endpoint_error is not None and ( best_endpoint is None or endpoint_error < best_endpoint[0] or ( abs(endpoint_error - best_endpoint[0]) <= 1e-9 and (error < best_endpoint[1] or (abs(error - best_endpoint[1]) <= 1e-9 and edge_id < best_endpoint[2])) ) ): best_endpoint = (endpoint_error, error, edge_id, length) if best_length is None or error < best_length[0] or ( abs(error - best_length[0]) <= 1e-9 and edge_id < best_length[1] ): best_length = (error, edge_id, length, endpoint_error) if best_length is None: return None match_method = "length" endpoint_error_value = best_length[3] error, edge_id, length = best_length[0], best_length[1], best_length[2] if expected is not None and best_endpoint is not None: endpoint_tolerance = max(_shape_diagonal(self.shape) * 1e-4, target_length * 1e-3, 1e-4) if best_endpoint[0] <= endpoint_tolerance: endpoint_error_value, error, edge_id, length = best_endpoint match_method = str(expected[2]) return { "edge_id": edge_id, "nearest_length": length, "target_error": error, "relative_error": error / max(target_length, 1e-9), "endpoint_error": endpoint_error_value if endpoint_error_value is not None else -1.0, "match_method": match_method, "scope": scope, } def _edge_length_expected_endpoints( self, plan: dict[str, object], ) -> tuple[tuple[float, float, float], tuple[float, float, float], str] | None: start = _tuple_or_none(plan.get("start_point")) end = _tuple_or_none(plan.get("end_point")) if start is None or end is None: return None strategy = str(plan.get("resize_strategy", "")) if strategy in {"local-edge-only-deform", "local-edge-endpoint-deform", "local-edge-center-deform"}: start_move = _tuple_or_none(plan.get("local_edge_deform_start_move")) or (0.0, 0.0, 0.0) end_move = _tuple_or_none(plan.get("local_edge_deform_end_move")) or (0.0, 0.0, 0.0) if strategy == "local-edge-endpoint-deform": match_label = "endpoint-local-coordinate" elif strategy == "local-edge-center-deform": match_label = "endpoint-local-center" else: match_label = "endpoint-local" return ( (start[0] + start_move[0], start[1] + start_move[1], start[2] + start_move[2]), (end[0] + end_move[0], end[1] + end_move[1], end[2] + end_move[2]), match_label, ) if strategy == "move-edge-end-plane-by-push-pull": movement = _tuple_or_none(plan.get("desired_movement_vector")) endpoint_role = str(plan.get("end_face_endpoint_role", "")) if movement is None or endpoint_role not in {"start", "end"}: return None if endpoint_role == "start": start = (start[0] + movement[0], start[1] + movement[1], start[2] + movement[2]) else: end = (end[0] + movement[0], end[1] + movement[1], end[2] + movement[2]) return start, end, "endpoint-push-pull" if strategy == "scale-owning-shape-from-edge": transformed_start = self._edge_length_affine_point(start, plan) transformed_end = self._edge_length_affine_point(end, plan) if transformed_start is None or transformed_end is None: return None return transformed_start, transformed_end, "endpoint-affine" return None def _edge_endpoint_pair_error( self, edge_id: int, expected_start: tuple[float, float, float], expected_end: tuple[float, float, float], ) -> float | None: info = self.edge_info(edge_id) start = _tuple_or_none(info.get("start_point")) end = _tuple_or_none(info.get("end_point")) if start is None or end is None: return None direct = max(_vector_length(_tuple_sub(start, expected_start)), _vector_length(_tuple_sub(end, expected_end))) reversed_order = max( _vector_length(_tuple_sub(start, expected_end)), _vector_length(_tuple_sub(end, expected_start)), ) return min(direct, reversed_order) def _edge_length_affine_point( self, point: tuple[float, float, float], plan: dict[str, object], ) -> tuple[float, float, float] | None: axis_point = _tuple_or_none(plan.get("affine_axis_point")) if axis_point is None: return None try: scale = float(plan.get("affine_scale", 1.0)) except (TypeError, ValueError): return None relative = _tuple_sub(point, axis_point) transform_kind = str(plan.get("affine_transform_kind", "axis-affine")) if transform_kind == "uniform": moved = _tuple_scale(relative, scale) else: axis_direction = _tuple_normalized(_tuple_or_none(plan.get("affine_axis_direction"))) if axis_direction is None: return None axial = _tuple_scale(axis_direction, _tuple_dot(relative, axis_direction)) radial = _tuple_sub(relative, axial) if transform_kind == "radial-affine": moved = ( axial[0] + radial[0] * scale, axial[1] + radial[1] * scale, axial[2] + radial[2] * scale, ) else: moved = ( radial[0] + axial[0] * scale, radial[1] + axial[1] * scale, radial[2] + axial[2] * scale, ) return (axis_point[0] + moved[0], axis_point[1] + moved[1], axis_point[2] + moved[2]) def _local_edge_deform_shape(self, plan: dict[str, object]) -> TopoDS_Shape: _target_kind, solid, _part, _source_solid = self._local_edge_deform_target(plan) tolerance = max(_shape_diagonal(solid) * 1e-7, abs(float(plan.get("delta_length", 0.0))) * 1e-7, 1e-6) faces = _explore(solid, TopAbs_FACE) moved_faces: list[TopoDS_Shape] = [] moved_points: dict[tuple[int, int, int], tuple[float, float, float]] = {} for face in faces: points = self._local_deform_face_vertex_points(face, tolerance) if len(points) < 3: raise RuntimeError("Local edge deformation could not read a stable face vertex loop.") for point in points: moved = self._local_edge_deform_moved_point(point, plan, tolerance) moved_points[self._local_point_key(moved, tolerance)] = moved if not moved_points: raise RuntimeError("Local edge deformation produced no moved vertices.") center = ( sum(point[0] for point in moved_points.values()) / len(moved_points), sum(point[1] for point in moved_points.values()) / len(moved_points), sum(point[2] for point in moved_points.values()) / len(moved_points), ) for face in faces: points = [ self._local_edge_deform_moved_point(point, plan, tolerance) for point in self._local_deform_face_vertex_points(face, tolerance) ] points = self._dedupe_local_points(points, tolerance) if len(points) < 3: raise RuntimeError("Local edge deformation collapsed a face.") points = self._orient_local_polygon_outward(points, center) if self._local_points_are_planar(points, tolerance): moved_faces.append(self._make_local_polygon_face(points)) elif len(points) == 4: moved_faces.append(self._make_local_bilinear_quad_face(points, tolerance)) else: for index in range(1, len(points) - 1): triangle = [points[0], points[index], points[index + 1]] triangle = self._orient_local_polygon_outward(triangle, center) moved_faces.append(self._make_local_polygon_face(triangle)) sewing = BRepBuilderAPI_Sewing(tolerance) for face in moved_faces: sewing.Add(face) sewing.Perform() sewed = sewing.SewedShape() if sewed.IsNull(): raise RuntimeError("Local edge deformation sewing produced an empty shape.") if sewed.ShapeType() == TopAbs_SHELL: shell = topods.Shell(sewed) else: shells = _explore(sewed, TopAbs_SHELL) if not shells: raise RuntimeError("Local edge deformation did not produce a sewable shell.") shell = topods.Shell(shells[0]) solid_builder = BRepBuilderAPI_MakeSolid(shell) solid = solid_builder.Solid() if solid.IsNull(): raise RuntimeError("Local edge deformation could not create a solid from the rebuilt shell.") return _ensure_valid_or_repaired_shape(solid, "local edge deformation") def _apply_local_edge_deform(self, plan: dict[str, object]) -> None: target_kind, _source_shape, part, source_solid = self._local_edge_deform_target(plan) transformed = self._local_edge_deform_shape(plan) if target_kind == "part": part.shape = transformed else: part_solids = _explore(part.shape, TopAbs_SOLID) replaced = False shapes: list[TopoDS_Shape] = [] for item in part_solids: if not replaced and source_solid is not None and _same_shape(item, source_solid): shapes.append(transformed) replaced = True else: shapes.append(item) if not replaced: raise RuntimeError(f"Could not locate solid {plan.get('solid_id')} inside part {plan.get('part_id')}.") part.shape = _compound_from_shapes(shapes) _ensure_valid_shape(part.shape) self.refresh_topology() def _local_face_deform_shape(self, plan: dict[str, object]) -> TopoDS_Shape: _target_kind, solid, _part, _source_solid = self._local_face_deform_target(plan) move_distance = _float_or_none(plan.get("local_face_deform_distance_hint")) if move_distance is None: move_distance = _float_or_none(plan.get("face_center_move_distance")) if move_distance is None: move_distance = 0.0 tolerance = max(_shape_diagonal(solid) * 1e-7, abs(move_distance) * 1e-7, 1e-6) faces = _explore(solid, TopAbs_FACE) moved_faces: list[TopoDS_Shape] = [] moved_points: dict[tuple[int, int, int], tuple[float, float, float]] = {} moved_count = 0 for face in faces: points = self._local_deform_face_vertex_points(face, tolerance) if len(points) < 3: raise RuntimeError("Local face deformation could not read a stable face vertex loop.") for point in points: moved = self._local_face_deform_moved_point(point, plan, tolerance) if _vector_length(_tuple_sub(moved, point)) > tolerance: moved_count += 1 moved_points[self._local_point_key(moved, tolerance)] = moved if not moved_points or moved_count == 0: raise RuntimeError("Local face deformation produced no moved vertices.") center = ( sum(point[0] for point in moved_points.values()) / len(moved_points), sum(point[1] for point in moved_points.values()) / len(moved_points), sum(point[2] for point in moved_points.values()) / len(moved_points), ) for face in faces: points = [ self._local_face_deform_moved_point(point, plan, tolerance) for point in self._local_deform_face_vertex_points(face, tolerance) ] points = self._dedupe_local_points(points, tolerance) if len(points) < 3: raise RuntimeError("Local face deformation collapsed a face.") points = self._orient_local_polygon_outward(points, center) if self._local_points_are_planar(points, tolerance): moved_faces.append(self._make_local_polygon_face(points)) else: for index in range(1, len(points) - 1): triangle = [points[0], points[index], points[index + 1]] triangle = self._orient_local_polygon_outward(triangle, center) moved_faces.append(self._make_local_polygon_face(triangle)) sewing = BRepBuilderAPI_Sewing(tolerance) for face in moved_faces: sewing.Add(face) sewing.Perform() sewed = sewing.SewedShape() if sewed.IsNull(): raise RuntimeError("Local face deformation sewing produced an empty shape.") if sewed.ShapeType() == TopAbs_SHELL: shell = topods.Shell(sewed) else: shells = _explore(sewed, TopAbs_SHELL) if not shells: raise RuntimeError("Local face deformation did not produce a sewable shell.") shell = topods.Shell(shells[0]) solid_builder = BRepBuilderAPI_MakeSolid(shell) solid_shape = solid_builder.Solid() if solid_shape.IsNull(): raise RuntimeError("Local face deformation could not create a solid from the rebuilt shell.") return _ensure_valid_or_repaired_shape(solid_shape, "local face deformation") def _apply_local_face_deform(self, plan: dict[str, object]) -> None: target_kind, source_shape, part, source_solid = self._local_face_deform_target(plan) target_face_id = int(plan.get("face_id", -1)) target_part_id = int(plan.get("part_id", -1)) target_logical_id: int | None = None target_points: tuple[tuple[float, float, float], ...] = () move_distance = _float_or_none(plan.get("local_face_deform_distance_hint")) if move_distance is None: move_distance = _float_or_none(plan.get("face_center_move_distance")) if move_distance is None: move_distance = 0.0 mapping_tolerance = max(_shape_diagonal(source_shape) * 1e-7, abs(move_distance) * 1e-7, 1e-6) if 0 <= target_face_id < len(self.faces): try: target_logical_id = self.face_region_logical_id(target_face_id) target_points = self._local_face_deform_target_points_for_face( target_face_id, plan, mapping_tolerance, ) except Exception: target_logical_id = None target_points = () transformed = self._local_face_deform_shape(plan) if target_kind == "part": part.shape = transformed else: part_solids = _explore(part.shape, TopAbs_SOLID) replaced = False shapes: list[TopoDS_Shape] = [] for item in part_solids: if not replaced and source_solid is not None and _same_shape(item, source_solid): shapes.append(transformed) replaced = True else: shapes.append(item) if not replaced: raise RuntimeError(f"Could not locate solid {plan.get('solid_id')} inside part {plan.get('part_id')}.") part.shape = _compound_from_shapes(shapes) _ensure_valid_shape(part.shape) self.refresh_topology() if target_logical_id is not None and target_points: self._assign_logical_id_to_matching_face_points( target_logical_id, target_points, target_part_id, mapping_tolerance, ) def _local_face_deform_target_points_for_face( self, face_id: int, plan: dict[str, object], tolerance: float, ) -> tuple[tuple[float, float, float], ...]: points = self._local_deform_face_vertex_points(self.faces[face_id], tolerance) return tuple(self._local_face_deform_moved_point(point, plan, tolerance) for point in points) def _assign_logical_id_to_matching_face_points( self, logical_id: int, target_points: Iterable[tuple[float, float, float]], part_id: int, tolerance: float, ) -> None: target = self._dedupe_local_points([tuple(point) for point in target_points], tolerance) if len(target) < 3: return max_error = max(tolerance * 500.0, _shape_diagonal(self.shape) * 1e-6, 1e-4) best_face_id: int | None = None best_error = math.inf for face_id, face in enumerate(self.faces): if part_id >= 0 and self.face_part_ids[face_id] != part_id: continue try: points = self._dedupe_local_points(self._local_deform_face_vertex_points(face, tolerance), tolerance) except Exception: continue if len(points) < 3: continue error = self._local_point_cloud_error(target, points) error += abs(len(points) - len(target)) * max_error * 0.25 if error < best_error: best_error = error best_face_id = face_id if best_face_id is None or best_error > max_error: return region_ids = self.connected_same_domain_face_ids(best_face_id) or [best_face_id] self.assign_logical_face_region_exclusive(int(logical_id), region_ids) def _local_point_cloud_error( self, left: list[tuple[float, float, float]], right: list[tuple[float, float, float]], ) -> float: if not left or not right: return math.inf def one_way(source: list[tuple[float, float, float]], target: list[tuple[float, float, float]]) -> float: return max(min(_vector_length(_tuple_sub(item, other)) for other in target) for item in source) return max(one_way(left, right), one_way(right, left)) def _translated_face_region_mapping_specs( self, specs: Iterable[dict[str, object]], vector: tuple[float, float, float], *, logical_id: int | None = None, ) -> list[dict[str, object]]: shifted_specs: list[dict[str, object]] = [] for spec in specs: shifted = dict(spec) if str(shifted.get("surface", "")) == "plane": point = _tuple_or_none(shifted.get("point")) if point is not None: shifted["point"] = _tuple_add(point, vector) if logical_id is not None: shifted["logical_id"] = int(logical_id) shifted_specs.append(shifted) return shifted_specs def _apply_face_region_mapping_specs_exclusive( self, logical_id: int | None, specs: Iterable[dict[str, object]], ) -> None: if logical_id is None: return region_ids: set[int] = set() for spec in specs: try: seed_face_ids = self._matching_face_ids_for_region_spec(spec) except Exception: seed_face_ids = [] for seed_face_id in seed_face_ids: region_ids.update(self.connected_same_domain_face_ids(seed_face_id) or [seed_face_id]) if region_ids: self.assign_logical_face_region_exclusive(int(logical_id), region_ids) def _apply_preserved_face_logical_ids_by_shape_identity( self, previous_faces: Iterable[TopoDS_Shape], previous_logical_ids: Iterable[int], *, excluded_logical_ids: Iterable[int | None] = (), ) -> int: excluded = {int(item) for item in excluded_logical_ids if item is not None} old_faces = list(previous_faces) old_logical_ids = [int(item) for item in previous_logical_ids] if not old_faces or len(old_faces) != len(old_logical_ids): return 0 unmatched_new_ids: set[int] = set(range(len(self.faces))) pending: list[tuple[TopoDS_Shape, int]] = [] matched_by_logical_id: dict[int, set[int]] = {} for old_face_id, old_face in enumerate(old_faces): logical_id = old_logical_ids[old_face_id] if logical_id in excluded: continue if ( old_face_id < len(self.faces) and old_face_id in unmatched_new_ids and _same_shape(old_face, self.faces[old_face_id]) ): matched_by_logical_id.setdefault(logical_id, set()).add(old_face_id) unmatched_new_ids.remove(old_face_id) else: pending.append((old_face, logical_id)) for old_face, logical_id in pending: if logical_id in excluded: continue match_id: int | None = None for new_face_id in tuple(unmatched_new_ids): if _same_shape(old_face, self.faces[new_face_id]): match_id = new_face_id break if match_id is None: continue matched_by_logical_id.setdefault(logical_id, set()).add(match_id) unmatched_new_ids.remove(match_id) reassigned = 0 assigned_face_ids: set[int] = set() logical_targets: dict[int, set[int]] = {} for logical_id, face_ids in sorted(matched_by_logical_id.items()): valid_face_ids = set(face_ids) - assigned_face_ids if not valid_face_ids: continue logical_targets[int(logical_id)] = valid_face_ids assigned_face_ids.update(valid_face_ids) reassigned += len(valid_face_ids) if not logical_targets: return 0 new_logical_ids = list(self.face_logical_ids) replacement_id = max( [len(self.faces), *[int(item) for item in new_logical_ids], *logical_targets.keys()], default=len(self.faces), ) + 1 for face_id, logical_id in enumerate(list(new_logical_ids)): target_face_ids = logical_targets.get(int(logical_id)) if target_face_ids is None or face_id in target_face_ids: continue new_logical_ids[face_id] = replacement_id replacement_id += 1 for logical_id, face_ids in logical_targets.items(): for face_id in face_ids: if 0 <= face_id < len(new_logical_ids): new_logical_ids[face_id] = logical_id self.face_logical_ids = new_logical_ids self._quick_face_info_cache.clear() self._face_info_cache.clear() self._feature_info_cache.clear() self._same_domain_face_ids_cache.clear() self._face_first_level_topology_cache.clear() self._cylindrical_first_level_topology_cache.clear() self._face_first_level_fact_cache.clear() self._local_face_deform_readiness_cache.clear() return reassigned def _local_face_deform_target( self, plan: dict[str, object], ) -> tuple[str, TopoDS_Shape, object, TopoDS_Shape | None]: part_id = int(plan.get("part_id", -1)) solid_id = int(plan.get("solid_id", -1)) part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") if solid_id < 0 or solid_id >= len(self.solids): raise ValueError(f"Unknown solid id {solid_id}") target_kind = str(plan.get("local_face_deform_target_kind", "part")) solid = self.solids[solid_id][1] return ("solid" if target_kind == "solid" else "part"), solid, part, solid def _local_edge_deform_target( self, plan: dict[str, object], ) -> tuple[str, TopoDS_Shape, object, TopoDS_Shape | None]: part_id = int(plan.get("part_id", -1)) solid_id = int(plan.get("solid_id", -1)) part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") if solid_id < 0 or solid_id >= len(self.solids): raise ValueError(f"Unknown solid id {solid_id}") target_kind = str(plan.get("local_edge_deform_target_kind", "part")) solid = self.solids[solid_id][1] return ("solid" if target_kind == "solid" else "part"), solid, part, solid def _local_deform_face_vertex_points( self, face: TopoDS_Shape, tolerance: float, ) -> list[tuple[float, float, float]]: points: list[tuple[float, float, float]] = [] explorer = TopExp_Explorer(face, TopAbs_VERTEX) while explorer.More(): vertex = topods.Vertex(explorer.Current()) point = _point_tuple(BRep_Tool.Pnt(vertex)) if not any(_vector_length(_tuple_sub(point, existing)) <= tolerance for existing in points): points.append(point) explorer.Next() if len(points) < 3: return points surf = BRepAdaptor_Surface(face) normal = _tuple_normalized(_dir_tuple(surf.Plane().Axis().Direction())) if normal is None: return points if face.Orientation() == TopAbs_REVERSED: normal = _tuple_scale(normal, -1.0) return self._order_local_polygon_points(points, normal) def _order_local_polygon_points( self, points: list[tuple[float, float, float]], normal: tuple[float, float, float], ) -> list[tuple[float, float, float]]: center = ( sum(point[0] for point in points) / len(points), sum(point[1] for point in points) / len(points), sum(point[2] for point in points) / len(points), ) reference = (1.0, 0.0, 0.0) if abs(normal[0]) < 0.9 else (0.0, 1.0, 0.0) u_axis = _tuple_normalized(_tuple_cross(normal, reference)) if u_axis is None: return points v_axis = _tuple_normalized(_tuple_cross(normal, u_axis)) if v_axis is None: return points ordered = sorted( points, key=lambda point: math.atan2( _tuple_dot(_tuple_sub(point, center), v_axis), _tuple_dot(_tuple_sub(point, center), u_axis), ), ) polygon_normal = self._local_polygon_normal(ordered) if polygon_normal is not None and _tuple_dot(polygon_normal, normal) < 0: ordered.reverse() return ordered def _local_edge_deform_moved_point( self, point: tuple[float, float, float], plan: dict[str, object], tolerance: float, ) -> tuple[float, float, float]: start = _tuple_or_none(plan.get("start_point")) end = _tuple_or_none(plan.get("end_point")) start_move = _tuple_or_none(plan.get("local_edge_deform_start_move")) or (0.0, 0.0, 0.0) end_move = _tuple_or_none(plan.get("local_edge_deform_end_move")) or (0.0, 0.0, 0.0) if start is not None and _vector_length(_tuple_sub(point, start)) <= tolerance: return (point[0] + start_move[0], point[1] + start_move[1], point[2] + start_move[2]) if end is not None and _vector_length(_tuple_sub(point, end)) <= tolerance: return (point[0] + end_move[0], point[1] + end_move[1], point[2] + end_move[2]) return point def _local_face_deform_moved_point( self, point: tuple[float, float, float], plan: dict[str, object], tolerance: float, ) -> tuple[float, float, float]: point_targets = plan.get("local_face_deform_source_point_targets") if isinstance(point_targets, (list, tuple)): for item in point_targets: if not isinstance(item, (list, tuple)) or len(item) < 2: continue source = _tuple_or_none(item[0]) target = _tuple_or_none(item[1]) if source is not None and target is not None and _vector_length(_tuple_sub(point, source)) <= tolerance: return target source_points = tuple(_tuple_or_none(item) for item in plan.get("local_face_deform_source_points", ())) move = _tuple_or_none(plan.get("face_center_move_vector")) or (0.0, 0.0, 0.0) for source in source_points: if source is not None and _vector_length(_tuple_sub(point, source)) <= tolerance: return (point[0] + move[0], point[1] + move[1], point[2] + move[2]) return point def _make_local_polygon_face(self, points: list[tuple[float, float, float]]) -> TopoDS_Shape: polygon = BRepBuilderAPI_MakePolygon() for point in points: polygon.Add(gp_Pnt(*point)) polygon.Close() if hasattr(polygon, "IsDone") and not polygon.IsDone(): raise RuntimeError("Local edge deformation could not create a polygon wire.") maker = BRepBuilderAPI_MakeFace(polygon.Wire()) if hasattr(maker, "IsDone") and not maker.IsDone(): raise RuntimeError("Local edge deformation could not create a face from a polygon wire.") face = maker.Face() if face.IsNull(): raise RuntimeError("Local edge deformation created an empty face.") return face def _make_local_bilinear_quad_face( self, points: list[tuple[float, float, float]], tolerance: float, ) -> TopoDS_Shape: if len(points) != 4: raise RuntimeError("Local edge deformation bilinear face needs exactly four points.") grid = TColgp_Array2OfPnt(1, 2, 1, 2) grid.SetValue(1, 1, gp_Pnt(*points[0])) grid.SetValue(2, 1, gp_Pnt(*points[1])) grid.SetValue(1, 2, gp_Pnt(*points[3])) grid.SetValue(2, 2, gp_Pnt(*points[2])) surface = GeomAPI_PointsToBSplineSurface(grid).Surface() maker = BRepBuilderAPI_MakeFace(surface, max(float(tolerance), 1e-7)) if hasattr(maker, "IsDone") and not maker.IsDone(): raise RuntimeError("Local edge deformation could not create a bilinear quad face.") face = maker.Face() if face.IsNull(): raise RuntimeError("Local edge deformation created an empty bilinear quad face.") return face def _dedupe_local_points( self, points: list[tuple[float, float, float]], tolerance: float, ) -> list[tuple[float, float, float]]: result: list[tuple[float, float, float]] = [] for point in points: if not any(_vector_length(_tuple_sub(point, existing)) <= tolerance for existing in result): result.append(point) if len(result) > 1 and _vector_length(_tuple_sub(result[0], result[-1])) <= tolerance: result.pop() return result def _local_points_are_planar( self, points: list[tuple[float, float, float]], tolerance: float, ) -> bool: if len(points) <= 3: return True normal = self._local_polygon_normal(points) if normal is None: return False origin = points[0] return all(abs(_tuple_dot(_tuple_sub(point, origin), normal)) <= tolerance * 20.0 for point in points[3:]) def _local_polygon_normal( self, points: list[tuple[float, float, float]], ) -> tuple[float, float, float] | None: normal = (0.0, 0.0, 0.0) count = len(points) for index, point in enumerate(points): next_point = points[(index + 1) % count] normal = ( normal[0] + (point[1] - next_point[1]) * (point[2] + next_point[2]), normal[1] + (point[2] - next_point[2]) * (point[0] + next_point[0]), normal[2] + (point[0] - next_point[0]) * (point[1] + next_point[1]), ) return _tuple_normalized(normal) def _orient_local_polygon_outward( self, points: list[tuple[float, float, float]], shape_center: tuple[float, float, float], ) -> list[tuple[float, float, float]]: normal = self._local_polygon_normal(points) if normal is None: return points center = ( sum(point[0] for point in points) / len(points), sum(point[1] for point in points) / len(points), sum(point[2] for point in points) / len(points), ) if _tuple_dot(normal, _tuple_sub(center, shape_center)) < 0: return list(reversed(points)) return points def _local_point_key(self, point: tuple[float, float, float], tolerance: float) -> tuple[int, int, int]: scale = max(float(tolerance), 1e-9) return (round(point[0] / scale), round(point[1] / scale), round(point[2] / scale)) def _edge_length_affine_preview_shape(self, plan: dict[str, object]) -> TopoDS_Shape: target_kind, source_shape, _part, _solid = self._edge_length_affine_target(plan) return self._affine_scaled_shape_along_edge(source_shape, plan) def _refine_affine_edge_length_scale(self, plan: dict[str, object]) -> str: edge_id = int(plan.get("edge_id", -1)) if edge_id < 0 or edge_id >= len(self.edges): return "" target_length = float(plan.get("target_length", 0.0)) if target_length <= 1e-9: return "" initial_scale = float(plan.get("affine_scale", 1.0)) plan["affine_initial_scale"] = initial_scale refined_scale = initial_scale measured_length = 0.0 iterations = 0 tolerance = max(target_length * 5e-4, 1e-5) for _index in range(3): iterations += 1 plan["affine_scale"] = refined_scale try: transformed_edge = self._affine_scaled_shape_along_edge(self.edges[edge_id], plan) props = GProp_GProps() brepgprop.LinearProperties(transformed_edge, props) measured_length = float(props.Mass()) except Exception: plan["affine_scale"] = initial_scale return "几何缩放比例预校正失败,将使用原始目标比例。" if measured_length <= 1e-9: plan["affine_scale"] = initial_scale return "几何缩放比例预校正失败:预估Edge长度无效。" if abs(measured_length - target_length) <= tolerance: break refined_scale *= target_length / measured_length plan["affine_scale"] = refined_scale try: transformed_edge = self._affine_scaled_shape_along_edge(self.edges[edge_id], plan) props = GProp_GProps() brepgprop.LinearProperties(transformed_edge, props) final_measured_length = float(props.Mass()) if final_measured_length > 1e-9: measured_length = final_measured_length except Exception: pass plan["affine_predicted_edge_length"] = measured_length plan["affine_scale_correction"] = refined_scale / initial_scale if abs(initial_scale) > 1e-12 else 1.0 plan["affine_scale_refine_iterations"] = iterations if abs(refined_scale - initial_scale) > max(abs(initial_scale) * 1e-4, 1e-6): plan["affine_scale_refine_note"] = "已用预变换Edge长度微调几何缩放比例。" return "已用预变换Edge长度微调几何缩放比例,使目标Edge长度更接近输入值。" plan["affine_scale_refine_note"] = "" return "" def _apply_edge_length_affine_transform(self, plan: dict[str, object]) -> None: target_kind, source_shape, part, solid = self._edge_length_affine_target(plan) previous_logical_ids = tuple(getattr(self, "face_logical_ids", ())) transformed = self._affine_scaled_shape_along_edge(source_shape, plan) _ensure_valid_shape(transformed) if target_kind == "part": part.shape = transformed else: part_solids = _explore(part.shape, TopAbs_SOLID) replaced = False shapes: list[TopoDS_Shape] = [] for item in part_solids: if not replaced and _same_shape(item, solid): shapes.append(transformed) replaced = True else: shapes.append(item) if not replaced: raise RuntimeError(f"Could not locate solid {plan.get('solid_id')} inside part {plan.get('part_id')}.") part.shape = _compound_from_shapes(shapes) _ensure_valid_shape(part.shape) self.refresh_topology() self._restore_face_logical_ids_if_count_matches(previous_logical_ids) def _edge_length_affine_target( self, plan: dict[str, object], ) -> tuple[str, TopoDS_Shape, object, TopoDS_Shape | None]: part_id = int(plan.get("part_id", -1)) solid_id = int(plan.get("solid_id", -1)) part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") target_kind = str(plan.get("affine_target_kind", "part")) if target_kind == "solid" and 0 <= solid_id < len(self.solids): return target_kind, self.solids[solid_id][1], part, self.solids[solid_id][1] return "part", part.shape, part, None def _affine_scaled_shape_along_edge(self, shape: TopoDS_Shape, plan: dict[str, object]) -> TopoDS_Shape: axis_point = _tuple_or_none(plan.get("affine_axis_point")) axis_direction = _tuple_normalized(_tuple_or_none(plan.get("affine_axis_direction"))) scale = float(plan.get("affine_scale", 1.0)) transform_kind = str(plan.get("affine_transform_kind", "axis-affine")) if axis_point is None: raise ValueError("Missing affine edge-length axis.") if transform_kind == "uniform": transform = gp_Trsf() transform.SetScale(gp_Pnt(*axis_point), scale) builder = BRepBuilderAPI_Transform(shape, transform, True) builder.Build() if not builder.IsDone(): raise RuntimeError("Uniform edge-length scale transform failed.") result = builder.Shape() if result.IsNull(): raise RuntimeError("Uniform edge-length scale transform produced an empty shape.") return result if axis_direction is None: raise ValueError("Missing affine edge-length axis direction.") ux, uy, uz = axis_direction if transform_kind == "radial-affine": matrix = [ [scale + (1.0 - scale) * ux * ux, (1.0 - scale) * ux * uy, (1.0 - scale) * ux * uz], [(1.0 - scale) * uy * ux, scale + (1.0 - scale) * uy * uy, (1.0 - scale) * uy * uz], [(1.0 - scale) * uz * ux, (1.0 - scale) * uz * uy, scale + (1.0 - scale) * uz * uz], ] else: matrix = [ [1.0 + (scale - 1.0) * ux * ux, (scale - 1.0) * ux * uy, (scale - 1.0) * ux * uz], [(scale - 1.0) * uy * ux, 1.0 + (scale - 1.0) * uy * uy, (scale - 1.0) * uy * uz], [(scale - 1.0) * uz * ux, (scale - 1.0) * uz * uy, 1.0 + (scale - 1.0) * uz * uz], ] cx, cy, cz = axis_point moved_center = ( matrix[0][0] * cx + matrix[0][1] * cy + matrix[0][2] * cz, matrix[1][0] * cx + matrix[1][1] * cy + matrix[1][2] * cz, matrix[2][0] * cx + matrix[2][1] * cy + matrix[2][2] * cz, ) translation = (cx - moved_center[0], cy - moved_center[1], cz - moved_center[2]) transform = gp_GTrsf() for row in range(3): for column in range(3): transform.SetValue(row + 1, column + 1, matrix[row][column]) transform.SetTranslationPart(gp_XYZ(*translation)) builder = BRepBuilderAPI_GTransform(shape, transform, True) builder.Build() if not builder.IsDone(): raise RuntimeError(f"{transform_kind} edge-length transform failed.") result = builder.Shape() if result.IsNull(): raise RuntimeError(f"{transform_kind} edge-length transform produced an empty shape.") return result def push_pull_face(self, face_id: int, distance: float) -> str: plan = self.push_pull_plan(face_id, distance) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: raise ValueError("Push/pull currently supports planar faces only.") part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") outward = plan["outward_direction"] scope_face_ids = _int_values(plan.get("push_pull_scope_face_ids")) or [face_id] boundary_shell_extension = None if plan.get("cylindrical_cap_extension_kind") is None: boundary_shell_extension = self._planar_cap_boundary_shell_extension_plan( face_id, float(distance), outward, scope_face_ids, ) if boundary_shell_extension is not None: target_logical_id: int | None = None target_region_mapping_specs: list[dict[str, object]] = [] try: target_logical_id = self.face_region_logical_id(face_id) plan["target_logical_id"] = target_logical_id target_region_mapping_specs = self._translated_face_region_mapping_specs( self._face_region_mapping_specs([face_id]), _tuple_scale(outward, float(distance)), logical_id=target_logical_id, ) except Exception: target_logical_id = None target_region_mapping_specs = [] def apply_boundary_shell_extension() -> None: previous_face_count = len(self.faces) part.shape = self._planar_cap_boundary_shell_extension_shape( face_id, boundary_shell_extension, float(distance), outward, ) self.refresh_topology() if len(self.faces) > previous_face_count: plan["result_candidate_face_ids"] = tuple(range(previous_face_count, len(self.faces))) self._apply_face_region_mapping_specs_exclusive(target_logical_id, target_region_mapping_specs) _action_result, result_check = self._run_checked_face_edit(plan, apply_boundary_shell_extension) return ( "Planar face push/pull completed: planar cap boundary-shell rebuild, " f"semantic_distance={distance:g}, " f"boundary_edges={int(boundary_shell_extension['cap_boundary_edge_count'])}, " f"inner_wires={int(boundary_shell_extension['inner_boundary_wires'])}, " f"adjacent_faces={int(boundary_shell_extension['adjacent_face_count'])}, " f"outward_direction={_format_tuple(outward)}, " f"direction_confidence={plan['direction_confidence']}, " f"risk={plan['risk']}. {result_check}" ) profile_shape = self._push_pull_profile_shape(scope_face_ids) boundary_edge_ids = self._region_boundary_edge_ids(scope_face_ids) side_face_ids = sorted( set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - set(scope_face_ids) ) side_region_mapping_specs = self._face_region_mapping_specs(side_face_ids) target_logical_id: int | None = None target_region_mapping_specs: list[dict[str, object]] = [] try: target_logical_id = self.face_region_logical_id(face_id) plan["target_logical_id"] = target_logical_id target_region_mapping_specs = self._translated_face_region_mapping_specs( self._face_region_mapping_specs([face_id]), _tuple_scale(outward, float(distance)), logical_id=target_logical_id, ) except Exception: target_logical_id = None target_region_mapping_specs = [] cap_extension = self._cylindrical_cap_extension_plan(face_id, distance, outward) prismatic_cap_rebuild: dict[str, object] | None = None if cap_extension is None or str(cap_extension.get("cap_extension_method") or "") == "cap-profile-prism": try: prismatic_cap_rebuild = self._prismatic_cap_rebuild_candidate( face_id, float(distance), outward, profile_shape, ) except Exception: prismatic_cap_rebuild = None if prismatic_cap_rebuild is not None: def apply_prismatic_cap_rebuild() -> None: part.shape = prismatic_cap_rebuild["replacement_shape"] self.refresh_topology() self._apply_face_region_mapping_specs(side_region_mapping_specs) self._apply_face_region_mapping_specs_exclusive(target_logical_id, target_region_mapping_specs) _action_result, result_check = self._run_checked_face_edit(plan, apply_prismatic_cap_rebuild) return ( "Planar face push/pull completed: prismatic cap analytic rebuild, " f"semantic_distance={distance:g}, " f"old_height={float(prismatic_cap_rebuild['old_height']):g}, " f"new_height={float(prismatic_cap_rebuild['new_height']):g}, " f"side_faces={int(prismatic_cap_rebuild['side_face_count'])}, " f"cap_planes={int(prismatic_cap_rebuild['cap_plane_count'])}, " f"outward_direction={_format_tuple(outward)}, " f"direction_confidence={plan['direction_confidence']}, " f"risk={plan['risk']}. {result_check}" ) slow_boolean_blocker = self._slow_push_pull_boolean_blocker(plan, cap_extension) if slow_boolean_blocker: raise ValueError(slow_boolean_blocker) if cap_extension is not None: use_cap_profile_prism = ( int(cap_extension.get("cap_extra_adjacent_face_count") or 0) > 0 and str(cap_extension.get("cap_extension_method") or "") == "cap-profile-prism" ) use_cap_local_shell_rebuild = str(cap_extension.get("cap_extension_method") or "") == "local-shell-rebuild" simple_replacement = ( cap_extension["tool_shape"] if ( not use_cap_profile_prism and not use_cap_local_shell_rebuild and self._simple_cylindrical_cap_extension_rebuild_available(face_id, cap_extension) ) else None ) if use_cap_profile_prism: cap_operation = "profile-extend" if float(distance) > 0.0 else "profile-retract" else: cap_operation = str(cap_extension.get("cap_operation") or "extend") if use_cap_profile_prism: local_delta_shape = self._cap_profile_prism_delta_shape( profile_shape, part.shape, outward, float(distance), ) cap_extension["local_delta_shape"] = local_delta_shape cap_extension["extension_shape"] = local_delta_shape cap_extension["cap_extension_method"] = "cap-profile-prism" elif use_cap_local_shell_rebuild: local_delta_shape = None else: local_delta_shape = ( cap_extension.get("local_delta_shape") or cap_extension.get("extension_shape") or cap_extension.get("removal_shape") ) if simple_replacement is None and local_delta_shape is None and not use_cap_local_shell_rebuild: raise RuntimeError("Cylindrical cap push/pull plan did not produce a local delta shape.") def apply_cap_extension() -> None: if simple_replacement is not None: result = simple_replacement elif use_cap_local_shell_rebuild: result = self._cylindrical_cap_local_shell_rebuild_shape( face_id, cap_extension, float(distance), outward, ) elif cap_operation in {"retract", "profile-retract"}: op = BRepAlgoAPI_Cut(part.shape, local_delta_shape) result = _finalize_boolean_result(op, "cylindrical cap push/pull local retraction", use_glue=False) result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance) else: op = BRepAlgoAPI_Fuse(part.shape, local_delta_shape) result = _finalize_boolean_result(op, "cylindrical cap push/pull local extension", use_glue=False) result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance) previous_face_count = len(self.faces) part.shape = result self.refresh_topology() if use_cap_local_shell_rebuild and len(self.faces) > previous_face_count: plan["result_candidate_face_ids"] = tuple(range(previous_face_count, len(self.faces))) self._apply_face_region_mapping_specs(side_region_mapping_specs) self._apply_face_region_mapping_specs_exclusive(target_logical_id, target_region_mapping_specs) _action_result, result_check = self._run_checked_face_edit(plan, apply_cap_extension) action = ( "cylindrical cap analytic rebuild" if simple_replacement is not None else ( "cylindrical cap local shell rebuild" if use_cap_local_shell_rebuild else ( "cylindrical cap profile-prism extension" if cap_operation == "profile-extend" else "cylindrical cap profile-prism retraction" if cap_operation == "profile-retract" else ( "cylindrical cap local retraction" if cap_operation == "retract" else "cylindrical cap local extension" ) ) ) ) return ( f"Planar face push/pull completed: {action}, " f"semantic_distance={distance:g}, " f"radius={float(cap_extension['radius']):g}, " f"inner_radius={_format_result_number(cap_extension.get('inner_radius'))}, " f"old_height={float(cap_extension['old_height']):g}, " f"new_height={float(cap_extension['new_height']):g}, " f"extension_height={_format_result_number(cap_extension.get('extension_height'))}, " f"cap_operation={cap_operation}, " f"cap_extension_kind={cap_extension.get('cap_extension_kind')}, " f"cap_extension_method={cap_extension.get('cap_extension_method')}, " f"side_faces={cap_extension['side_face_ids']}, " f"outward_direction={_format_tuple(outward)}, " f"direction_confidence={plan['direction_confidence']}, " f"risk={plan['risk']}. {result_check}" ) overlap = _boolean_overlap_distance(part.shape, distance) start_offset = -overlap if distance >= 0 else overlap tool_distance = distance + overlap if distance >= 0 else distance - overlap tool_face = _translated_shape(profile_shape, outward, start_offset) vec = gp_Vec( float(outward[0]) * tool_distance, float(outward[1]) * tool_distance, float(outward[2]) * tool_distance, ) tool_shape = BRepPrimAPI_MakePrism(tool_face, vec).Shape() def apply_push_pull() -> None: op = BRepAlgoAPI_Fuse(part.shape, tool_shape) if distance >= 0 else BRepAlgoAPI_Cut(part.shape, tool_shape) result = _finalize_boolean_result(op, "push/pull") result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance) part.shape = result self.refresh_topology() self._apply_face_region_mapping_specs(side_region_mapping_specs) self._apply_face_region_mapping_specs_exclusive(target_logical_id, target_region_mapping_specs) _action_result, result_check = self._run_checked_face_edit(plan, apply_push_pull) action = "fused outward prism" if distance >= 0 else "cut inward prism" return ( "Planar face push/pull completed: " f"{action}, semantic_distance={distance:g}, " f"tool_overlap={overlap:g}, " f"scope_faces={len(scope_face_ids)}, " f"outward_direction={_format_tuple(outward)}, " f"direction_confidence={plan['direction_confidence']}, " f"risk={plan['risk']}. {result_check}" ) def _cylindrical_cap_local_shell_rebuild_shape( self, face_id: int, cap_extension: dict[str, object], distance: float, outward: tuple[float, float, float], ) -> TopoDS_Shape: part_id = self.face_part_ids[face_id] solid_id = self.face_solid_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") if solid_id < 0 or solid_id >= len(self.solids): raise ValueError(f"Face {face_id} is not attached to a rebuildable Solid.") source_solid = self.solids[solid_id][1] cap_scope_face_ids = _int_values(cap_extension.get("cap_scope_face_ids")) or [face_id] side_specs = [item for item in cap_extension.get("side_rebuild_specs", ()) if isinstance(item, dict)] if not side_specs: raise RuntimeError("Cylindrical cap local shell rebuild has no side Face rebuild specs.") axis_alignment = _float_or_none(cap_extension.get("axis_alignment")) if axis_alignment is None: raise RuntimeError("Cylindrical cap local shell rebuild has no axis direction.") if float(distance) <= 0.0 or str(cap_extension.get("cap_operation") or "") != "extend": raise RuntimeError( "Cylindrical cap local shell rebuild currently supports outward cap extension only." ) remove_face_ids = sorted(set(cap_scope_face_ids)) remove_faces = [self.faces[item] for item in remove_face_ids if 0 <= item < len(self.faces)] if not remove_faces: raise RuntimeError("Cylindrical cap local shell rebuild has no removable source Faces.") tolerance = max( _shape_diagonal(source_solid) * 1e-7, abs(float(distance)) * 1e-7, 1e-6, ) rebuilt_faces: list[TopoDS_Shape] = [] removed_count = 0 for source_face in _explore(source_solid, TopAbs_FACE): if any(_same_shape(source_face, removable) for removable in remove_faces): removed_count += 1 continue rebuilt_faces.append(source_face) if removed_count < len(remove_faces): raise RuntimeError( "Cylindrical cap local shell rebuild could not find every source Face in the Solid." ) for cap_face_id in cap_scope_face_ids: if cap_face_id < 0 or cap_face_id >= len(self.faces): continue moved_cap = _translated_shape( self.faces[cap_face_id], (float(outward[0]), float(outward[1]), float(outward[2])), float(distance), ) _ensure_valid_shape(moved_cap) rebuilt_faces.append(moved_cap) for spec in side_specs: side_face_id = int(spec.get("face_id", -1)) if side_face_id < 0 or side_face_id >= len(self.faces): raise RuntimeError("Cylindrical cap local shell rebuild references an invalid side Face.") surf = BRepAdaptor_Surface(self.faces[side_face_id]) if surf.GetType() != GeomAbs_Cylinder: raise RuntimeError("Cylindrical cap local shell rebuild side Face is not cylindrical.") u_min = float(spec["u_min"]) u_max = float(spec["u_max"]) if axis_alignment > 0.0: v_min = float(spec["old_v_max"]) v_max = float(spec["new_v_max"]) else: v_min = float(spec["new_v_min"]) v_max = float(spec["old_v_min"]) if v_max <= v_min + tolerance: raise RuntimeError("Cylindrical cap local shell rebuild collapsed a side Face.") side_face = BRepBuilderAPI_MakeFace(surf.Cylinder(), u_min, u_max, v_min, v_max).Face() side_face.Orientation(self.faces[side_face_id].Orientation()) _ensure_valid_shape(side_face) rebuilt_faces.append(side_face) sewing = BRepBuilderAPI_Sewing(tolerance) for rebuilt_face in rebuilt_faces: sewing.Add(rebuilt_face) sewing.Perform() sewed = sewing.SewedShape() if sewed.IsNull(): raise RuntimeError("Cylindrical cap local shell rebuild sewing produced an empty shape.") if sewed.ShapeType() == TopAbs_SHELL: shell = topods.Shell(sewed) else: shells = _explore(sewed, TopAbs_SHELL) if not shells: raise RuntimeError("Cylindrical cap local shell rebuild did not produce a sewable shell.") shell = topods.Shell(shells[0]) solid_builder = BRepBuilderAPI_MakeSolid(shell) rebuilt_solid = solid_builder.Solid() if rebuilt_solid.IsNull(): raise RuntimeError("Cylindrical cap local shell rebuild could not create a Solid.") rebuilt_solid = _ensure_valid_or_repaired_shape(rebuilt_solid, "cylindrical cap local shell rebuild") part_solids = _explore(part.shape, TopAbs_SOLID) if not part_solids: return rebuilt_solid if len(part_solids) == 1 and _same_shape(part_solids[0], source_solid): return rebuilt_solid shapes: list[TopoDS_Shape] = [] replaced = False for item in part_solids: if not replaced and _same_shape(item, source_solid): shapes.append(rebuilt_solid) replaced = True else: shapes.append(item) if not replaced: raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.") return _ensure_valid_or_repaired_shape( _compound_from_shapes(shapes), "cylindrical cap local shell rebuild compound", ) def _cap_profile_prism_delta_shape( self, profile_shape: TopoDS_Shape, source_shape: TopoDS_Shape, outward: tuple[float, float, float], distance: float, ) -> TopoDS_Shape: if abs(distance) <= 1e-9: raise ValueError("Cap profile prism push/pull requires a non-zero distance.") overlap = _boolean_overlap_distance(source_shape, distance) if distance > 0: start_offset = -overlap tool_distance = distance + overlap else: start_offset = overlap tool_distance = distance - overlap tool_face = _translated_shape(profile_shape, outward, start_offset) vec = gp_Vec( float(outward[0]) * tool_distance, float(outward[1]) * tool_distance, float(outward[2]) * tool_distance, ) tool_shape = BRepPrimAPI_MakePrism(tool_face, vec).Shape() _ensure_valid_shape(tool_shape) return tool_shape def _prismatic_cap_rebuild_candidate( self, face_id: int, distance: float, outward: tuple[float, float, float], profile_shape: TopoDS_Shape, ) -> dict[str, object] | None: if abs(distance) <= 1e-9: return None if face_id < 0 or face_id >= len(self.faces): return None part_id = self.face_part_ids[face_id] solid_id = self.face_solid_ids[face_id] part = self.part_by_id(part_id) if part is None or solid_id < 0 or solid_id >= len(self.solids): return None if sum(1 for candidate_part_id, _solid in self.solids if candidate_part_id == part_id) != 1: return None source_surf = BRepAdaptor_Surface(self.faces[face_id]) if source_surf.GetType() != GeomAbs_Plane: return None axis_direction_tuple = _tuple_normalized(outward) if axis_direction_tuple is None: return None axis_point = source_surf.Plane().Location() axis_direction = gp_Dir(*axis_direction_tuple) solid = self.solids[solid_id][1] axis_interval = _shape_axis_interval(solid, axis_point, axis_direction) if axis_interval is None: return None interval_min, interval_max = float(axis_interval[0]), float(axis_interval[1]) old_height = max(interval_max - interval_min, 0.0) if old_height <= 1e-9: return None tolerance = max(_shape_diagonal(solid) * 1e-6, abs(distance) * 1e-6, old_height * 1e-6, 1e-5) if abs(interval_max) > max(old_height * 0.08, tolerance * 20.0): return None new_height = old_height + float(distance) if new_height <= max(old_height * 1e-5, tolerance): return None cap_parameters: list[float] = [] side_face_count = 0 solid_face_ids = [index for index, item in enumerate(self.face_solid_ids) if item == solid_id] if len(solid_face_ids) < 3 or len(solid_face_ids) > 128: return None for candidate_face_id in solid_face_ids: try: surf = BRepAdaptor_Surface(self.faces[candidate_face_id]) except Exception: return None surface_type = surf.GetType() if surface_type == GeomAbs_Plane: normal = surf.Plane().Axis().Direction() normal_dot = abs(_direction_dot(normal, axis_direction)) if normal_dot >= 0.92: center = _surface_center(self.faces[candidate_face_id]) cap_parameters.append(_axis_parameter(axis_point, axis_direction, center)) elif normal_dot <= 0.12: side_face_count += 1 else: return None elif surface_type == GeomAbs_Cylinder: cylinder_axis = surf.Cylinder().Axis().Direction() if abs(_direction_dot(cylinder_axis, axis_direction)) < 0.92: return None side_face_count += 1 else: return None unique_cap_parameters: list[float] = [] for parameter in sorted(cap_parameters): if not any(abs(parameter - existing) <= tolerance * 20.0 for existing in unique_cap_parameters): unique_cap_parameters.append(parameter) if len(unique_cap_parameters) != 2 or side_face_count <= 0: return None if abs(max(unique_cap_parameters)) > max(old_height * 0.08, tolerance * 20.0): return None if abs(min(unique_cap_parameters) - interval_min) > max(old_height * 0.08, tolerance * 20.0): return None profile_start = _translated_shape(profile_shape, axis_direction_tuple, interval_min) vec = gp_Vec( float(axis_direction_tuple[0]) * new_height, float(axis_direction_tuple[1]) * new_height, float(axis_direction_tuple[2]) * new_height, ) replacement = BRepPrimAPI_MakePrism(profile_start, vec).Shape() replacement = _ensure_valid_or_repaired_shape(replacement, "prismatic cap analytic rebuild") replacement = _unify_same_domain_shape(replacement) replacement = _ensure_valid_or_repaired_shape(replacement, "prismatic cap analytic rebuild unify") return { "replacement_shape": replacement, "old_height": old_height, "new_height": new_height, "axis_interval": axis_interval, "side_face_count": side_face_count, "cap_plane_count": len(cap_parameters), "cap_position_count": len(unique_cap_parameters), "axis_direction": axis_direction_tuple, "start_offset": interval_min, } def _slow_push_pull_boolean_blocker( self, plan: dict[str, object], cap_extension: dict[str, object] | None, ) -> str: distance = abs(float(plan.get("distance", 0.0) or 0.0)) if distance <= 1e-9: return "" face_count = len(self.faces) selected_inner_wires = int(plan.get("selected_inner_boundary_wires", 0) or 0) bbox_diagonal = _float_or_none(plan.get("bbox_diagonal")) or 0.0 face_ratio = distance / bbox_diagonal if bbox_diagonal > 1e-9 else None cap_method = str(cap_extension.get("cap_extension_method") or "") if cap_extension is not None else "" old_height = _float_or_none(cap_extension.get("old_height")) if cap_extension is not None else None height_ratio = distance / old_height if old_height is not None and old_height > 1e-9 else None if ( cap_method == "cap-profile-prism" and height_ratio is not None and height_ratio > 0.35 ): return ( "已识别为带槽口/台阶等额外一级相邻面的端盖,但无法把它安全简化成解析重建;" "继续走通用 OCCT 布尔很容易长时间卡住。已快速阻止本次大距离拉伸/切除," "请先缩小修改距离,或后续用更明确的槽/台阶一级关系编辑入口处理。" ) if ( cap_extension is None and face_count > 600 and selected_inner_wires > 0 and ( face_ratio is None or face_ratio > 0.2 or distance > 10.0 ) ): return ( "当前 Face 有多个内孔/内边界,并且所属 STEP 实体很复杂;程序没有识别到可安全解析重建的单一拉伸体。" "继续使用通用布尔拉伸/切除很可能再次超时。已快速阻止本次大距离拉伸/切除;" "简单空心圆柱、多个贯穿孔圆柱和普通板件端盖已经会走快速解析重建," "这种复杂大实体需要先做更明确的特征隔离/一级关系编辑。" ) return "" def _planar_cap_boundary_shell_extension_plan( self, face_id: int, distance: float, outward: tuple[float, float, float], scope_face_ids: Iterable[int] | None = None, ) -> dict[str, object] | None: if abs(float(distance)) <= 1e-9: return None if face_id < 0 or face_id >= len(self.faces): return None try: surf = BRepAdaptor_Surface(self.faces[face_id]) except Exception: return None if surf.GetType() != GeomAbs_Plane: return None solid_id = self.face_solid_ids[face_id] part_id = self.face_part_ids[face_id] if solid_id < 0 or solid_id >= len(self.solids): return None if self.part_by_id(part_id) is None: return None try: wire_count = len(_explore(self.faces[face_id], TopAbs_WIRE)) except Exception: wire_count = 0 inner_wires = max(wire_count - 1, 0) if inner_wires <= 0: return None cap_scope_face_ids = sorted( { int(item) for item in (scope_face_ids or (face_id,)) if 0 <= int(item) < len(self.faces) and int(self.face_solid_ids[int(item)]) == solid_id } ) if not cap_scope_face_ids: cap_scope_face_ids = [face_id] boundary_edge_ids = self._region_boundary_edge_ids(cap_scope_face_ids) if not boundary_edge_ids: boundary_edge_ids = self._face_boundary_edge_ids(face_id) boundary_edge_ids = sorted({int(item) for item in boundary_edge_ids if 0 <= int(item) < len(self.edges)}) if len(boundary_edge_ids) < 4: return None adjacent_ids = sorted( set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - set(cap_scope_face_ids) ) if not adjacent_ids: return None return { "cap_extension_kind": "multi-boundary-planar-cap", "cap_extension_method": "boundary-shell-rebuild", "cap_operation": "extend" if float(distance) > 0.0 else "retract", "cap_scope_face_ids": tuple(cap_scope_face_ids), "cap_boundary_edge_ids": tuple(boundary_edge_ids), "cap_boundary_edge_count": len(boundary_edge_ids), "bridge_face_count": len(boundary_edge_ids), "inner_boundary_wires": inner_wires, "boundary_wires": wire_count, "adjacent_face_ids": tuple(adjacent_ids), "adjacent_face_count": len(adjacent_ids), "part_id": part_id, "solid_id": solid_id, } def _planar_cap_boundary_shell_extension_shape( self, face_id: int, extension: dict[str, object], distance: float, outward: tuple[float, float, float], ) -> TopoDS_Shape: if abs(float(distance)) <= 1e-9: raise RuntimeError("Planar cap boundary-shell rebuild requires a non-zero distance.") part_id = self.face_part_ids[face_id] solid_id = self.face_solid_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") if solid_id < 0 or solid_id >= len(self.solids): raise ValueError(f"Face {face_id} is not attached to a rebuildable Solid.") source_solid = self.solids[solid_id][1] cap_scope_face_ids = _int_values(extension.get("cap_scope_face_ids")) or [face_id] cap_scope_face_ids = [ item for item in cap_scope_face_ids if 0 <= item < len(self.faces) and int(self.face_solid_ids[item]) == solid_id ] if not cap_scope_face_ids: raise RuntimeError("Planar cap boundary-shell rebuild has no cap Face to move.") boundary_edge_ids = _int_values(extension.get("cap_boundary_edge_ids")) if not boundary_edge_ids: boundary_edge_ids = self._region_boundary_edge_ids(cap_scope_face_ids) boundary_edge_ids = sorted({item for item in boundary_edge_ids if 0 <= item < len(self.edges)}) if len(boundary_edge_ids) < 3: raise RuntimeError("Planar cap boundary-shell rebuild has too few boundary Edges.") tolerance = max( _shape_diagonal(source_solid) * 1e-7, abs(float(distance)) * 1e-7, 1e-6, ) remove_faces = [self.faces[item] for item in cap_scope_face_ids] rebuilt_faces: list[TopoDS_Shape] = [] removed_count = 0 for source_face in _explore(source_solid, TopAbs_FACE): if any(_same_shape(source_face, removable) for removable in remove_faces): removed_count += 1 continue rebuilt_faces.append(source_face) if removed_count < len(remove_faces): raise RuntimeError( "Planar cap boundary-shell rebuild could not find every source cap Face in the Solid." ) for cap_face_id in cap_scope_face_ids: moved_cap = _translated_shape( self.faces[cap_face_id], (float(outward[0]), float(outward[1]), float(outward[2])), float(distance), ) _ensure_valid_shape(moved_cap) rebuilt_faces.append(moved_cap) bridge_vector = gp_Vec( float(outward[0]) * float(distance), float(outward[1]) * float(distance), float(outward[2]) * float(distance), ) bridge_face_count = 0 for edge_id in boundary_edge_ids: bridge_shape = BRepPrimAPI_MakePrism(self.edges[edge_id], bridge_vector).Shape() bridge_faces = _explore(bridge_shape, TopAbs_FACE) if not bridge_faces and bridge_shape.ShapeType() == TopAbs_FACE: bridge_faces = [bridge_shape] if not bridge_faces: raise RuntimeError( f"Planar cap boundary-shell rebuild could not create a bridge Face for Edge {edge_id}." ) for bridge_face in bridge_faces: if bridge_face.IsNull(): raise RuntimeError( f"Planar cap boundary-shell rebuild created an empty bridge Face for Edge {edge_id}." ) rebuilt_faces.append(bridge_face) bridge_face_count += 1 if bridge_face_count < len(boundary_edge_ids): raise RuntimeError( "Planar cap boundary-shell rebuild created fewer bridge Faces than boundary Edges." ) sewing = BRepBuilderAPI_Sewing(tolerance) for rebuilt_face in rebuilt_faces: sewing.Add(rebuilt_face) sewing.Perform() sewed = sewing.SewedShape() if sewed.IsNull(): raise RuntimeError("Planar cap boundary-shell rebuild sewing produced an empty shape.") if sewed.ShapeType() == TopAbs_SHELL: shell = topods.Shell(sewed) else: shells = _explore(sewed, TopAbs_SHELL) if not shells: raise RuntimeError("Planar cap boundary-shell rebuild did not produce a sewable shell.") shell = topods.Shell(shells[0]) solid_builder = BRepBuilderAPI_MakeSolid(shell) rebuilt_solid = solid_builder.Solid() if rebuilt_solid.IsNull(): raise RuntimeError("Planar cap boundary-shell rebuild could not create a Solid.") rebuilt_solid = _ensure_valid_or_repaired_shape( rebuilt_solid, "planar cap boundary-shell rebuild", ) part_solids = _explore(part.shape, TopAbs_SOLID) if not part_solids: return rebuilt_solid if len(part_solids) == 1 and _same_shape(part_solids[0], source_solid): return rebuilt_solid shapes: list[TopoDS_Shape] = [] replaced = False for item in part_solids: if not replaced and _same_shape(item, source_solid): shapes.append(rebuilt_solid) replaced = True else: shapes.append(item) if not replaced: raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.") return _ensure_valid_or_repaired_shape( _compound_from_shapes(shapes), "planar cap boundary-shell rebuild compound", ) def _cylindrical_cap_extension_plan( self, face_id: int, distance: float, outward: tuple[float, float, float], ) -> dict[str, object] | None: if abs(distance) <= 1e-9: return None if face_id < 0 or face_id >= len(self.faces): return None source_surf = BRepAdaptor_Surface(self.faces[face_id]) if source_surf.GetType() != GeomAbs_Plane: return None source_plane = source_surf.Plane() cap_plane_point = source_plane.Location() try: scope_face_ids = self._connected_coplanar_planar_face_ids(face_id) or [face_id] except Exception: scope_face_ids = [face_id] scope_face_ids = sorted({int(item) for item in scope_face_ids if 0 <= int(item) < len(self.faces)}) if not scope_face_ids: scope_face_ids = [face_id] try: region_shape = _compound_from_shapes([self.faces[item] for item in scope_face_ids]) region_props = GProp_GProps() brepgprop.SurfaceProperties(region_shape, region_props) cap_center = region_props.CentreOfMass() except Exception: cap_center = _surface_center(self.faces[face_id]) boundary_edge_ids = self._region_boundary_edge_ids(scope_face_ids) if not boundary_edge_ids: boundary_edge_ids = self._face_boundary_edge_ids(face_id) adjacent_face_ids = self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id) if not adjacent_face_ids: return None diagonal = _shape_diagonal(self.shape) tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) outward_dir = gp_Dir(float(outward[0]), float(outward[1]), float(outward[2])) candidates: list[tuple[float, dict[str, object]]] = [] cylindrical_adjacent_face_ids: set[int] = set() adjacent_face_id_set = set(int(item) for item in adjacent_face_ids) for adjacent_id in adjacent_face_ids: side_surf = BRepAdaptor_Surface(self.faces[adjacent_id]) if side_surf.GetType() != GeomAbs_Cylinder: continue angular_span = abs(side_surf.LastUParameter() - side_surf.FirstUParameter()) cylinder = side_surf.Cylinder() radius = float(cylinder.Radius()) axis = cylinder.Axis() axis_point = axis.Location() axis_dir = axis.Direction() axis_alignment = _direction_dot(outward_dir, axis_dir) if abs(axis_alignment) < 0.92: continue center_axis_distance = _point_axis_distance(axis_point, axis_dir, cap_center) axis_range = self._cylindrical_axis_range(adjacent_id, side_surf) v_min = float(axis_range["v_min"]) v_max = float(axis_range["v_max"]) old_height = max(v_max - v_min, 1e-9) cap_parameter = _axis_parameter(axis_point, axis_dir, cap_plane_point) start_distance = abs(cap_parameter - v_min) end_distance = abs(cap_parameter - v_max) end_tolerance = max(old_height * 0.05, radius * 0.2, tolerance * 10.0, 0.05) delta_abs = abs(float(distance)) operation = "extend" if distance > 0 else "retract" segment_overlap = ( min( max(tolerance * 20.0, radius * 1e-5, old_height * 1e-5, 1e-5), max(old_height * 0.02, 1e-5), max(delta_abs * 0.05, 1e-5), ) if operation == "extend" else 0.0 ) if axis_alignment > 0 and end_distance <= end_tolerance: new_min = v_min new_max = v_max + distance if distance > 0 else v_max - delta_abs if new_max <= new_min + max(tolerance * 10.0, old_height * 1e-5, 1e-6): continue segment_start_parameter = v_max - segment_overlap if distance > 0 else new_max segment_height = delta_abs + segment_overlap if distance > 0 else delta_abs end_score = end_distance elif axis_alignment < 0 and start_distance <= end_tolerance: new_min = v_min - distance if distance > 0 else v_min + delta_abs new_max = v_max if new_max <= new_min + max(tolerance * 10.0, old_height * 1e-5, 1e-6): continue segment_start_parameter = new_min if distance > 0 else v_min segment_height = delta_abs + segment_overlap if distance > 0 else delta_abs end_score = start_distance else: continue height = max(new_max - new_min, 1e-6) start = _point_on_axis(axis_point, axis_dir, new_min) end = _point_on_axis(axis_point, axis_dir, new_max) old_start = _point_on_axis(axis_point, axis_dir, v_min) old_end = _point_on_axis(axis_point, axis_dir, v_max) tool_shape = BRepPrimAPI_MakeCylinder(gp_Ax2(start, gp_Dir(axis_dir.X(), axis_dir.Y(), axis_dir.Z())), radius, height).Shape() segment_start = _point_on_axis(axis_point, axis_dir, segment_start_parameter) segment_shape = BRepPrimAPI_MakeCylinder( gp_Ax2(segment_start, gp_Dir(axis_dir.X(), axis_dir.Y(), axis_dir.Z())), radius, max(segment_height, 1e-6), ).Shape() score = end_score + _point_axis_distance(axis_point, axis_dir, cap_center) candidate = { "tool_shape": tool_shape, "extension_shape": segment_shape if operation == "extend" else None, "removal_shape": segment_shape if operation == "retract" else None, "local_delta_shape": segment_shape, "side_face_ids": axis_range["same_domain_face_ids"], "radius": radius, "old_height": old_height, "new_height": height, "extension_height": max(segment_height, 1e-6), "cap_operation": operation, "axis_alignment": axis_alignment, "angular_span": angular_span, "cap_center_axis_distance": center_axis_distance, "axis_point": _point_tuple(axis_point), "axis_direction": (float(axis_dir.X()), float(axis_dir.Y()), float(axis_dir.Z())), "cap_axis_parameter": cap_parameter, "old_start_parameter": v_min, "old_end_parameter": v_max, "start_parameter": new_min, "end_parameter": new_max, "old_start_point": _point_tuple(old_start), "old_end_point": _point_tuple(old_end), "start_point": _point_tuple(start), "end_point": _point_tuple(end), "extension_start_point": _point_tuple(segment_start), "cap_extension_kind": "solid-cylinder", "inner_radius": None, "cap_scope_face_ids": tuple(scope_face_ids), "cap_boundary_edge_ids": tuple(boundary_edge_ids), } candidates.append((score, candidate)) cylindrical_adjacent_face_ids.add(int(adjacent_id)) if not candidates: return None distinct_radii: list[float] = [] for _score, candidate in candidates: radius = float(candidate["radius"]) if not any(abs(radius - existing) <= max(radius, existing, 1.0) * 1e-5 for existing in distinct_radii): distinct_radii.append(radius) if len(distinct_radii) > 1: tube_candidate = self._coaxial_tube_cap_extension_candidate(candidates, tolerance) if tube_candidate is not None and self._tube_cap_region_matches_radii( scope_face_ids, tube_candidate, tolerance, ): extra_adjacent_face_ids = tuple(sorted(adjacent_face_id_set - cylindrical_adjacent_face_ids)) tube_candidate["cap_scope_face_ids"] = tuple(scope_face_ids) tube_candidate["cap_boundary_edge_ids"] = tuple(boundary_edge_ids) tube_candidate["cap_extra_adjacent_face_ids"] = extra_adjacent_face_ids tube_candidate["cap_extra_adjacent_face_count"] = len(extra_adjacent_face_ids) if extra_adjacent_face_ids: if distance > 0: tube_candidate["cap_extension_method"] = "cap-profile-prism" else: tube_candidate.update( self._cap_extra_adjacent_retract_limit( extra_adjacent_face_ids, tube_candidate, tolerance, ) ) if bool(tube_candidate.get("cap_profile_prism_retract_safe")): tube_candidate["cap_extension_method"] = "cap-profile-prism" return tube_candidate stepped_candidate = self._coaxial_stepped_cap_extension_candidate(candidates, tolerance) if stepped_candidate is not None and self._tube_cap_region_matches_radii( scope_face_ids, stepped_candidate, tolerance, ): extra_adjacent_face_ids = tuple(sorted(adjacent_face_id_set - cylindrical_adjacent_face_ids)) stepped_candidate["cap_scope_face_ids"] = tuple(scope_face_ids) stepped_candidate["cap_boundary_edge_ids"] = tuple(boundary_edge_ids) stepped_candidate["cap_extra_adjacent_face_ids"] = extra_adjacent_face_ids stepped_candidate["cap_extra_adjacent_face_count"] = len(extra_adjacent_face_ids) if ( str(stepped_candidate.get("cap_operation") or "") == "extend" and not extra_adjacent_face_ids and self._cylindrical_cap_side_faces_are_simple( stepped_candidate, scope_face_ids, tolerance, ) ): stepped_candidate["cap_extension_method"] = "local-shell-rebuild" return stepped_candidate return None extra_adjacent_face_ids = tuple(sorted(adjacent_face_id_set - cylindrical_adjacent_face_ids)) if len(distinct_radii) == 1 and extra_adjacent_face_ids: solid_candidate = min(candidates, key=lambda item: item[0])[1] if self._solid_cap_region_matches_radius(scope_face_ids, solid_candidate, tolerance): solid_candidate["cap_scope_face_ids"] = tuple(scope_face_ids) solid_candidate["cap_boundary_edge_ids"] = tuple(boundary_edge_ids) solid_candidate["cap_extra_adjacent_face_ids"] = extra_adjacent_face_ids solid_candidate["cap_extra_adjacent_face_count"] = len(extra_adjacent_face_ids) if distance > 0: solid_candidate["cap_extension_method"] = "cap-profile-prism" else: solid_candidate.update( self._cap_extra_adjacent_retract_limit( extra_adjacent_face_ids, solid_candidate, tolerance, ) ) if bool(solid_candidate.get("cap_profile_prism_retract_safe")): solid_candidate["cap_extension_method"] = "cap-profile-prism" return solid_candidate if cylindrical_adjacent_face_ids != adjacent_face_id_set: return None if any(not _is_effectively_full_cylinder(candidate) for _score, candidate in candidates): return None if any( float(candidate.get("cap_center_axis_distance") or 0.0) > max(float(candidate["radius"]) * 0.08, tolerance * 10.0) for _score, candidate in candidates ): return None return min(candidates, key=lambda item: item[0])[1] def _cap_extra_adjacent_retract_limit( self, extra_face_ids: Iterable[int], cap_extension: dict[str, object], tolerance: float, ) -> dict[str, object]: axis_point_tuple = _tuple_or_none(cap_extension.get("axis_point")) axis_direction_tuple = _tuple_normalized(_tuple_or_none(cap_extension.get("axis_direction"))) axis_alignment = _float_or_none(cap_extension.get("axis_alignment")) if axis_point_tuple is None or axis_direction_tuple is None or axis_alignment is None: return {"cap_profile_prism_retract_safe": False} axis_point = gp_Pnt(*axis_point_tuple) axis_direction = gp_Dir(*axis_direction_tuple) old_start = _float_or_none(cap_extension.get("old_start_parameter")) old_end = _float_or_none(cap_extension.get("old_end_parameter")) new_start = _float_or_none(cap_extension.get("start_parameter")) new_end = _float_or_none(cap_extension.get("end_parameter")) if old_start is None or old_end is None or new_start is None or new_end is None: return {"cap_profile_prism_retract_safe": False} if axis_alignment > 0.0: old_cap_parameter = old_end target_parameter = new_end else: old_cap_parameter = old_start target_parameter = new_start limit_candidates: list[float] = [] touch_tolerance = max(abs(old_end - old_start) * 1e-4, tolerance * 50.0, 1e-4) for face_id in extra_face_ids: if face_id < 0 or face_id >= len(self.faces): continue interval = _shape_axis_interval(self.faces[face_id], axis_point, axis_direction) if interval is None: continue interval_min, interval_max = float(interval[0]), float(interval[1]) if not (interval_min - touch_tolerance <= old_cap_parameter <= interval_max + touch_tolerance): continue limit_candidates.append(interval_min if axis_alignment > 0.0 else interval_max) if not limit_candidates: return { "cap_profile_prism_retract_safe": False, "cap_profile_prism_target_parameter": target_parameter, } if axis_alignment > 0.0: limit_parameter = min(limit_candidates) safe = target_parameter >= limit_parameter - touch_tolerance excess = max(limit_parameter - target_parameter, 0.0) else: limit_parameter = max(limit_candidates) safe = target_parameter <= limit_parameter + touch_tolerance excess = max(target_parameter - limit_parameter, 0.0) return { "cap_profile_prism_retract_safe": safe, "cap_profile_prism_target_parameter": target_parameter, "cap_profile_prism_retract_limit_parameter": limit_parameter, "cap_profile_prism_retract_excess": excess, } def _solid_cap_region_matches_radius( self, face_ids: Iterable[int], cap_extension: dict[str, object], tolerance: float, ) -> bool: radius = _float_or_none(cap_extension.get("radius")) axis_point_tuple = _tuple_or_none(cap_extension.get("axis_point")) axis_direction_tuple = _tuple_normalized(_tuple_or_none(cap_extension.get("axis_direction"))) if radius is None or radius <= 1e-9 or axis_point_tuple is None or axis_direction_tuple is None: return False axis_point = gp_Pnt(*axis_point_tuple) axis_direction = gp_Dir(*axis_direction_tuple) radial_values: list[float] = [] for item in face_ids: if item < 0 or item >= len(self.faces): continue explorer = TopExp_Explorer(self.faces[item], TopAbs_VERTEX) while explorer.More(): vertex = topods.Vertex(explorer.Current()) radial_values.append(_point_axis_distance(axis_point, axis_direction, BRep_Tool.Pnt(vertex))) explorer.Next() if not radial_values: return False radial_tolerance = max(radius * 0.04, tolerance * 50.0, 1e-4) radial_max = max(radial_values) if radial_max > radius + radial_tolerance: return False if abs(radial_max - radius) > radial_tolerance: return False cap_extension["cap_region_radial_max"] = radial_max return True def _tube_cap_region_matches_radii( self, face_ids: Iterable[int], cap_extension: dict[str, object], tolerance: float, ) -> bool: outer_radius = _float_or_none(cap_extension.get("radius")) inner_radius = _float_or_none(cap_extension.get("inner_radius")) axis_point_tuple = _tuple_or_none(cap_extension.get("axis_point")) axis_direction_tuple = _tuple_normalized(_tuple_or_none(cap_extension.get("axis_direction"))) if ( outer_radius is None or inner_radius is None or outer_radius <= inner_radius or inner_radius <= 1e-9 or axis_point_tuple is None or axis_direction_tuple is None ): return False axis_point = gp_Pnt(*axis_point_tuple) axis_direction = gp_Dir(*axis_direction_tuple) radial_values: list[float] = [] for item in face_ids: if item < 0 or item >= len(self.faces): continue explorer = TopExp_Explorer(self.faces[item], TopAbs_VERTEX) while explorer.More(): vertex = topods.Vertex(explorer.Current()) radial_values.append(_point_axis_distance(axis_point, axis_direction, BRep_Tool.Pnt(vertex))) explorer.Next() if not radial_values: return False radial_tolerance = max(outer_radius * 0.04, inner_radius * 0.04, tolerance * 50.0, 1e-4) radial_min = min(radial_values) radial_max = max(radial_values) if radial_max > outer_radius + radial_tolerance: return False if radial_min < inner_radius - radial_tolerance: return False if abs(radial_max - outer_radius) > radial_tolerance: return False if abs(radial_min - inner_radius) > radial_tolerance: return False cap_extension["cap_region_radial_min"] = radial_min cap_extension["cap_region_radial_max"] = radial_max return True def _coaxial_tube_cap_extension_candidate( self, candidates: list[tuple[float, dict[str, object]]], tolerance: float, ) -> dict[str, object] | None: if len(candidates) < 2: return None radii = sorted({round(float(candidate["radius"]), 9) for _score, candidate in candidates}) if len(radii) != 2: return None inner_radius = float(radii[0]) outer_radius = float(radii[1]) if inner_radius <= 1e-9 or outer_radius <= inner_radius: return None reference = candidates[0][1] axis_point_tuple = _tuple_or_none(reference.get("axis_point")) axis_direction_tuple = _tuple_normalized(_tuple_or_none(reference.get("axis_direction"))) start_point_tuple = _tuple_or_none(reference.get("start_point")) end_point_tuple = _tuple_or_none(reference.get("end_point")) old_start_point_tuple = _tuple_or_none(reference.get("old_start_point")) old_end_point_tuple = _tuple_or_none(reference.get("old_end_point")) if ( axis_point_tuple is None or axis_direction_tuple is None or start_point_tuple is None or end_point_tuple is None or old_start_point_tuple is None or old_end_point_tuple is None ): return None axis_point = gp_Pnt(*axis_point_tuple) axis_direction = gp_Dir(*axis_direction_tuple) start_point = gp_Pnt(*start_point_tuple) end_point = gp_Pnt(*end_point_tuple) old_start_point = gp_Pnt(*old_start_point_tuple) old_end_point = gp_Pnt(*old_end_point_tuple) start_parameter = float(reference["start_parameter"]) end_parameter = float(reference["end_parameter"]) old_height = float(reference["old_height"]) new_height = float(reference["new_height"]) side_face_ids: set[int] = set() score = 0.0 reference_operation = str(reference.get("cap_operation") or "extend") for item_score, candidate in candidates: if str(candidate.get("cap_operation") or "extend") != reference_operation: return None candidate_axis_point = _tuple_or_none(candidate.get("axis_point")) candidate_axis_direction = _tuple_normalized(_tuple_or_none(candidate.get("axis_direction"))) if candidate_axis_point is None or candidate_axis_direction is None: return None if abs(abs(_tuple_dot(axis_direction_tuple, candidate_axis_direction)) - 1.0) > 1e-5: return None if _point_axis_distance(axis_point, axis_direction, gp_Pnt(*candidate_axis_point)) > max(tolerance * 10.0, outer_radius * 1e-5): return None for key, expected in (("old_height", old_height), ("new_height", new_height)): if abs(float(candidate[key]) - expected) > max(tolerance * 10.0, old_height * 1e-5, 1e-5): return None candidate_start = _tuple_or_none(candidate.get("start_point")) candidate_end = _tuple_or_none(candidate.get("end_point")) candidate_old_start = _tuple_or_none(candidate.get("old_start_point")) candidate_old_end = _tuple_or_none(candidate.get("old_end_point")) if ( candidate_start is None or candidate_end is None or candidate_old_start is None or candidate_old_end is None ): return None point_tolerance = max(tolerance * 20.0, outer_radius * 1e-5, old_height * 1e-6, 1e-5) for actual_point, expected_point in ( (gp_Pnt(*candidate_start), start_point), (gp_Pnt(*candidate_end), end_point), (gp_Pnt(*candidate_old_start), old_start_point), (gp_Pnt(*candidate_old_end), old_end_point), ): if actual_point.Distance(expected_point) > point_tolerance: return None side_face_ids.update(_int_values(candidate.get("side_face_ids"))) score += float(item_score) extension_start_point_tuple = _tuple_or_none(reference.get("extension_start_point")) extension_height = _float_or_none(reference.get("extension_height")) if extension_start_point_tuple is None or extension_height is None or extension_height <= 1e-9: return None extension_start_point = gp_Pnt(*extension_start_point_tuple) operation = str(reference.get("cap_operation") or "extend") tool_shape = self._make_coaxial_tube_shape(start_point, axis_direction, outer_radius, inner_radius, new_height) local_delta_shape = self._make_coaxial_tube_shape( extension_start_point, axis_direction, outer_radius, inner_radius, extension_height, ) return { **reference, "tool_shape": tool_shape, "extension_shape": local_delta_shape if operation == "extend" else None, "removal_shape": local_delta_shape if operation == "retract" else None, "local_delta_shape": local_delta_shape, "side_face_ids": tuple(sorted(side_face_ids)), "radius": outer_radius, "inner_radius": inner_radius, "old_height": old_height, "new_height": new_height, "extension_height": extension_height, "cap_operation": operation, "start_parameter": start_parameter, "end_parameter": end_parameter, "cap_extension_kind": "coaxial-tube", "candidate_score": score, } def _coaxial_stepped_cap_extension_candidate( self, candidates: list[tuple[float, dict[str, object]]], tolerance: float, ) -> dict[str, object] | None: if len(candidates) < 2: return None radii = sorted({round(float(candidate["radius"]), 9) for _score, candidate in candidates}) if len(radii) != 2: return None inner_radius = float(radii[0]) outer_radius = float(radii[1]) if inner_radius <= 1e-9 or outer_radius <= inner_radius: return None reference = candidates[0][1] axis_point_tuple = _tuple_or_none(reference.get("axis_point")) axis_direction_tuple = _tuple_normalized(_tuple_or_none(reference.get("axis_direction"))) if axis_point_tuple is None or axis_direction_tuple is None: return None axis_point = gp_Pnt(*axis_point_tuple) axis_direction = gp_Dir(*axis_direction_tuple) operation = str(reference.get("cap_operation") or "extend") axis_alignment = _float_or_none(reference.get("axis_alignment")) reference_cap_parameter = _float_or_none(reference.get("cap_axis_parameter")) if axis_alignment is None or reference_cap_parameter is None: return None side_face_ids: set[int] = set() radius_ranges: dict[float, dict[str, float]] = {} score = 0.0 target_cap_parameter: float | None = None for item_score, candidate in candidates: if str(candidate.get("cap_operation") or "extend") != operation: return None candidate_axis_point = _tuple_or_none(candidate.get("axis_point")) candidate_axis_direction = _tuple_normalized(_tuple_or_none(candidate.get("axis_direction"))) if candidate_axis_point is None or candidate_axis_direction is None: return None if abs(abs(_tuple_dot(axis_direction_tuple, candidate_axis_direction)) - 1.0) > 1e-5: return None if _point_axis_distance(axis_point, axis_direction, gp_Pnt(*candidate_axis_point)) > max( tolerance * 10.0, outer_radius * 1e-5, ): return None candidate_alignment = _float_or_none(candidate.get("axis_alignment")) candidate_cap_parameter = _float_or_none(candidate.get("cap_axis_parameter")) start_parameter = _float_or_none(candidate.get("start_parameter")) end_parameter = _float_or_none(candidate.get("end_parameter")) old_start_parameter = _float_or_none(candidate.get("old_start_parameter")) old_end_parameter = _float_or_none(candidate.get("old_end_parameter")) radius = _float_or_none(candidate.get("radius")) if ( candidate_alignment is None or candidate_cap_parameter is None or start_parameter is None or end_parameter is None or old_start_parameter is None or old_end_parameter is None or radius is None or radius <= 1e-9 ): return None if candidate_alignment * axis_alignment <= 0.0: return None if abs(candidate_cap_parameter - reference_cap_parameter) > max(tolerance * 20.0, 1e-5): return None candidate_target = end_parameter if axis_alignment > 0.0 else start_parameter if target_cap_parameter is None: target_cap_parameter = candidate_target elif abs(candidate_target - target_cap_parameter) > max(tolerance * 20.0, 1e-5): return None key = min(radii, key=lambda item: abs(item - float(radius))) existing = radius_ranges.get(key) data = { "radius": float(radius), "old_start_parameter": old_start_parameter, "old_end_parameter": old_end_parameter, "start_parameter": start_parameter, "end_parameter": end_parameter, "old_height": max(old_end_parameter - old_start_parameter, 0.0), "new_height": max(end_parameter - start_parameter, 0.0), } if existing is not None: for field in ("old_start_parameter", "old_end_parameter", "start_parameter", "end_parameter"): if abs(float(existing[field]) - float(data[field])) > max(tolerance * 20.0, 1e-5): return None radius_ranges[key] = data side_face_ids.update(_int_values(candidate.get("side_face_ids"))) score += float(item_score) if set(radius_ranges) != set(radii) or target_cap_parameter is None: return None side_specs: list[dict[str, object]] = [] for side_face_id in sorted(side_face_ids): if side_face_id < 0 or side_face_id >= len(self.faces): return None try: surf = BRepAdaptor_Surface(self.faces[side_face_id]) except Exception: return None if surf.GetType() != GeomAbs_Cylinder: return None cylinder = surf.Cylinder() radius = float(cylinder.Radius()) key = min(radii, key=lambda item: abs(item - radius)) if abs(radius - key) > max(key * 1e-5, tolerance * 10.0): return None side_axis = cylinder.Axis() if abs(abs(_direction_dot(axis_direction, side_axis.Direction())) - 1.0) > 1e-5: return None if _point_axis_distance(axis_point, axis_direction, side_axis.Location()) > max( tolerance * 10.0, outer_radius * 1e-5, ): return None range_data = radius_ranges[key] side_specs.append( { "face_id": side_face_id, "radius": radius, "u_min": float(surf.FirstUParameter()), "u_max": float(surf.LastUParameter()), "old_v_min": float(range_data["old_start_parameter"]), "old_v_max": float(range_data["old_end_parameter"]), "new_v_min": float(range_data["start_parameter"]), "new_v_max": float(range_data["end_parameter"]), } ) old_height = max(float(item["old_height"]) for item in radius_ranges.values()) new_height = max(float(item["new_height"]) for item in radius_ranges.values()) extension_height = abs(target_cap_parameter - reference_cap_parameter) return { **reference, "tool_shape": None, "extension_shape": None, "removal_shape": None, "local_delta_shape": None, "side_face_ids": tuple(sorted(side_face_ids)), "side_rebuild_specs": tuple(side_specs), "radius": outer_radius, "inner_radius": inner_radius, "old_height": old_height, "new_height": new_height, "extension_height": extension_height, "cap_operation": operation, "start_parameter": min(float(item["start_parameter"]) for item in radius_ranges.values()), "end_parameter": max(float(item["end_parameter"]) for item in radius_ranges.values()), "cap_extension_kind": "coaxial-stepped-cap", "candidate_score": score, } def _cylindrical_cap_side_faces_are_simple( self, cap_extension: dict[str, object], scope_face_ids: Iterable[int], tolerance: float, ) -> bool: side_specs = [item for item in cap_extension.get("side_rebuild_specs", ()) if isinstance(item, dict)] if not side_specs: return False axis_point_tuple = _tuple_or_none(cap_extension.get("axis_point")) axis_direction_tuple = _tuple_normalized(_tuple_or_none(cap_extension.get("axis_direction"))) axis_alignment = _float_or_none(cap_extension.get("axis_alignment")) if axis_point_tuple is None or axis_direction_tuple is None or axis_alignment is None: return False axis_point = gp_Pnt(*axis_point_tuple) axis_direction = gp_Dir(*axis_direction_tuple) cap_scope = {int(item) for item in scope_face_ids if 0 <= int(item) < len(self.faces)} side_face_ids = {int(item.get("face_id", -1)) for item in side_specs} side_face_ids = {item for item in side_face_ids if 0 <= item < len(self.faces)} if not side_face_ids: return False end_tolerance = max(tolerance * 50.0, 1e-4) for spec in side_specs: side_face_id = int(spec.get("face_id", -1)) if side_face_id < 0 or side_face_id >= len(self.faces): return False if len(_explore(self.faces[side_face_id], TopAbs_WIRE)) != 1: return False edge_ids = self._face_boundary_edge_ids(side_face_id) if not edge_ids or len(edge_ids) > 6: return False fixed_parameter = ( _float_or_none(spec.get("old_v_min")) if axis_alignment > 0.0 else _float_or_none(spec.get("old_v_max")) ) if fixed_parameter is None: return False adjacent_ids: set[int] = set() for edge_id in edge_ids: adjacent_ids.update(self._edge_adjacent_face_ids(edge_id)) extra_ids = sorted(adjacent_ids - {side_face_id} - side_face_ids - cap_scope) for extra_id in extra_ids: if extra_id < 0 or extra_id >= len(self.faces): return False interval = _shape_axis_interval(self.faces[extra_id], axis_point, axis_direction) if interval is None: return False interval_min, interval_max = float(interval[0]), float(interval[1]) if not (interval_min - end_tolerance <= fixed_parameter <= interval_max + end_tolerance): return False return True def _make_coaxial_tube_shape( self, start: gp_Pnt, axis_direction, outer_radius: float, inner_radius: float, height: float, ) -> TopoDS_Shape: axis = gp_Ax2(start, gp_Dir(axis_direction.X(), axis_direction.Y(), axis_direction.Z())) outer = BRepPrimAPI_MakeCylinder(axis, outer_radius, height).Shape() extra = max(float(height) * 1e-5, float(outer_radius) * 1e-5, 1e-5) cutter_start = _point_on_axis(start, axis_direction, -extra) cutter_axis = gp_Ax2(cutter_start, gp_Dir(axis_direction.X(), axis_direction.Y(), axis_direction.Z())) inner = BRepPrimAPI_MakeCylinder(cutter_axis, inner_radius, height + 2.0 * extra).Shape() op = BRepAlgoAPI_Cut(outer, inner) return _finalize_boolean_result(op, "coaxial tube analytic rebuild", use_glue=False) def _simple_cylindrical_cap_extension_rebuild_available( self, face_id: int, cap_extension: dict[str, object], ) -> bool: if face_id < 0 or face_id >= len(self.faces): return False solid_id = self.face_solid_ids[face_id] part_id = self.face_part_ids[face_id] if solid_id < 0 or solid_id >= len(self.solids): return False if sum(1 for candidate_part_id, _solid in self.solids if candidate_part_id == part_id) != 1: return False outer_radius = _float_or_none(cap_extension.get("radius")) inner_radius = _float_or_none(cap_extension.get("inner_radius")) axis_point_tuple = _tuple_or_none(cap_extension.get("axis_point")) axis_direction_tuple = _tuple_normalized(_tuple_or_none(cap_extension.get("axis_direction"))) if outer_radius is None or outer_radius <= 1e-9 or axis_point_tuple is None or axis_direction_tuple is None: return False axis_point = gp_Pnt(*axis_point_tuple) axis_direction = gp_Dir(*axis_direction_tuple) tolerance = max(float(cap_extension.get("new_height") or 0.0) * 1e-6, outer_radius * 1e-5, 1e-5) expected_radii = [outer_radius] if inner_radius is not None and inner_radius > 1e-9: expected_radii.append(inner_radius) cylinder_matches: list[float] = [] cap_parameters: list[float] = [] face_ids = [index for index, item in enumerate(self.face_solid_ids) if item == solid_id] if len(face_ids) < 3 or len(face_ids) > 96: return False saw_inner_radius = inner_radius is None or inner_radius <= 1e-9 saw_outer_radius = False for candidate_face_id in face_ids: try: surf = BRepAdaptor_Surface(self.faces[candidate_face_id]) except Exception: return False if surf.GetType() == GeomAbs_Cylinder: cylinder = surf.Cylinder() radius = float(cylinder.Radius()) if not any(abs(radius - expected) <= max(expected * 1e-5, tolerance) for expected in expected_radii): return False if abs(abs(_direction_dot(axis_direction, cylinder.Axis().Direction())) - 1.0) > 1e-5: return False if _point_axis_distance(axis_point, axis_direction, cylinder.Axis().Location()) > max(tolerance * 10.0, outer_radius * 1e-5): return False if abs(radius - outer_radius) <= max(outer_radius * 1e-5, tolerance): saw_outer_radius = True if inner_radius is not None and inner_radius > 1e-9: if abs(radius - inner_radius) <= max(inner_radius * 1e-5, tolerance): saw_inner_radius = True cylinder_matches.append(radius) elif surf.GetType() == GeomAbs_Plane: normal = surf.Plane().Axis().Direction() if abs(_direction_dot(normal, axis_direction)) < 0.92: return False center = _surface_center(self.faces[candidate_face_id]) if _point_axis_distance(axis_point, axis_direction, center) > max(outer_radius * 0.05, tolerance * 10.0): return False cap_parameters.append(_axis_parameter(axis_point, axis_direction, center)) else: return False unique_cap_parameters: list[float] = [] cap_parameter_tolerance = max( float(cap_extension.get("new_height") or 0.0) * 1e-5, outer_radius * 1e-5, tolerance * 10.0, 1e-5, ) for parameter in sorted(cap_parameters): if not any(abs(parameter - existing) <= cap_parameter_tolerance for existing in unique_cap_parameters): unique_cap_parameters.append(parameter) if len(unique_cap_parameters) != 2: return False return bool(saw_outer_radius and saw_inner_radius) def resize_existing_fillet(self, face_id: int, target_radius: float) -> str: plan = self.existing_fillet_resize_plan(face_id, target_radius) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = int(plan["part_id"]) part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") source_face = topods.Face(self.faces[face_id]) defeatured = _defeature_faces(part.shape, [source_face]) axis_point = gp_Pnt(*plan["axis_point"]) axis_dir = gp_Dir(*plan["axis"]) root_edges = _axis_aligned_edge_candidates( defeatured, axis_point, axis_dir, expected_length=float(plan.get("height_estimate") or 0.0), reference_radius=float(plan["current_radius"]), ) if not root_edges: raise RuntimeError( "已尝试移除已有圆角面,但没有找到可重新倒圆的轴向锐边;" "该圆角可能不是简单直线边圆角。" ) result = None failures: list[str] = [] for index, root_edge in enumerate(root_edges[:16], start=1): try: maker = BRepFilletAPI_MakeFillet(defeatured) maker.Add(float(target_radius), topods.Edge(root_edge)) result = _finalize_builder_result(maker, f"existing fillet resize candidate {index}") break except Exception as exc: failures.append(str(exc)) if result is None: detail = failures[-1] if failures else "没有可用的候选边。" raise RuntimeError( "已移除已有圆角面,但所有候选锐边都无法重新倒圆;" f"该圆角可能是复杂 blend 或支撑面不适合重建。最后错误:{detail}" ) part.shape = result self.refresh_topology() return ( "Existing fillet radius resize completed: " f"face {face_id}, current_radius={float(plan['current_radius']):g}, " f"target_radius={target_radius:g}, " f"delta_radius={float(plan['delta_radius']):g}, " f"support_faces={plan.get('feature_existing_fillet_support_face_ids')}, " f"risk={plan['risk']}." ) def fillet_edge(self, edge_id: int, radius: float) -> str: plan = self.edge_fillet_plan(edge_id, radius) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = self.edge_part_ids[edge_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") maker = BRepFilletAPI_MakeFillet(part.shape) maker.Add(radius, topods.Edge(self.edges[edge_id])) result = _finalize_builder_result(maker, "edge fillet") part.shape = result self.refresh_topology() return ( f"Edge fillet completed: edge {edge_id}, radius={radius:g}, " f"edge_length={float(plan['edge_length']):g}, " f"radius_to_length_ratio={float(plan['radius_to_length_ratio']):g}, " f"risk={plan['risk']}." ) def chamfer_edge(self, edge_id: int, distance: float) -> str: plan = self.edge_chamfer_plan(edge_id, distance) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = self.edge_part_ids[edge_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") maker = BRepFilletAPI_MakeChamfer(part.shape) maker.Add(distance, topods.Edge(self.edges[edge_id])) result = _finalize_builder_result(maker, "edge chamfer") part.shape = result self.refresh_topology() return ( f"Edge chamfer completed: edge {edge_id}, distance={distance:g}, " f"edge_length={float(plan['edge_length']):g}, " f"distance_to_length_ratio={float(plan['distance_to_length_ratio']):g}, " f"risk={plan['risk']}." ) def chamfer_edge_asymmetric( self, edge_id: int, distance1: float, distance2: float, reference_face_id: int | None = None, ) -> str: plan = self.edge_asymmetric_chamfer_plan(edge_id, distance1, distance2, reference_face_id) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = self.edge_part_ids[edge_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") resolved_reference_face_id = int(plan["reference_face_id"]) maker = BRepFilletAPI_MakeChamfer(part.shape) try: maker.Add( float(distance1), float(distance2), topods.Edge(self.edges[edge_id]), topods.Face(self.faces[resolved_reference_face_id]), ) except TypeError as exc: raise RuntimeError("The current OCCT binding does not support asymmetric chamfer Add(D1, D2, Edge, Face).") from exc result = _finalize_builder_result(maker, "asymmetric edge chamfer") part.shape = result self.refresh_topology() return ( f"Asymmetric Edge chamfer completed: edge {edge_id}, " f"distance1={float(distance1):g}, distance2={float(distance2):g}, " f"reference_face={resolved_reference_face_id}, " f"edge_length={float(plan['edge_length']):g}, " f"distance1_to_length_ratio={float(plan['distance1_to_length_ratio']):g}, " f"distance2_to_length_ratio={float(plan['distance2_to_length_ratio']):g}, " f"risk={plan['risk']}." ) def chamfer_edge_distance_angle( self, edge_id: int, distance: float, angle_degrees: float, reference_face_id: int | None = None, ) -> str: plan = self.edge_distance_angle_chamfer_plan(edge_id, distance, angle_degrees, reference_face_id) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = self.edge_part_ids[edge_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") resolved_reference_face_id = int(plan["reference_face_id"]) maker = BRepFilletAPI_MakeChamfer(part.shape) try: maker.AddDA( float(distance), float(plan["target_angle_radians"]), topods.Edge(self.edges[edge_id]), topods.Face(self.faces[resolved_reference_face_id]), ) except AttributeError as exc: raise RuntimeError("The current OCCT binding does not support distance-angle chamfer AddDA(D, Angle, Edge, Face).") from exc except TypeError as exc: raise RuntimeError("The current OCCT binding rejected distance-angle chamfer AddDA(D, Angle, Edge, Face).") from exc result = _finalize_builder_result(maker, "distance-angle edge chamfer") part.shape = result self.refresh_topology() return ( f"Distance-angle Edge chamfer completed: edge {edge_id}, " f"distance={float(distance):g}, angle_degrees={float(angle_degrees):g}, " f"reference_face={resolved_reference_face_id}, " f"edge_length={float(plan['edge_length']):g}, " f"distance_to_length_ratio={float(plan['distance_to_length_ratio']):g}, " f"risk={plan['risk']}." ) def enlarge_cylindrical_hole(self, face_id: int, new_diameter: float) -> str: return self.resize_cylindrical_hole(face_id, new_diameter) def resize_cylindrical_slot_width( self, face_id: int, target_width: float, pair_face_id: int | None = None, ) -> str: return self._resize_cylindrical_slot_parameter(face_id, target_width, "width", pair_face_id=pair_face_id) def resize_cylindrical_slot_depth( self, face_id: int, target_depth: float, pair_face_id: int | None = None, ) -> str: return self._resize_cylindrical_slot_parameter(face_id, target_depth, "depth", pair_face_id=pair_face_id) def resize_cylindrical_slot_arc_length( self, face_id: int, target_arc_length: float, pair_face_id: int | None = None, ) -> str: return self._resize_cylindrical_slot_parameter(face_id, target_arc_length, "arc_length", pair_face_id=pair_face_id) def resize_cylindrical_slot_angular_span(self, face_id: int, target_angular_span: float) -> str: plan = self.cylindrical_slot_angular_span_plan(face_id, target_angular_span) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) return self._resize_cylindrical_slot_angular_span_with_sector_tool(plan) def resize_cylindrical_slot_total_length( self, face_id: int, target_total_length: float, pair_face_id: int | None = None, ) -> str: plan = self.cylindrical_slot_total_length_plan(face_id, target_total_length, pair_face_id=pair_face_id) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) return self._resize_cylindrical_slot_length_with_capsule_tool(plan) def resize_cylindrical_slot_center_distance( self, face_id: int, target_center_distance: float, pair_face_id: int | None = None, ) -> str: plan = self.cylindrical_slot_center_distance_plan( face_id, target_center_distance, pair_face_id=pair_face_id, ) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) return self._resize_cylindrical_slot_length_with_capsule_tool(plan) def _resize_cylindrical_slot_parameter( self, face_id: int, target_value: float, mode: str, pair_face_id: int | None = None, ) -> str: plan = self.cylindrical_slot_resize_plan(face_id, target_value, mode, pair_face_id=pair_face_id) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) target_diameter = float(plan["slot_target_diameter"]) if plan.get("slot_resize_strategy") == "paired-obround-slot-prism": resize_result = self._resize_cylindrical_slot_with_capsule_tool(plan) tool_path = "paired-obround-slot-prism" else: resize_result = self._resize_cylindrical_slot_with_sector_tool(plan) tool_path = "slot-sector" mode_key = str(plan.get("slot_resize_mode") or mode) if mode_key == "depth": current_value = plan.get("slot_current_depth") final_value = plan.get("slot_target_depth") label = "depth" elif mode_key == "arc_length": current_value = plan.get("slot_current_arc_length") final_value = plan.get("slot_target_arc_length") label = "arc_length" else: current_value = plan.get("slot_current_width") final_value = plan.get("slot_target_width") label = "width" def fmt(value: object) -> str: try: return f"{float(value):g}" except (TypeError, ValueError): return "" return ( f"Cylindrical slot {label} resize completed: face {face_id}, " f"{label}={fmt(current_value)}->{fmt(final_value)}, " f"derived_diameter={target_diameter:g}, " f"angular_span={fmt(plan.get('slot_angular_span'))}, " f"strategy={plan.get('slot_resize_strategy')}, " f"tool_path={tool_path}, " f"risk={plan['risk']}. {resize_result}" ) def _slot_fill_radius(self, plan: dict[str, object]) -> float: fill_radius = _float_or_none(plan.get("fill_radius")) nominal_diameter = _float_or_none(plan.get("current_diameter")) if nominal_diameter is None: nominal_diameter = _float_or_none(plan.get("slot_target_diameter")) nominal_radius = nominal_diameter * 0.5 if nominal_diameter is not None else None if fill_radius is None or fill_radius <= 1e-9: fill_radius = nominal_radius if nominal_radius is not None else 0.0 if nominal_radius is not None and nominal_radius > 0: overlap = min(max(nominal_radius * 0.02, 0.02), 0.2) fill_radius = max(fill_radius, nominal_radius + overlap) return float(fill_radius) def _drop_tiny_artifact_solids( self, result: TopoDS_Shape, reference_shape: TopoDS_Shape, ) -> tuple[TopoDS_Shape, dict[str, object]]: solids = _explore(result, TopAbs_SOLID) if len(solids) <= 1: return result, {"discarded_count": 0, "discarded_volume": 0.0} def solid_volume(shape: TopoDS_Shape) -> float: value = _shape_volume_info(shape).get("volume") try: return abs(float(value)) except (TypeError, ValueError): return 0.0 volumes = [solid_volume(solid) for solid in solids] largest_index = max(range(len(solids)), key=lambda index: volumes[index]) largest_volume = volumes[largest_index] if largest_volume <= 1e-9: return result, {"discarded_count": 0, "discarded_volume": 0.0} reference_volume = solid_volume(reference_shape) volume_limit = max(largest_volume, reference_volume, 1.0) * 1e-4 tiny_indices = [index for index, volume in enumerate(volumes) if index != largest_index and volume <= volume_limit] discarded_volume = sum(volumes[index] for index in tiny_indices) if len(tiny_indices) != len(solids) - 1 or discarded_volume > volume_limit: return result, {"discarded_count": 0, "discarded_volume": 0.0} return ( solids[largest_index], { "discarded_count": len(tiny_indices), "discarded_volume": discarded_volume, "largest_volume": largest_volume, "volume_limit": volume_limit, }, ) def _resize_cylindrical_slot_length_with_capsule_tool(self, plan: dict[str, object]) -> str: face_id = int(plan["face_id"]) part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") old_part_shape = part.shape source_shape = part.shape target_plan = dict(plan) target_plan["slot_capsule_start_center_1"] = plan.get("slot_capsule_target_start_center_1") target_plan["slot_capsule_start_center_2"] = plan.get("slot_capsule_target_start_center_2") try: filler = self._slot_capsule_prism_tool(plan, self._slot_fill_radius(plan)) fuse = BRepAlgoAPI_Fuse(part.shape, filler) source_shape = _finalize_boolean_result(fuse, "obround slot length fill/fuse", use_glue=False) cutter = self._slot_capsule_prism_tool(target_plan, float(plan["slot_target_diameter"]) / 2.0) op = BRepAlgoAPI_Cut(source_shape, cutter) result = _finalize_boolean_result(op, "obround slot length cut") part.shape = result self.refresh_topology() verification = self._verify_obround_slot_length_result(plan, part_id) if not verification["matched"]: raise RuntimeError(str(verification.get("detail", "obround slot length result verification failed"))) except Exception: part.shape = old_part_shape self.refresh_topology() raise mode_label = "center distance" if plan.get("slot_resize_mode") == "center_distance" else "total length" return ( f"Obround cylindrical slot {mode_label} resize completed: " f"length {float(plan.get('slot_current_total_length', 0.0)):g} -> {float(plan['slot_target_total_length']):g}, " f"center_distance={float(plan['slot_current_center_distance']):g}->{float(plan['slot_target_center_distance']):g}, " f"diameter={float(plan['slot_target_diameter']):g}, " f"paired_face={plan.get('slot_pair_face_id', '')}, " f"mode={plan['resize_mode']}, action=capsule fill and recut, " f"verified_face={verification.get('face_id', '')}." ) def _resize_cylindrical_slot_angular_span_with_sector_tool(self, plan: dict[str, object]) -> str: face_id = int(plan["face_id"]) part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") old_part_shape = part.shape source_shape = part.shape try: filler = self._slot_sector_prism_tool( face_id, self._slot_fill_radius(plan), float(plan["fill_start_parameter"]), float(plan["fill_end_parameter"]), ) fuse = BRepAlgoAPI_Fuse(part.shape, filler) source_shape = _finalize_boolean_result(fuse, "slot angular-span fill/fuse", use_glue=False) cutter = self._slot_sector_prism_tool( face_id, float(plan["slot_target_diameter"]) / 2.0, float(plan["cutter_start_parameter"]), float(plan["cutter_end_parameter"]), u_first_override=float(plan["slot_target_u_first"]), u_last_override=float(plan["slot_target_u_last"]), u_padding=0.0, ) op = BRepAlgoAPI_Cut(source_shape, cutter) result = _finalize_boolean_result(op, "slot angular-span cut") part.shape = result self.refresh_topology() verification = self._verify_slot_angular_span_result(plan, part_id) if not verification["matched"]: raise RuntimeError(str(verification.get("detail", "slot angular-span result verification failed"))) except Exception: part.shape = old_part_shape self.refresh_topology() raise return ( "Cylindrical slot angular-span resize completed: " f"span {float(plan['slot_current_angular_span']):g} -> {float(plan['slot_target_angular_span']):g}, " f"degrees={math.degrees(float(plan['slot_current_angular_span'])):g}->{math.degrees(float(plan['slot_target_angular_span'])):g}, " f"diameter={float(plan['slot_target_diameter']):g}, " f"mode={plan['resize_mode']}, action=sector fill and recut, " f"height={float(plan['cutter_height']):g}, " f"verified_face={verification.get('face_id', '')}." ) def _verify_slot_angular_span_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: target_span = _float_or_none(plan.get("slot_target_angular_span")) target_diameter = _float_or_none(plan.get("slot_target_diameter")) if target_span is None or target_span <= 1e-9 or target_diameter is None or target_diameter <= 1e-9: return {"matched": False, "detail": " Missing target slot angular-span verification data."} try: axis_point = gp_Pnt(*plan["cutter_axis_point"]) axis_direction = gp_Dir(*plan["cutter_axis_direction"]) except Exception: return {"matched": False, "detail": " Missing original slot axis data."} target_radius = target_diameter * 0.5 part = self.part_by_id(part_id) diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_radius, 1.0) radius_tolerance = max(target_radius * 0.03, diagonal * 1e-6, 1e-5) axis_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4) span_tolerance = max(target_span * 0.08, 0.02) best: dict[str, object] | None = None best_score = math.inf for face_id, face in enumerate(self.faces): if self.face_part_ids[face_id] != part_id: continue try: surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: continue cyl = surf.Cylinder() candidate_radius = float(cyl.Radius()) radius_error = abs(candidate_radius - target_radius) axis_dot = abs(_direction_dot(axis_direction, cyl.Axis().Direction())) if axis_dot < 1.0 - 1e-5: continue axis_distance = _point_axis_distance(axis_point, axis_direction, cyl.Axis().Location()) angular_span = abs(float(surf.LastUParameter()) - float(surf.FirstUParameter())) span_error = abs(angular_span - target_span) except Exception: continue score = ( radius_error / max(radius_tolerance, 1e-9) + axis_distance / max(axis_tolerance, 1e-9) + span_error / max(span_tolerance, 1e-9) ) candidate = { "matched": ( radius_error <= radius_tolerance and axis_distance <= axis_tolerance and span_error <= span_tolerance ), "face_id": face_id, "angular_span": angular_span, "span_error": span_error, "diameter": candidate_radius * 2.0, "radius_error": radius_error, "axis_distance": axis_distance, "span_tolerance": span_tolerance, } if candidate["matched"]: return self._attach_cylindrical_first_level_result_check(candidate, plan) if score < best_score: best_score = score best = candidate if best is None: return {"matched": False, "detail": " No cylindrical slot face on the original axis was found after angular-span edit."} return { "matched": False, "detail": ( f" Closest slot Face {best['face_id']} span {float(best['angular_span']):.6g}, " f"target {target_span:.6g}, span error {float(best['span_error']):.6g}, " f"diameter {float(best['diameter']):.6g}." ), **best, } def _verify_obround_slot_length_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: target_distance = _float_or_none(plan.get("slot_target_center_distance")) target_diameter = _float_or_none(plan.get("slot_target_diameter")) axis_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_axis_direction"))) length_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_length_direction"))) if target_distance is None or target_diameter is None or axis_dir is None or length_dir is None: return {"matched": False, "detail": " Missing obround slot length verification data."} target_radius = target_diameter * 0.5 if target_radius <= 1e-9 or target_distance <= 1e-9: return {"matched": False, "detail": " Invalid target slot length or diameter."} part = self.part_by_id(part_id) diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_distance, target_radius, 1.0) radius_tolerance = max(target_radius * 0.03, diagonal * 1e-6, 1e-5) distance_tolerance = max(target_distance * 0.05, target_radius * 0.08, diagonal * 1e-5, 1e-4) candidates: list[dict[str, object]] = [] for face_id, face in enumerate(self.faces): if self.face_part_ids[face_id] != part_id: continue try: surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: continue cyl = surf.Cylinder() radius = float(cyl.Radius()) if abs(radius - target_radius) > radius_tolerance: continue candidate_axis = _tuple_normalized(_dir_tuple(cyl.Axis().Direction())) if candidate_axis is None or abs(_tuple_dot(candidate_axis, axis_dir)) < 1.0 - 1e-4: continue axis_range = self._cylindrical_axis_range(face_id, surf) mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5 mid = _point_tuple(_point_on_axis(cyl.Axis().Location(), cyl.Axis().Direction(), mid_parameter)) candidates.append({"face_id": face_id, "mid": mid, "radius": radius}) except Exception: continue best: dict[str, object] | None = None best_score = math.inf for index, first in enumerate(candidates): for second in candidates[index + 1 :]: raw_offset = _tuple_sub(second["mid"], first["mid"]) axis_offset = _tuple_scale(axis_dir, _tuple_dot(raw_offset, axis_dir)) section_offset = _tuple_sub(raw_offset, axis_offset) distance = _vector_length(section_offset) if distance <= 1e-9: continue direction = _tuple_normalized(section_offset) if direction is None or abs(_tuple_dot(direction, length_dir)) < 0.96: continue error = abs(distance - target_distance) score = error / max(distance_tolerance, 1e-9) candidate = { "matched": error <= distance_tolerance, "face_id": first["face_id"], "paired_face_id": second["face_id"], "center_distance": distance, "target_center_distance": target_distance, "center_distance_error": error, "center_distance_tolerance": distance_tolerance, } if candidate["matched"]: return self._attach_cylindrical_first_level_result_check(candidate, plan) if score < best_score: best_score = score best = candidate if best is None: return {"matched": False, "detail": " No paired cylindrical slot ends matching the target radius were found."} return { "matched": False, "detail": ( f" Closest paired slot center distance {float(best['center_distance']):.6g}, " f"target {target_distance:.6g}, error {float(best['center_distance_error']):.6g}." ), **best, } def _verify_obround_slot_axis_move_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: target_center_1 = _tuple_or_none(plan.get("slot_pair_target_axis_center_1")) target_center_2 = _tuple_or_none(plan.get("slot_pair_target_axis_center_2")) target_distance = _float_or_none(plan.get("slot_target_center_distance")) if target_distance is None: target_distance = _float_or_none(plan.get("slot_pair_axis_distance")) target_diameter = _float_or_none(plan.get("slot_target_diameter")) axis_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_axis_direction"))) length_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_length_direction"))) if ( target_center_1 is None or target_center_2 is None or target_distance is None or target_diameter is None or axis_dir is None or length_dir is None ): return {"matched": False, "detail": " Missing obround slot axis verification data."} target_radius = target_diameter * 0.5 if target_radius <= 1e-9 or target_distance <= 1e-9: return {"matched": False, "detail": " Invalid target obround slot axis data."} part = self.part_by_id(part_id) diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_distance, target_radius, 1.0) radius_tolerance = max(target_radius * 0.03, diagonal * 1e-6, 1e-5) distance_tolerance = max(target_distance * 0.05, target_radius * 0.08, diagonal * 1e-5, 1e-4) center_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4) candidates: list[dict[str, object]] = [] for face_id, face in enumerate(self.faces): if self.face_part_ids[face_id] != part_id: continue try: surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: continue cyl = surf.Cylinder() radius = float(cyl.Radius()) if abs(radius - target_radius) > radius_tolerance: continue candidate_axis = _tuple_normalized(_dir_tuple(cyl.Axis().Direction())) if candidate_axis is None or abs(_tuple_dot(candidate_axis, axis_dir)) < 1.0 - 1e-4: continue axis_range = self._cylindrical_axis_range(face_id, surf) mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5 mid = _point_tuple(_point_on_axis(cyl.Axis().Location(), cyl.Axis().Direction(), mid_parameter)) candidates.append({"face_id": face_id, "mid": mid, "radius": radius}) except Exception: continue best: dict[str, object] | None = None best_score = math.inf for index, first in enumerate(candidates): for second in candidates[index + 1 :]: raw_offset = _tuple_sub(second["mid"], first["mid"]) axis_offset = _tuple_scale(axis_dir, _tuple_dot(raw_offset, axis_dir)) section_offset = _tuple_sub(raw_offset, axis_offset) distance = _vector_length(section_offset) if distance <= 1e-9: continue direction = _tuple_normalized(section_offset) if direction is None or abs(_tuple_dot(direction, length_dir)) < 0.96: continue distance_error = abs(distance - target_distance) direct_center_error = max( _vector_length(_tuple_sub(first["mid"], target_center_1)), _vector_length(_tuple_sub(second["mid"], target_center_2)), ) swapped_center_error = max( _vector_length(_tuple_sub(first["mid"], target_center_2)), _vector_length(_tuple_sub(second["mid"], target_center_1)), ) center_error = min(direct_center_error, swapped_center_error) score = ( distance_error / max(distance_tolerance, 1e-9) + center_error / max(center_tolerance, 1e-9) ) candidate = { "matched": distance_error <= distance_tolerance and center_error <= center_tolerance, "face_id": first["face_id"], "paired_face_id": second["face_id"], "center_distance": distance, "target_center_distance": target_distance, "center_distance_error": distance_error, "center_error": center_error, "center_tolerance": center_tolerance, } if candidate["matched"]: return self._attach_cylindrical_first_level_result_check(candidate, plan) if score < best_score: best_score = score best = candidate if best is None: return {"matched": False, "detail": " No paired obround slot ends were found at the target axis."} return { "matched": False, "detail": ( f" Closest obround slot center error {float(best['center_error']):.6g}, " f"distance {float(best['center_distance']):.6g}, target distance {target_distance:.6g}." ), **best, } def _resize_cylindrical_slot_with_capsule_tool(self, plan: dict[str, object]) -> str: face_id = int(plan["face_id"]) face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: raise ValueError("Obround slot resize currently supports cylindrical faces only.") cyl = surf.Cylinder() old_radius = float(cyl.Radius()) new_radius = float(plan["slot_target_diameter"]) / 2.0 if new_radius <= 0: raise ValueError("Target slot diameter must be greater than 0.") part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") old_part_shape = part.shape source_shape = part.shape try: if plan["resize_mode"] == "shrink": filler = self._slot_capsule_prism_tool(plan, self._slot_fill_radius(plan)) fuse = BRepAlgoAPI_Fuse(part.shape, filler) source_shape = _finalize_boolean_result(fuse, "obround slot fill/fuse") cutter = self._slot_capsule_prism_tool(plan, new_radius) op = BRepAlgoAPI_Cut(source_shape, cutter) result = _finalize_boolean_result(op, "obround slot cut") part.shape = result self.refresh_topology() verification = self._verify_cylindrical_resize_result(plan, part_id) if not verification["matched"]: raise RuntimeError(str(verification.get("detail", "obround slot result verification failed"))) except Exception: part.shape = old_part_shape self.refresh_topology() raise action = "capsule cut" if plan["resize_mode"] == "enlarge" else "capsule fill and recut" return ( "Obround cylindrical slot resize completed: " f"diameter {old_radius * 2.0:g} -> {float(plan['slot_target_diameter']):g}, " f"paired_face={plan.get('slot_pair_face_id', '')}, " f"axis_distance={float(plan.get('slot_pair_axis_distance', 0.0)):g}, " f"mode={plan['resize_mode']}, action={action}, " f"height={float(plan['cutter_height']):g}, " f"verified_face={verification.get('face_id', '')}." ) def _slot_capsule_prism_tool(self, plan: dict[str, object], radius: float) -> TopoDS_Shape: if radius <= 1e-9: raise ValueError("Slot capsule radius must be greater than 0.") center_1 = _tuple_or_none(plan.get("slot_capsule_start_center_1")) center_2 = _tuple_or_none(plan.get("slot_capsule_start_center_2")) axis_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_axis_direction"))) length_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_length_direction"))) side_dir = _tuple_normalized(_tuple_or_none(plan.get("slot_capsule_side_direction"))) if center_1 is None or center_2 is None or axis_dir is None or length_dir is None or side_dir is None: raise ValueError("Obround slot tool is missing capsule frame information.") center_distance = _vector_length(_tuple_sub(center_2, center_1)) if center_distance <= 1e-9: raise ValueError("Obround slot tool requires two distinct slot centers.") if abs(_tuple_dot(axis_dir, length_dir)) > 1e-4 or abs(_tuple_dot(axis_dir, side_dir)) > 1e-4: raise ValueError("Obround slot capsule frame is not perpendicular to the extrusion axis.") if abs(_tuple_dot(length_dir, side_dir)) > 1e-4: raise ValueError("Obround slot capsule frame length/side axes are not perpendicular.") height = max(float(plan.get("cutter_height", 0.0)), 1e-6) height_vec = gp_Vec(axis_dir[0] * height, axis_dir[1] * height, axis_dir[2] * height) try: top_1 = _tuple_add(center_1, _tuple_scale(side_dir, radius)) top_2 = _tuple_add(center_2, _tuple_scale(side_dir, radius)) bottom_2 = _tuple_add(center_2, _tuple_scale(side_dir, -radius)) bottom_1 = _tuple_add(center_1, _tuple_scale(side_dir, -radius)) end_mid_2 = _tuple_add(center_2, _tuple_scale(length_dir, radius)) end_mid_1 = _tuple_add(center_1, _tuple_scale(length_dir, -radius)) top_edge = BRepBuilderAPI_MakeEdge(gp_Pnt(*top_1), gp_Pnt(*top_2)).Edge() end_arc_2 = GC_MakeArcOfCircle(gp_Pnt(*top_2), gp_Pnt(*end_mid_2), gp_Pnt(*bottom_2)).Value() end_edge_2 = BRepBuilderAPI_MakeEdge(end_arc_2).Edge() bottom_edge = BRepBuilderAPI_MakeEdge(gp_Pnt(*bottom_2), gp_Pnt(*bottom_1)).Edge() end_arc_1 = GC_MakeArcOfCircle(gp_Pnt(*bottom_1), gp_Pnt(*end_mid_1), gp_Pnt(*top_1)).Value() end_edge_1 = BRepBuilderAPI_MakeEdge(end_arc_1).Edge() wire = BRepBuilderAPI_MakeWire(top_edge, end_edge_2, bottom_edge, end_edge_1).Wire() tool_face = _finalize_builder_result(BRepBuilderAPI_MakeFace(wire), "obround slot circular profile") return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, height_vec), "obround slot capsule prism") except Exception: # Keep the older sampled profile as a fallback for unusual frames. The # circular profile is preferred because it preserves cylindrical slot ends. pass sample_count = max(12, min(96, int(math.pi * max(radius, 1.0) / max(radius * 0.12, 0.02)))) points: list[tuple[float, float, float]] = [ _tuple_add(center_1, _tuple_scale(side_dir, radius)), _tuple_add(center_2, _tuple_scale(side_dir, radius)), ] for index in range(1, sample_count + 1): theta = math.pi / 2.0 - math.pi * index / sample_count radial = _tuple_add(_tuple_scale(length_dir, math.cos(theta) * radius), _tuple_scale(side_dir, math.sin(theta) * radius)) points.append(_tuple_add(center_2, radial)) points.append(_tuple_add(center_1, _tuple_scale(side_dir, -radius))) for index in range(1, sample_count + 1): theta = -math.pi / 2.0 - math.pi * index / sample_count radial = _tuple_add(_tuple_scale(length_dir, math.cos(theta) * radius), _tuple_scale(side_dir, math.sin(theta) * radius)) points.append(_tuple_add(center_1, radial)) tool_face = self._make_local_polygon_face(self._dedupe_local_points(points, max(radius * 1e-7, 1e-7))) return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, height_vec), "obround slot capsule prism") def _resize_cylindrical_slot_with_sector_tool(self, plan: dict[str, object]) -> str: face_id = int(plan["face_id"]) face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: raise ValueError("Slot resize currently supports cylindrical faces only.") cyl = surf.Cylinder() old_radius = cyl.Radius() new_radius = float(plan["slot_target_diameter"]) / 2.0 if new_radius <= 0: raise ValueError("Target slot diameter must be greater than 0.") part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") old_part_shape = part.shape source_shape = part.shape try: if plan["resize_mode"] == "shrink": filler = self._slot_sector_prism_tool( face_id, self._slot_fill_radius(plan), float(plan["fill_start_parameter"]), float(plan["fill_end_parameter"]), ) fuse = BRepAlgoAPI_Fuse(part.shape, filler) source_shape = _finalize_boolean_result(fuse, "slot sector fill/fuse") cutter = self._slot_sector_prism_tool( face_id, new_radius, float(plan["cutter_start_parameter"]), float(plan["cutter_end_parameter"]), ) op = BRepAlgoAPI_Cut(source_shape, cutter) result = _finalize_boolean_result(op, "slot sector cut") part.shape = result self.refresh_topology() verification = self._verify_cylindrical_resize_result(plan, part_id) if not verification["matched"]: raise RuntimeError(str(verification.get("detail", "slot sector result verification failed"))) except Exception: part.shape = old_part_shape self.refresh_topology() raise action = "sector cut" if plan["resize_mode"] == "enlarge" else "sector fill and recut" return ( "Cylindrical slot sector resize completed: " f"diameter {old_radius * 2.0:g} -> {float(plan['slot_target_diameter']):g}, " f"mode={plan['resize_mode']}, action={action}, " f"height={float(plan['cutter_height']):g}, " f"verified_face={verification.get('face_id', '')}." ) def _slot_sector_prism_tool( self, face_id: int, radius: float, start_parameter: float, end_parameter: float, *, u_first_override: float | None = None, u_last_override: float | None = None, axis_point_offset: tuple[float, float, float] | None = None, u_padding: float | None = None, ) -> TopoDS_Shape: face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: raise ValueError("Slot sector tool requires a cylindrical face.") if radius <= 1e-9: raise ValueError("Slot sector tool radius must be greater than 0.") cyl = surf.Cylinder() axis = cyl.Axis() axis_point = axis.Location() direction = axis.Direction() tool_axis_point = axis_point if axis_point_offset is not None: tool_axis_point = gp_Pnt( axis_point.X() + float(axis_point_offset[0]), axis_point.Y() + float(axis_point_offset[1]), axis_point.Z() + float(axis_point_offset[2]), ) u_first = float(surf.FirstUParameter()) if u_first_override is None else float(u_first_override) u_last = float(surf.LastUParameter()) if u_last_override is None else float(u_last_override) span = abs(u_last - u_first) if span <= 1e-6 or span >= math.tau * 0.98: raise ValueError("Slot sector tool requires a stable partial-cylinder U span.") if end_parameter < start_parameter: start_parameter, end_parameter = end_parameter, start_parameter height = max(float(end_parameter) - float(start_parameter), 1e-6) pad = ( min(max(1e-4, 0.001 / max(float(radius), 1e-6)), max(span * 0.02, 1e-4)) if u_padding is None else max(float(u_padding), 0.0) ) if u_last >= u_first: u_start = u_first - pad u_end = u_last + pad else: u_start = u_first + pad u_end = u_last - pad v_mid = (float(surf.FirstVParameter()) + float(surf.LastVParameter())) / 2.0 axis_start = _point_on_axis(tool_axis_point, direction, float(start_parameter)) axis_start_tuple = _point_tuple(axis_start) def radial_point(u: float) -> tuple[float, float, float]: source = surf.Value(float(u), v_mid) source_parameter = _axis_parameter(axis_point, direction, source) source_axis = _point_on_axis(axis_point, direction, source_parameter) radial = _tuple_sub(_point_tuple(source), _point_tuple(source_axis)) unit = _tuple_normalized(radial) if unit is None: raise ValueError("Could not derive slot sector radial direction.") return ( axis_start_tuple[0] + unit[0] * float(radius), axis_start_tuple[1] + unit[1] * float(radius), axis_start_tuple[2] + unit[2] * float(radius), ) height_vec = gp_Vec(direction.X() * height, direction.Y() * height, direction.Z() * height) try: start_tuple = radial_point(u_start) mid_tuple = radial_point((u_start + u_end) * 0.5) end_tuple = radial_point(u_end) center_point = gp_Pnt(*axis_start_tuple) start_point = gp_Pnt(*start_tuple) mid_point = gp_Pnt(*mid_tuple) end_point = gp_Pnt(*end_tuple) center_to_start = BRepBuilderAPI_MakeEdge(center_point, start_point).Edge() arc = GC_MakeArcOfCircle(start_point, mid_point, end_point).Value() arc_edge = BRepBuilderAPI_MakeEdge(arc).Edge() end_to_center = BRepBuilderAPI_MakeEdge(end_point, center_point).Edge() wire = BRepBuilderAPI_MakeWire(center_to_start, arc_edge, end_to_center).Wire() tool_face = _finalize_builder_result(BRepBuilderAPI_MakeFace(wire), "slot sector circular profile") return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, height_vec), "slot sector prism") except Exception: # Fall back to the older sampled polygon path for unusual parameterizations. # The circular path is preferred because it preserves a real cylindrical # wall after Cut/Fuse; the fallback keeps the edit attempt available. pass sample_count = max(8, min(96, int(abs(u_end - u_start) / (math.pi / 36.0)) + 2)) points: list[tuple[float, float, float]] = [axis_start_tuple] for index in range(sample_count): u = u_start + (u_end - u_start) * index / max(sample_count - 1, 1) points.append(radial_point(u)) tool_face = self._make_local_polygon_face(points) return _finalize_builder_result(BRepPrimAPI_MakePrism(tool_face, height_vec), "slot sector prism") def move_cylindrical_slot_axis( self, face_id: int, target_center: tuple[float, float, float], ) -> str: plan = self.cylindrical_slot_axis_move_plan(face_id, target_center) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") old_part_shape = part.shape verification: dict[str, object] = {} artifact_cleanup: dict[str, object] = {"discarded_count": 0, "discarded_volume": 0.0} try: if str(plan.get("resize_strategy", "")) == "paired-obround-slot-axis-prism": filler = self._slot_capsule_prism_tool(plan, self._slot_fill_radius(plan)) fuse = BRepAlgoAPI_Fuse(part.shape, filler) filled_shape = _finalize_boolean_result(fuse, "obround slot axis move fill/fuse", use_glue=False) target_plan = dict(plan) target_plan["slot_capsule_start_center_1"] = plan.get("slot_capsule_target_start_center_1") target_plan["slot_capsule_start_center_2"] = plan.get("slot_capsule_target_start_center_2") cutter = self._slot_capsule_prism_tool(target_plan, float(plan["slot_target_diameter"]) * 0.5) op = BRepAlgoAPI_Cut(filled_shape, cutter) result = _finalize_boolean_result(op, "obround slot axis move cut") else: filler = self._slot_sector_prism_tool( face_id, self._slot_fill_radius(plan), float(plan["fill_start_parameter"]), float(plan["fill_end_parameter"]), ) fuse = BRepAlgoAPI_Fuse(part.shape, filler) filled_shape = _finalize_boolean_result(fuse, "slot axis move old-sector fill/fuse", use_glue=False) movement = _tuple_or_none(plan.get("axis_move_vector")) if movement is None: raise ValueError("Could not derive slot axis movement vector.") cutter = self._slot_sector_prism_tool( face_id, float(plan["slot_target_diameter"]) * 0.5, float(plan["cutter_start_parameter"]), float(plan["cutter_end_parameter"]), axis_point_offset=movement, ) op = BRepAlgoAPI_Cut(filled_shape, cutter) result = _finalize_boolean_result(op, "slot axis move target-sector cut") result = _prepare_shape_for_step_export(result) before_solids = _topology_shape_count(old_part_shape, TopAbs_SOLID) after_solids = _topology_shape_count(result, TopAbs_SOLID) if before_solids == 1 and after_solids > 1: result, artifact_cleanup = self._drop_tiny_artifact_solids(result, old_part_shape) after_solids = _topology_shape_count(result, TopAbs_SOLID) if before_solids and after_solids != before_solids: raise RuntimeError( "槽/半孔轴心移动结果改变了 Solid 数量,当前版本已回滚," "避免把一个实体拆成多个独立实体。请改用“轴心(缩放特征)”或槽孔总长度/槽宽等更稳定参数。" ) part.shape = result self.refresh_topology() if str(plan.get("resize_strategy", "")) == "paired-obround-slot-axis-prism": verification = self._verify_obround_slot_axis_move_result(plan, part_id) else: verification_plan = dict(plan) verification_plan["cutter_axis_point"] = plan["target_cutter_axis_point"] verification_plan["cutter_start_point"] = plan["target_cutter_start_point"] verification = self._verify_slot_angular_span_result(verification_plan, part_id) if not verification["matched"]: detail = str(verification.get("detail", "slot axis move result verification failed")) raise RuntimeError( "槽/半孔轴心坐标布尔计算返回了结果,但结果里没有检测到目标轴心位置的槽面," "已回滚到修改前状态。" f"{detail}" ) except Exception: part.shape = old_part_shape self.refresh_topology() raise return ( "Cylindrical slot axis move completed: " f"face {face_id}, " f"center={plan.get('current_axis_center')}->{plan.get('target_axis_center')}, " f"move={plan.get('axis_move_vector')}, " f"diameter={float(plan['slot_target_diameter']):g}, " f"span={float(plan['slot_target_angular_span']):g}, " f"height={float(plan['cutter_height']):g}, " f"discarded_tiny_solids={int(artifact_cleanup.get('discarded_count', 0))}, " f"discarded_tiny_volume={float(artifact_cleanup.get('discarded_volume', 0.0)):g}, " f"risk={plan['risk']}, " f"verified_face={verification.get('face_id', '')}." ) def resize_cylindrical_hole(self, face_id: int, new_diameter: float) -> str: plan = self.cylindrical_resize_plan(face_id, new_diameter) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: raise ValueError("Hole resize currently supports cylindrical faces only.") cyl = surf.Cylinder() old_radius = cyl.Radius() new_radius = new_diameter / 2.0 if new_radius <= 0: raise ValueError("Target diameter must be greater than 0.") part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") direction = cyl.Axis().Direction() old_part_shape = part.shape source_shape = part.shape if plan["resize_mode"] == "shrink": fill_start = gp_Pnt(*plan["fill_start_point"]) fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) filler = BRepPrimAPI_MakeCylinder( fill_axis, float(plan["fill_radius"]), float(plan["fill_height"]), ).Shape() fuse = BRepAlgoAPI_Fuse(part.shape, filler) source_shape = _finalize_boolean_result(fuse, "cylinder fill/fuse", use_glue=False) cutter_start = gp_Pnt(*plan["cutter_start_point"]) cutter_axis = gp_Ax2(cutter_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) cutter = BRepPrimAPI_MakeCylinder(cutter_axis, new_radius, float(plan["cutter_height"])).Shape() op = BRepAlgoAPI_Cut(source_shape, cutter) result = _finalize_boolean_result(op, "cylinder cut") part.shape = result self.refresh_topology() verification = self._verify_cylindrical_resize_result(plan, part_id) if not verification["matched"]: part.shape = old_part_shape self.refresh_topology() detail = str(verification.get("detail", "")) raise RuntimeError( "孔/圆柱直径布尔计算返回了结果,但结果里没有检测到目标直径的圆柱面," "已回滚到修改前状态。" f"{detail}" ) action = "enlarged by bounded cut" if plan["resize_mode"] == "enlarge" else "shrunk by fill and recut" return ( f"Cylindrical resize completed: diameter {old_radius * 2.0:g} -> {new_diameter:g}, " f"mode={plan['resize_mode']}, action={action}, " f"risk={plan['risk']}, feature={plan['feature_guess']}, " f"cutter={plan['cutter_strategy']}, height={float(plan['cutter_height']):g}, " f"verified_face={verification.get('face_id', '')}." ) def move_cylindrical_hole_axis( self, face_id: int, target_center: tuple[float, float, float], ) -> str: plan = self.cylindrical_axis_move_plan(face_id, target_center) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") direction = gp_Dir(*plan["cutter_axis_direction"]) old_part_shape = part.shape try: fill_start = gp_Pnt(*plan["fill_start_point"]) fill_axis = gp_Ax2(fill_start, direction) filler = BRepPrimAPI_MakeCylinder( fill_axis, float(plan["fill_radius"]), float(plan["fill_height"]), ).Shape() fuse = BRepAlgoAPI_Fuse(part.shape, filler) filled_shape = _finalize_boolean_result(fuse, "cylinder axis move old-hole fill/fuse", use_glue=False) cutter_start = gp_Pnt(*plan["target_cutter_start_point"]) cutter_axis = gp_Ax2(cutter_start, direction) cutter = BRepPrimAPI_MakeCylinder( cutter_axis, float(plan["target_radius"]), float(plan["cutter_height"]), ).Shape() op = BRepAlgoAPI_Cut(filled_shape, cutter) result = _finalize_boolean_result(op, "cylinder axis move target-hole cut") part.shape = result self.refresh_topology() verification_plan = dict(plan) verification_plan["cutter_axis_point"] = plan["target_cutter_axis_point"] verification_plan["cutter_start_point"] = plan["target_cutter_start_point"] verification = self._verify_cylindrical_resize_result(verification_plan, part_id) if not verification["matched"]: raise RuntimeError(str(verification.get("detail", "cylinder axis move result verification failed"))) except Exception: part.shape = old_part_shape self.refresh_topology() raise return ( "Cylindrical hole axis move completed: " f"face {face_id}, " f"center={plan.get('current_axis_center')}->{plan.get('target_axis_center')}, " f"move={plan.get('axis_move_vector')}, " f"diameter={float(plan['target_diameter']):g}, " f"height={float(plan['cutter_height']):g}, " f"risk={plan['risk']}, " f"verified_face={verification.get('face_id', '')}." ) def _cylindrical_result_first_level_minimums(self, plan: dict[str, object] | None) -> dict[str, int]: if not isinstance(plan, dict): return {} span = ( _float_or_none(plan.get("slot_angular_span")) or _float_or_none(plan.get("slot_target_angular_span")) or _float_or_none(plan.get("angular_span")) ) is_partial = bool( str(plan.get("slot_kind") or "") or str(plan.get("slot_resize_mode") or "").startswith("slot") or (span is not None and span < math.tau * 0.92) ) stable_floor = 4 if is_partial else 2 minimums: dict[str, int] = {} count_keys = { "cylindrical_feature_boundary_edge_count": "boundary_edge_count", "cylindrical_feature_boundary_vertex_count": "boundary_vertex_count", "cylindrical_feature_adjacent_face_count": "adjacent_face_count", } for plan_key, summary_key in count_keys.items(): value = _int_or_none(plan.get(plan_key)) if value is None or value <= 0: continue minimums[summary_key] = max(1, min(int(value), stable_floor)) return minimums def _attach_cylindrical_first_level_result_check( self, candidate: dict[str, object], plan: dict[str, object] | None = None, ) -> dict[str, object]: if not candidate.get("matched"): return candidate face_ids: list[int] = [] for key in ("face_id", "paired_face_id"): face_id = _int_or_none(candidate.get(key)) if face_id is not None and face_id not in face_ids: face_ids.append(face_id) if not face_ids: return { **candidate, "matched": False, "first_level_topology_matched": False, "detail": " Result verification matched geometry but did not identify a Face ID for first-level topology.", } summaries: list[dict[str, object]] = [] for face_id in face_ids: try: topology = self.cylindrical_feature_first_level_topology(face_id) side_face_count = int(topology.get("cylindrical_feature_side_face_count", 0) or 0) boundary_edge_count = int(topology.get("cylindrical_feature_boundary_edge_count", 0) or 0) boundary_vertex_count = int(topology.get("cylindrical_feature_boundary_vertex_count", 0) or 0) adjacent_face_count = int(topology.get("cylindrical_feature_adjacent_face_count", 0) or 0) end_face_count = int(topology.get("cylindrical_feature_end_face_count", 0) or 0) bottom_face_count = int(topology.get("cylindrical_feature_bottom_face_count", 0) or 0) opening_face_count = int(topology.get("cylindrical_feature_opening_face_count", 0) or 0) slot_boundary_face_count = int(topology.get("cylindrical_feature_slot_boundary_face_count", 0) or 0) except Exception as exc: return { **candidate, "matched": False, "first_level_topology_matched": False, "detail": f" Result Face {face_id} matched target geometry but first-level topology failed: {exc}", } summaries.append( { "face_id": face_id, "side_face_count": side_face_count, "boundary_edge_count": boundary_edge_count, "boundary_vertex_count": boundary_vertex_count, "adjacent_face_count": adjacent_face_count, "end_face_count": end_face_count, "bottom_face_count": bottom_face_count, "opening_face_count": opening_face_count, "slot_boundary_face_count": slot_boundary_face_count, } ) if side_face_count <= 0 or boundary_edge_count <= 0 or adjacent_face_count <= 0: return { **candidate, "matched": False, "first_level_topology_matched": False, "first_level_topology_summaries": tuple(summaries), "detail": ( f" Result Face {face_id} matched target geometry but lost first-level topology " f"(side={side_face_count}, boundary_edges={boundary_edge_count}, " f"adjacent_faces={adjacent_face_count})." ), } minimums = self._cylindrical_result_first_level_minimums(plan) under_minimum: list[str] = [] summary_values = summaries[-1] for key, minimum in minimums.items(): actual = int(summary_values.get(key, 0) or 0) if actual < minimum: under_minimum.append(f"{key}={actual} < {minimum}") if under_minimum: return { **candidate, "matched": False, "first_level_topology_matched": False, "first_level_topology_summaries": tuple(summaries), "first_level_topology_minimums": dict(minimums), "detail": ( f" Result Face {face_id} matched target geometry but its first-level topology is weaker " f"than the edit plan requires ({'; '.join(under_minimum)})." ), } return { **candidate, "first_level_topology_matched": True, "first_level_topology_summaries": tuple(summaries), "first_level_topology_minimums": self._cylindrical_result_first_level_minimums(plan), } def _verify_cylindrical_resize_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: target_diameter = float(plan.get("target_diameter", 0.0)) target_radius = target_diameter / 2.0 if target_radius <= 1e-9: return {"matched": False, "detail": " 目标直径无效。"} try: axis_point = gp_Pnt(*plan["cutter_axis_point"]) axis_direction = gp_Dir(*plan["cutter_axis_direction"]) except Exception: return {"matched": False, "detail": " 缺少原孔轴信息,无法确认结果。"} part = self.part_by_id(part_id) diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_radius, 1.0) radius_tolerance = max(target_radius * 0.02, diagonal * 1e-6, 1e-5) axis_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4) best: dict[str, object] | None = None best_score = math.inf for face_id, face in enumerate(self.faces): if self.face_part_ids[face_id] != part_id: continue try: surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: continue cyl = surf.Cylinder() candidate_radius = float(cyl.Radius()) radius_error = abs(candidate_radius - target_radius) axis_dot = abs(_direction_dot(axis_direction, cyl.Axis().Direction())) if axis_dot < 1.0 - 1e-5: continue axis_distance = _point_axis_distance(axis_point, axis_direction, cyl.Axis().Location()) except Exception: continue score = radius_error / max(radius_tolerance, 1e-9) + axis_distance / max(axis_tolerance, 1e-9) candidate = { "matched": radius_error <= radius_tolerance and axis_distance <= axis_tolerance, "face_id": face_id, "diameter": candidate_radius * 2.0, "radius_error": radius_error, "axis_distance": axis_distance, "radius_tolerance": radius_tolerance, "axis_tolerance": axis_tolerance, } if candidate["matched"]: return self._attach_cylindrical_first_level_result_check(candidate, plan) if score < best_score: best_score = score best = candidate if best is None: return {"matched": False, "detail": " 未找到同零件内与原孔轴平行的圆柱面。"} return { "matched": False, "detail": ( f" 最近候选 Face {best['face_id']} 的直径约 {float(best['diameter']):.6g}," f"目标直径 {target_diameter:.6g}," f"半径误差 {float(best['radius_error']):.6g}," f"轴距 {float(best['axis_distance']):.6g}。" ), **best, } def _verify_cylindrical_height_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: target_height = _float_or_none(plan.get("target_height")) target_radius = _float_or_none(plan.get("radius")) diameter = _float_or_none(plan.get("diameter")) if target_radius is None and diameter is not None: target_radius = diameter * 0.5 axis_direction_tuple = _tuple_normalized( _tuple_or_none(plan.get("axis")) or _tuple_or_none(plan.get("affine_axis_direction")) ) axis_point_tuple = ( _tuple_or_none(plan.get("axis_point")) or _tuple_or_none(plan.get("affine_axis_point")) or _tuple_or_none(plan.get("scale_center")) ) if ( target_height is None or target_height <= 1e-9 or target_radius is None or target_radius <= 1e-9 or axis_direction_tuple is None or axis_point_tuple is None ): return {"matched": False, "detail": " Missing cylindrical height verification data."} target_axis_direction = gp_Dir(*axis_direction_tuple) target_axis_point = gp_Pnt(*axis_point_tuple) part = self.part_by_id(part_id) diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_height, target_radius, 1.0) height_tolerance = max(target_height * 0.005, diagonal * 1e-5, 1e-4) radius_tolerance = max(target_radius * 0.02, diagonal * 1e-6, 1e-5) axis_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4) best: dict[str, object] | None = None best_score = math.inf for candidate_face_id, face in enumerate(self.faces): if self.face_part_ids[candidate_face_id] != part_id: continue try: surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: continue cyl = surf.Cylinder() candidate_radius = float(cyl.Radius()) radius_error = abs(candidate_radius - target_radius) axis_dot = abs(_direction_dot(target_axis_direction, cyl.Axis().Direction())) if axis_dot < 1.0 - 1e-5: continue axis_distance = _point_axis_distance(target_axis_point, target_axis_direction, cyl.Axis().Location()) if axis_distance > axis_tolerance: continue try: feature = self.feature_info(candidate_face_id) except Exception: feature = {} axis_range = self._cylindrical_axis_range( candidate_face_id, surf, _int_values(feature.get("feature_side_face_ids")), ) candidate_height = float(axis_range.get("span", 0.0)) height_error = abs(candidate_height - target_height) except Exception: continue score = ( height_error / max(height_tolerance, 1e-9) + radius_error / max(radius_tolerance, 1e-9) + axis_distance / max(axis_tolerance, 1e-9) ) candidate = { "matched": ( height_error <= height_tolerance and radius_error <= radius_tolerance and axis_distance <= axis_tolerance ), "face_id": candidate_face_id, "height": candidate_height, "target_height": target_height, "height_error": height_error, "height_tolerance": height_tolerance, "radius": candidate_radius, "target_radius": target_radius, "radius_error": radius_error, "radius_tolerance": radius_tolerance, "axis_distance": axis_distance, "axis_tolerance": axis_tolerance, } if candidate["matched"]: return self._attach_cylindrical_first_level_result_check(candidate, plan) if score < best_score: best_score = score best = candidate if best is None: return {"matched": False, "detail": " No same-axis cylindrical Face was found after height edit."} return { "matched": False, "detail": ( f" Closest cylindrical Face {best['face_id']} height {float(best['height']):.6g}, " f"target height {target_height:.6g}, height error {float(best['height_error']):.6g}, " f"radius {float(best['radius']):.6g}, target radius {target_radius:.6g}, " f"axis distance {float(best['axis_distance']):.6g}." ), **best, } def _verify_axis_height_span_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: target_height = _float_or_none(plan.get("target_height")) axis_direction_tuple = _tuple_normalized( _tuple_or_none(plan.get("affine_axis_direction")) or _tuple_or_none(plan.get("axis")) ) axis_point_tuple = ( _tuple_or_none(plan.get("affine_axis_point")) or _tuple_or_none(plan.get("scale_center")) or _tuple_or_none(plan.get("axis_point")) ) if target_height is None or target_height <= 1e-9 or axis_direction_tuple is None or axis_point_tuple is None: return {"matched": False, "detail": " Missing axis span verification data."} shape: TopoDS_Shape | None = None target_kind = str(plan.get("affine_target_kind") or plan.get("target_kind") or "part") solid_id = _int_or_none(plan.get("solid_id")) if ( target_kind == "solid" and solid_id is not None and 0 <= solid_id < len(self.solids) and int(self.solids[solid_id][0]) == int(part_id) ): shape = self.solids[solid_id][1] if shape is None: part = self.part_by_id(part_id) shape = part.shape if part is not None else None if shape is None: return {"matched": False, "detail": " Could not find edited part or solid for axis span verification."} axis_point = gp_Pnt(*axis_point_tuple) axis_direction = gp_Dir(*axis_direction_tuple) try: axis_interval = _shape_axis_interval(shape, axis_point, axis_direction) except Exception as exc: return {"matched": False, "detail": f" Could not measure edited axis span: {exc}"} actual_height = max(float(axis_interval[1]) - float(axis_interval[0]), 0.0) diagonal = max(_shape_diagonal(shape), target_height, 1.0) tolerance = max(target_height * 0.005, diagonal * 1e-5, 1e-4) error = abs(actual_height - target_height) return { "matched": error <= tolerance, "face_id": _int_or_none(plan.get("face_id")), "axis_span": actual_height, "target_height": target_height, "height_error": error, "height_tolerance": tolerance, "axis_interval": axis_interval, "target_kind": target_kind, "detail": ( f" Axis span {actual_height:.6g}, target height {target_height:.6g}, " f"height error {error:.6g}." ), } def _verify_cylindrical_depth_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: target_depth = _float_or_none(plan.get("target_depth")) target_diameter = _float_or_none(plan.get("diameter") or plan.get("target_diameter")) axis_direction_tuple = _tuple_normalized(_tuple_or_none(plan.get("depth_axis_direction"))) open_point_tuple = _tuple_or_none(plan.get("depth_open_point")) if ( target_depth is None or target_depth <= 1e-9 or target_diameter is None or target_diameter <= 1e-9 or axis_direction_tuple is None or open_point_tuple is None ): return {"matched": False, "detail": " Missing blind-depth verification data."} target_radius = target_diameter * 0.5 target_axis_direction = gp_Dir(*axis_direction_tuple) target_open_point = gp_Pnt(*open_point_tuple) part = self.part_by_id(part_id) diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_depth, target_radius, 1.0) radius_tolerance = max(target_radius * 0.03, diagonal * 1e-6, 1e-5) axis_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4) depth_tolerance = max(target_depth * 0.03, diagonal * 1e-5, 1e-4) best: dict[str, object] | None = None best_score = math.inf for candidate_face_id, face in enumerate(self.faces): if self.face_part_ids[candidate_face_id] != part_id: continue try: surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: continue cyl = surf.Cylinder() candidate_radius = float(cyl.Radius()) radius_error = abs(candidate_radius - target_radius) axis_dot = abs(_direction_dot(target_axis_direction, cyl.Axis().Direction())) if axis_dot < 1.0 - 1e-5: continue axis_distance = _point_axis_distance(cyl.Axis().Location(), cyl.Axis().Direction(), target_open_point) if axis_distance > axis_tolerance: continue feature = self.feature_info(candidate_face_id) bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ()) or ()) if not bottom_face_ids: continue depth_plan = self.cylindrical_depth_plan( candidate_face_id, target_depth, bottom_face_id=int(bottom_face_ids[0]), ) candidate_depth = _float_or_none(depth_plan.get("current_depth")) if candidate_depth is None or candidate_depth <= 1e-9: continue depth_error = abs(candidate_depth - target_depth) except Exception: continue score = ( radius_error / max(radius_tolerance, 1e-9) + axis_distance / max(axis_tolerance, 1e-9) + depth_error / max(depth_tolerance, 1e-9) ) candidate = { "matched": ( radius_error <= radius_tolerance and axis_distance <= axis_tolerance and depth_error <= depth_tolerance ), "face_id": candidate_face_id, "depth": candidate_depth, "target_depth": target_depth, "depth_error": depth_error, "depth_tolerance": depth_tolerance, "diameter": candidate_radius * 2.0, "radius_error": radius_error, "axis_distance": axis_distance, "radius_tolerance": radius_tolerance, "axis_tolerance": axis_tolerance, } if candidate["matched"]: return self._attach_cylindrical_first_level_result_check(candidate, plan) if score < best_score: best_score = score best = candidate if best is None: return {"matched": False, "detail": " No measurable blind cylindrical feature remained after depth edit."} return { "matched": False, "detail": ( f" Closest blind cylindrical Face {best['face_id']} depth {float(best['depth']):.6g}, " f"target {target_depth:.6g}, depth error {float(best['depth_error']):.6g}, " f"diameter {float(best['diameter']):.6g}." ), **best, } def _verify_cylindrical_suppress_result(self, plan: dict[str, object], part_id: int) -> dict[str, object]: target_radius = _float_or_none(plan.get("radius")) if target_radius is None: target_diameter = _float_or_none(plan.get("diameter")) target_radius = target_diameter * 0.5 if target_diameter is not None else None axis_point_tuple = _tuple_or_none(plan.get("axis_point")) axis_direction_tuple = _tuple_normalized(_tuple_or_none(plan.get("axis"))) fill_start_tuple = _tuple_or_none(plan.get("fill_start_point")) fill_height = _float_or_none(plan.get("fill_height")) if ( target_radius is None or target_radius <= 1e-9 or axis_point_tuple is None or axis_direction_tuple is None or fill_start_tuple is None or fill_height is None or fill_height <= 1e-9 ): return {"matched": False, "detail": " Missing cylindrical suppress verification data."} axis_point = gp_Pnt(*axis_point_tuple) axis_direction = gp_Dir(*axis_direction_tuple) fill_start = gp_Pnt(*fill_start_tuple) fill_start_parameter = _axis_parameter(axis_point, axis_direction, fill_start) fill_min = min(fill_start_parameter, fill_start_parameter + fill_height) fill_max = max(fill_start_parameter, fill_start_parameter + fill_height) part = self.part_by_id(part_id) diagonal = max(_shape_diagonal(part.shape) if part is not None else 0.0, target_radius, fill_height, 1.0) radius_tolerance = max(target_radius * 0.03, diagonal * 1e-6, 1e-5) axis_tolerance = max(target_radius * 0.08, diagonal * 1e-5, 1e-4) overlap_tolerance = max(fill_height * 0.05, diagonal * 1e-5, 1e-4) remaining: list[dict[str, object]] = [] for face_id, face in enumerate(self.faces): if self.face_part_ids[face_id] != part_id: continue try: surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: continue cyl = surf.Cylinder() candidate_radius = float(cyl.Radius()) radius_error = abs(candidate_radius - target_radius) if radius_error > radius_tolerance: continue axis_dot = abs(_direction_dot(axis_direction, cyl.Axis().Direction())) if axis_dot < 1.0 - 1e-5: continue axis_distance = _point_axis_distance(axis_point, axis_direction, cyl.Axis().Location()) if axis_distance > axis_tolerance: continue axis_range = self._cylindrical_axis_range(face_id, surf) candidate_start = _point_on_axis(cyl.Axis().Location(), cyl.Axis().Direction(), float(axis_range["v_min"])) candidate_end = _point_on_axis(cyl.Axis().Location(), cyl.Axis().Direction(), float(axis_range["v_max"])) candidate_min = min( _axis_parameter(axis_point, axis_direction, candidate_start), _axis_parameter(axis_point, axis_direction, candidate_end), ) candidate_max = max( _axis_parameter(axis_point, axis_direction, candidate_start), _axis_parameter(axis_point, axis_direction, candidate_end), ) overlap = max(0.0, min(fill_max, candidate_max) - max(fill_min, candidate_min)) if overlap <= overlap_tolerance: continue feature_guess = str(self.face_info(face_id).get("feature_guess") or "") except Exception: continue remaining.append( { "face_id": face_id, "diameter": candidate_radius * 2.0, "radius_error": radius_error, "axis_distance": axis_distance, "axis_overlap": overlap, "feature_guess": feature_guess, } ) if remaining: first = remaining[0] return { "matched": False, "suppressed": False, "remaining_face_ids": tuple(int(item["face_id"]) for item in remaining), "remaining_count": len(remaining), "detail": ( f" Suppress result still contains {len(remaining)} same-axis cylindrical Face(s) " f"in the filled range; first Face {first['face_id']} diameter {float(first['diameter']):.6g}, " f"axis overlap {float(first['axis_overlap']):.6g}." ), } return { "matched": True, "suppressed": True, "remaining_face_ids": (), "remaining_count": 0, } def resize_cylindrical_boss(self, face_id: int, new_diameter: float) -> str: plan = self.cylindrical_boss_resize_plan(face_id, new_diameter) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") start = gp_Pnt(*plan["boss_tool_start_point"]) direction = gp_Dir(*plan["boss_tool_axis_direction"]) axis = gp_Ax2(start, direction) height = float(plan["boss_tool_height"]) if plan["resize_mode"] == "enlarge": tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape() op = BRepAlgoAPI_Fuse(part.shape, tool) result = _finalize_boolean_result(op, "cylindrical boss fuse", use_glue=False) action = "enlarged by bounded fuse" else: removal = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape() replacement = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape() remove_op = BRepAlgoAPI_Cut(part.shape, removal) removed = _finalize_boolean_result(remove_op, "cylindrical boss shrink remove envelope", use_glue=False) if _topology_shape_count(removed, TopAbs_FACE) == 0: exact_start = gp_Pnt(*plan["boss_tool_exact_start_point"]) exact_axis = gp_Ax2(exact_start, direction) exact_replacement = BRepPrimAPI_MakeCylinder( exact_axis, float(plan["boss_tool_inner_radius"]), float(plan["boss_tool_exact_height"]), ).Shape() result = _ensure_valid_or_repaired_shape(exact_replacement, "cylindrical boss shrink replacement") else: fuse_op = BRepAlgoAPI_Fuse(removed, replacement) result = _finalize_boolean_result(fuse_op, "cylindrical boss shrink rebuild", use_glue=False) action = "shrunk by removing old envelope and fusing target cylinder" part.shape = result self.refresh_topology() return ( f"Cylindrical boss resize completed: diameter {float(plan['current_diameter']):g} -> {new_diameter:g}, " f"mode={plan['resize_mode']}, action={action}, risk={plan['risk']}, " f"feature={plan['feature_guess']}, tool={plan['boss_tool_strategy']}, " f"height={float(plan['boss_tool_height']):g}." ) def move_cylindrical_boss_axis( self, face_id: int, target_center: tuple[float, float, float], ) -> str: plan = self.cylindrical_boss_axis_move_plan(face_id, target_center) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") old_part_shape = part.shape verification: dict[str, object] = {} try: direction = gp_Dir(*plan["boss_tool_axis_direction"]) height = float(plan["boss_tool_height"]) removal_start = gp_Pnt(*plan["boss_tool_start_point"]) removal_axis = gp_Ax2(removal_start, direction) removal_radius = float( plan.get("boss_tool_outer_radius") or plan.get("boss_tool_old_radius") or plan.get("target_radius") ) removal = BRepPrimAPI_MakeCylinder(removal_axis, removal_radius, height).Shape() remove_op = BRepAlgoAPI_Cut(part.shape, removal) removed = _finalize_boolean_result(remove_op, "cylindrical boss axis move remove old envelope", use_glue=False) target_start_values = _tuple_or_none(plan.get("target_boss_tool_start_point")) target_height = height if _topology_shape_count(removed, TopAbs_FACE) == 0: exact_start = _tuple_or_none(plan.get("target_boss_tool_exact_start_point")) if exact_start is not None: target_start_values = exact_start target_height = float(plan.get("boss_tool_exact_height") or height) if target_start_values is None: raise ValueError("Could not derive target boss tool start point.") target_start = gp_Pnt(*target_start_values) target_axis = gp_Ax2(target_start, direction) replacement = BRepPrimAPI_MakeCylinder( target_axis, float(plan["target_boss_tool_radius"]), max(target_height, 1e-6), ).Shape() if _topology_shape_count(removed, TopAbs_FACE) == 0: result = _ensure_valid_or_repaired_shape(replacement, "cylindrical boss axis move replacement") action = "rebuilt moved cylinder" else: fuse_op = BRepAlgoAPI_Fuse(removed, replacement) result = _finalize_boolean_result(fuse_op, "cylindrical boss axis move fuse target cylinder", use_glue=False) action = "removed old envelope and fused moved cylinder" part.shape = result self.refresh_topology() verification_plan = dict(plan) verification_plan["cutter_axis_point"] = plan["target_boss_tool_axis_point"] verification_plan["cutter_axis_direction"] = plan["boss_tool_axis_direction"] verification = self._verify_cylindrical_resize_result(verification_plan, part_id) if not verification["matched"]: detail = str(verification.get("detail", "cylindrical boss axis move result verification failed")) raise RuntimeError( "圆柱凸台轴心坐标布尔计算返回了结果,但结果里没有检测到目标轴心位置的圆柱凸台," "已回滚到修改前状态。" f"{detail}" ) except Exception: part.shape = old_part_shape self.refresh_topology() raise return ( "Cylindrical boss axis move completed: " f"face {face_id}, " f"center={plan.get('current_axis_center')}->{plan.get('target_axis_center')}, " f"move={plan.get('axis_move_vector')}, " f"diameter={float(plan['target_diameter']):g}, " f"height={float(plan['boss_tool_height']):g}, " f"risk={plan['risk']}, action={action}, " f"verified_face={verification.get('face_id', '')}." ) def suppress_cylindrical_hole(self, face_id: int) -> str: plan = self.cylindrical_suppress_plan(face_id) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: raise ValueError("Cylinder suppress currently supports cylindrical faces only.") direction = surf.Cylinder().Axis().Direction() fill_start = gp_Pnt(*plan["fill_start_point"]) fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) filler = BRepPrimAPI_MakeCylinder( fill_axis, float(plan["fill_radius"]), float(plan["fill_height"]), ).Shape() fuse = BRepAlgoAPI_Fuse(part.shape, filler) result = _finalize_boolean_result(fuse, "cylinder suppress/fill", use_glue=False) part.shape = result self.refresh_topology() return ( f"Cylindrical hole suppress completed: face {face_id}, " f"diameter={float(plan['diameter']):g}, " f"height={float(plan['fill_height']):g}, " f"risk={plan['risk']}, feature={plan['feature_guess']}." ) def resize_cylindrical_depth( self, face_id: int, target_depth: float, bottom_face_id: int | None = None, ) -> str: plan = self.cylindrical_depth_plan( face_id, target_depth, bottom_face_id=bottom_face_id, ) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") old_part_shape = part.shape verification: dict[str, object] = {} try: start = gp_Pnt(*plan["depth_tool_start_point"]) direction = gp_Dir(*plan["depth_axis_direction"]) axis = gp_Ax2(start, direction) tool = BRepPrimAPI_MakeCylinder( axis, float(plan["depth_tool_radius"]), float(plan["depth_tool_height"]), ).Shape() if plan["depth_mode"] == "deepen": op = BRepAlgoAPI_Cut(part.shape, tool) result = _finalize_boolean_result(op, "blind depth cut") action = "deepened by bounded cut" else: op = BRepAlgoAPI_Fuse(part.shape, tool) result = _finalize_boolean_result(op, "blind depth fill/fuse", use_glue=False) action = "made shallower by bounded fill" part.shape = result self.refresh_topology() verification = self._verify_cylindrical_depth_result(plan, part_id) if not verification["matched"]: detail = str(verification.get("detail", "blind depth result verification failed")) raise RuntimeError( "盲孔/盲槽深度布尔计算返回了结果,但没有检测到达到目标深度且保留一级关系的圆柱特征," "已回滚到修改前状态。" f"{detail}" ) except Exception: part.shape = old_part_shape self.refresh_topology() raise return ( f"Blind cylindrical depth completed: depth {float(plan['current_depth']):g} -> {target_depth:g}, " f"mode={plan['depth_mode']}, action={action}, " f"risk={plan['risk']}, feature={plan['feature_guess']}, " f"tool={plan['depth_tool_strategy']}, height={float(plan['depth_tool_height']):g}, " f"verified_face={verification.get('face_id', '')}." )