from __future__ import annotations import math from typing import Iterable from OCC.Core.BRep import BRep_Tool from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Defeaturing from OCC.Core.BRepBndLib import brepbndlib from OCC.Core.BOPAlgo import BOPAlgo_GlueFull from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_GTransform, BRepBuilderAPI_Transform from OCC.Core.BRepCheck import BRepCheck_Analyzer from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier from OCC.Core.BRepGProp import brepgprop from OCC.Core.Bnd import Bnd_Box from OCC.Core.GeomAbs import ( GeomAbs_Circle, GeomAbs_Cylinder, GeomAbs_Line, GeomAbs_Plane, ) from OCC.Core.GProp import GProp_GProps from OCC.Core.ShapeFix import ShapeFix_Shape from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain from OCC.Core.TopAbs import ( TopAbs_EDGE, TopAbs_FACE, TopAbs_IN, TopAbs_OUT, TopAbs_REVERSED, TopAbs_SOLID, ) from OCC.Core.TopExp import TopExp_Explorer from OCC.Core.TopLoc import TopLoc_Location from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape from OCC.Core.gp import gp_Ax1, gp_Dir, gp_GTrsf, gp_Pnt, gp_Trsf, gp_Vec, gp_XYZ from OCC.Extend.TopologyUtils import TopologyExplorer from .constants import CURVE_TYPES, ORIENTATION_TYPES def _compound_from_shapes(shapes: Iterable[TopoDS_Shape]) -> TopoDS_Shape: from OCC.Core.BRep import BRep_Builder valid_shapes = [shape for shape in shapes if not shape.IsNull()] if len(valid_shapes) == 1: return valid_shapes[0] compound = TopoDS_Compound() builder = BRep_Builder() builder.MakeCompound(compound) for shape in valid_shapes: builder.Add(compound, shape) return compound def _explore(shape: TopoDS_Shape, shape_type: int) -> list[TopoDS_Shape]: items: list[TopoDS_Shape] = [] explorer = TopExp_Explorer(shape, shape_type) while explorer.More(): current = explorer.Current() if shape_type == TopAbs_FACE: items.append(topods.Face(current)) elif shape_type == TopAbs_EDGE: items.append(topods.Edge(current)) elif shape_type == TopAbs_SOLID: items.append(topods.Solid(current)) else: items.append(current) explorer.Next() return items def _same_shape(left: TopoDS_Shape, right: TopoDS_Shape) -> bool: try: return bool(left.IsSame(right)) except Exception: return False def _surfaces_are_coplanar(left: BRepAdaptor_Surface, right: BRepAdaptor_Surface, tolerance: float) -> bool: if left.GetType() != GeomAbs_Plane or right.GetType() != GeomAbs_Plane: return False left_plane = left.Plane() right_plane = right.Plane() left_dir = left_plane.Axis().Direction() right_dir = right_plane.Axis().Direction() dot = abs( left_dir.X() * right_dir.X() + left_dir.Y() * right_dir.Y() + left_dir.Z() * right_dir.Z() ) if dot < 1.0 - 1e-7: return False left_point = left_plane.Location() right_point = right_plane.Location() distance = abs( (right_point.X() - left_point.X()) * left_dir.X() + (right_point.Y() - left_point.Y()) * left_dir.Y() + (right_point.Z() - left_point.Z()) * left_dir.Z() ) return distance <= tolerance def _surfaces_are_cocylindrical(left: BRepAdaptor_Surface, right: BRepAdaptor_Surface, tolerance: float) -> bool: if left.GetType() != GeomAbs_Cylinder or right.GetType() != GeomAbs_Cylinder: return False left_cylinder = left.Cylinder() right_cylinder = right.Cylinder() left_axis = left_cylinder.Axis() right_axis = right_cylinder.Axis() left_dir = left_axis.Direction() right_dir = right_axis.Direction() axis_dot = abs(_direction_dot(left_dir, right_dir)) if axis_dot < 1.0 - 1e-6: return False radius_tolerance = max(tolerance, max(left_cylinder.Radius(), right_cylinder.Radius()) * 1e-6) if abs(left_cylinder.Radius() - right_cylinder.Radius()) > radius_tolerance: return False axis_distance = _point_axis_distance(left_axis.Location(), left_dir, right_axis.Location()) return axis_distance <= max(tolerance, radius_tolerance) def _surface_matches_plane_spec(surf: BRepAdaptor_Surface, spec: dict[str, object], tolerance: float) -> bool: if surf.GetType() != GeomAbs_Plane: return False plane = surf.Plane() normal = plane.Axis().Direction() spec_normal = gp_Dir(*spec["normal"]) if abs(_direction_dot(normal, spec_normal)) < 1.0 - 1e-7: return False spec_point = gp_Pnt(*spec["point"]) distance = abs(_axis_parameter(spec_point, spec_normal, plane.Location())) return distance <= tolerance def _surface_matches_cylinder_spec(surf: BRepAdaptor_Surface, spec: dict[str, object], tolerance: float) -> bool: if surf.GetType() != GeomAbs_Cylinder: return False cylinder = surf.Cylinder() axis = cylinder.Axis() axis_dir = axis.Direction() spec_axis_point = gp_Pnt(*spec["axis_point"]) spec_axis_dir = gp_Dir(*spec["axis_direction"]) if abs(_direction_dot(axis_dir, spec_axis_dir)) < 1.0 - 1e-6: return False radius = float(spec["radius"]) radius_tolerance = max(tolerance, max(radius, float(cylinder.Radius())) * 1e-6) if abs(float(cylinder.Radius()) - radius) > radius_tolerance: return False return _point_axis_distance(spec_axis_point, spec_axis_dir, axis.Location()) <= max(tolerance, radius_tolerance) def _mapped_edge_solid_id( edge: TopoDS_Shape, solid_edge_maps: list[tuple[int, TopTools_IndexedDataMapOfShapeListOfShape]], ) -> int: for solid_id, edge_map in solid_edge_maps: if edge_map.Contains(edge): return solid_id return -1 def _shape_quality_info(label: str, shape: TopoDS_Shape, expect_solid: bool) -> dict[str, object]: warnings: list[str] = [] if shape.IsNull(): return { "quality_label": label, "quality_status": "blocked", "brep_valid": False, "solids": 0, "faces": 0, "edges": 0, "vertices": 0, "quality_warnings": "导出对象是空 shape,不能可靠导出。", } try: brep_valid = BRepCheck_Analyzer(shape).IsValid() except Exception as exc: brep_valid = False warnings.append(f"B-Rep 校验执行失败:{exc}") topo = TopologyExplorer(shape, ignore_orientation=True) solids = len(list(topo.solids())) faces = len(list(topo.faces())) edges = len(list(topo.edges())) vertices = len(list(topo.vertices())) if not brep_valid: warnings.append("B-Rep 校验未通过,导出后其他 CAD 软件可能无法正常识别。") if faces == 0: warnings.append("没有检测到Face,导出结果可能不可用。") if expect_solid and solids == 0: warnings.append("没有检测到Solid,导出后可能不是实体。") elif expect_solid and solids > 1: warnings.append( f"检测到 {solids} 个Solid。" "如果这不是有意的多实体特征,导出后可能看起来像多个体叠在一起或彼此分离。" ) geometry_info = _shape_volume_info(shape) volume = geometry_info.get("volume", "") if expect_solid and isinstance(volume, (int, float)) and abs(float(volume)) <= 1e-9: warnings.append("实体体积接近 0,请确认导出对象是否为有效实体。") bounds_info = _shape_bounds_info(shape) return { "quality_label": label, "quality_status": "warning" if warnings else "ok", "brep_valid": brep_valid, "solids": solids, "faces": faces, "edges": edges, "vertices": vertices, "volume": volume, "bbox_diagonal": bounds_info.get("bbox_diagonal", ""), "quality_warnings": ";".join(warnings), } def _shape_faces_polydata(shape: TopoDS_Shape): import vtk points = vtk.vtkPoints() polys = vtk.vtkCellArray() for face in _explore(shape, TopAbs_FACE): loc = TopLoc_Location() tri = BRep_Tool.Triangulation(topods.Face(face), loc) if tri is None: continue transform = loc.Transformation() node_offset = points.GetNumberOfPoints() for node_index in range(1, tri.NbNodes() + 1): pnt = tri.Node(node_index).Transformed(transform) points.InsertNextPoint(pnt.X(), pnt.Y(), pnt.Z()) reversed_face = face.Orientation() == TopAbs_REVERSED for tri_index in range(1, tri.NbTriangles() + 1): n1, n2, n3 = tri.Triangle(tri_index).Get() if reversed_face: n2, n3 = n3, n2 vtk_tri = vtk.vtkTriangle() vtk_tri.GetPointIds().SetId(0, node_offset + n1 - 1) vtk_tri.GetPointIds().SetId(1, node_offset + n2 - 1) vtk_tri.GetPointIds().SetId(2, node_offset + n3 - 1) polys.InsertNextCell(vtk_tri) poly = vtk.vtkPolyData() poly.SetPoints(points) poly.SetPolys(polys) return poly def _shape_bounds(shape: TopoDS_Shape) -> tuple[float, float, float, float, float, float]: if shape.IsNull(): raise RuntimeError("Shape is null and has no usable bounds.") box = Bnd_Box() brepbndlib.Add(shape, box) try: if box.IsVoid(): raise RuntimeError("Shape has no usable bounds.") except AttributeError: pass try: return box.Get() except Exception as exc: raise RuntimeError("Shape has no usable bounds.") from exc def _shape_bounds_info(shape: TopoDS_Shape) -> dict[str, object]: xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(shape) dx = xmax - xmin dy = ymax - ymin dz = zmax - zmin return { "bbox_min": (xmin, ymin, zmin), "bbox_max": (xmax, ymax, zmax), "bbox_size": (dx, dy, dz), "bbox_diagonal": math.sqrt(dx * dx + dy * dy + dz * dz), } def _shape_volume_info(shape: TopoDS_Shape) -> dict[str, object]: props = GProp_GProps() try: brepgprop.VolumeProperties(shape, props) except Exception: return {"volume": "unavailable"} volume = props.Mass() info: dict[str, object] = {"volume": volume} if abs(volume) > 1e-9: info["center_of_mass"] = _point_tuple(props.CentreOfMass()) return info def _shape_diagonal(shape: TopoDS_Shape) -> float: xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(shape) return math.sqrt((xmax - xmin) ** 2 + (ymax - ymin) ** 2 + (zmax - zmin) ** 2) def _shape_center(shape: TopoDS_Shape) -> tuple[float, float, float]: xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(shape) return ((xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0) def _translated_shape(shape: TopoDS_Shape, direction: tuple[float, float, float], distance: float) -> TopoDS_Shape: if abs(distance) <= 1e-12: return shape trsf = gp_Trsf() trsf.SetTranslation( gp_Vec( float(direction[0]) * distance, float(direction[1]) * distance, float(direction[2]) * distance, ) ) return BRepBuilderAPI_Transform(shape, trsf, True).Shape() def _translated_shape_by_vector(shape: TopoDS_Shape, vector: tuple[float, float, float]) -> TopoDS_Shape: if _vector_length(vector) <= 1e-12: return shape trsf = gp_Trsf() trsf.SetTranslation(gp_Vec(float(vector[0]), float(vector[1]), float(vector[2]))) return BRepBuilderAPI_Transform(shape, trsf, True).Shape() def _rotated_shape( shape: TopoDS_Shape, axis_name: str, angle_degrees: float, center: tuple[float, float, float], ) -> TopoDS_Shape: if abs(angle_degrees) <= 1e-12: return shape axis_dir = _axis_dir_from_name(axis_name) trsf = gp_Trsf() trsf.SetRotation(gp_Ax1(gp_Pnt(*center), axis_dir), math.radians(angle_degrees)) return BRepBuilderAPI_Transform(shape, trsf, True).Shape() def _scaled_shape(shape: TopoDS_Shape, scale: float, center: tuple[float, float, float]) -> TopoDS_Shape: if abs(scale - 1.0) <= 1e-12: return shape if scale <= 0: raise ValueError("Scale factor must be greater than 0.") trsf = gp_Trsf() trsf.SetScale(gp_Pnt(*center), float(scale)) return BRepBuilderAPI_Transform(shape, trsf, True).Shape() def _axis_scaled_shape( shape: TopoDS_Shape, axis_name: str, scale: float, center: tuple[float, float, float], ) -> TopoDS_Shape: if abs(scale - 1.0) <= 1e-12: return shape if scale <= 0: raise ValueError("Scale factor must be greater than 0.") axis = axis_name.upper() axis_index = {"X": 0, "Y": 1, "Z": 2}.get(axis) if axis_index is None: raise ValueError("Scale axis must be X, Y or Z.") matrix = [ [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], ] matrix[axis_index][axis_index] = float(scale) cx, cy, cz = center moved_center = ( matrix[0][0] * cx + matrix[0][1] * cy + matrix[0][2] * cz, matrix[1][0] * cx + matrix[1][1] * cy + matrix[1][2] * cz, matrix[2][0] * cx + matrix[2][1] * cy + matrix[2][2] * cz, ) translation = (cx - moved_center[0], cy - moved_center[1], cz - moved_center[2]) transform = gp_GTrsf() for row in range(3): for column in range(3): transform.SetValue(row + 1, column + 1, matrix[row][column]) transform.SetTranslationPart(gp_XYZ(*translation)) builder = BRepBuilderAPI_GTransform(shape, transform, True) builder.Build() if not builder.IsDone(): raise RuntimeError("Axis scale transform failed.") result = builder.Shape() if result.IsNull(): raise RuntimeError("Axis scale transform produced an empty shape.") return result def _axis_dir_from_name(axis_name: str) -> gp_Dir: axis = axis_name.upper() if axis == "X": return gp_Dir(1.0, 0.0, 0.0) if axis == "Y": return gp_Dir(0.0, 1.0, 0.0) if axis == "Z": return gp_Dir(0.0, 0.0, 1.0) raise ValueError("Rotation axis must be X, Y or Z.") def _vector_length(vector: tuple[float, float, float]) -> float: return math.sqrt(float(vector[0]) ** 2 + float(vector[1]) ** 2 + float(vector[2]) ** 2) def _tuple_or_none(value: object) -> tuple[float, float, float] | None: if not isinstance(value, (list, tuple)) or len(value) != 3: return None try: return (float(value[0]), float(value[1]), float(value[2])) except (TypeError, ValueError): return None def _float_or_none(value: object) -> float | None: if value is None or value == "": return None try: return float(value) except (TypeError, ValueError): return None def _tuple_sub(left: tuple[float, float, float], right: tuple[float, float, float]) -> tuple[float, float, float]: return (left[0] - right[0], left[1] - right[1], left[2] - right[2]) def _tuple_add(left: tuple[float, float, float], right: tuple[float, float, float]) -> tuple[float, float, float]: return (left[0] + right[0], left[1] + right[1], left[2] + right[2]) def _tuple_scale(values: tuple[float, float, float], scale: float) -> tuple[float, float, float]: return (values[0] * scale, values[1] * scale, values[2] * scale) def _tuple_dot(left: tuple[float, float, float], right: tuple[float, float, float]) -> float: return left[0] * right[0] + left[1] * right[1] + left[2] * right[2] def _tuple_cross(left: tuple[float, float, float], right: tuple[float, float, float]) -> tuple[float, float, float]: return ( left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0], ) def _tuple_normalized(value: tuple[float, float, float] | None) -> tuple[float, float, float] | None: if value is None: return None length = _vector_length(value) if length <= 1e-12: return None return (value[0] / length, value[1] / length, value[2] / length) def _rotation_readiness(axis_name: str, angle_degrees: float) -> dict[str, object]: risk = "low" status = "ready" warnings: list[str] = [] blockers: list[str] = [] axis = axis_name.upper() if axis not in {"X", "Y", "Z"}: status = "blocked" risk = "blocked" blockers.append("旋转轴必须是 X、Y 或 Z。") if abs(angle_degrees) <= 1e-9: status = "blocked" risk = "blocked" blockers.append("旋转角度为 0,不需要修改。") if abs(angle_degrees) > 360.0: risk = _max_risk(risk, "medium") warnings.append("旋转角度超过 360 度,请确认输入是否符合预期。") if blockers: note = " ".join(blockers + warnings) elif warnings: status = "caution" note = " ".join(warnings) else: note = "可以尝试旋转当前对象。" return { "rotate_status": status, "rotate_risk": risk, "rotate_warnings": ";".join(warnings), "rotate_blockers": ";".join(blockers), "rotate_note": note, } def _translation_readiness(vector: tuple[float, float, float], shape: TopoDS_Shape) -> dict[str, object]: risk = "low" status = "ready" warnings: list[str] = [] blockers: list[str] = [] distance = _vector_length(vector) diagonal = _shape_diagonal(shape) if distance <= 1e-9: status = "blocked" risk = "blocked" blockers.append("平移向量为 0,不需要修改。") elif diagonal > 1e-9: ratio = distance / diagonal if ratio > 2.0: risk = "high" warnings.append("平移距离超过目标包围盒对角线的 2 倍,请确认单位和方向。") elif ratio > 0.5: risk = "medium" warnings.append("平移距离超过目标包围盒对角线的 50%,请确认单位和方向。") if blockers: note = " ".join(blockers + warnings) elif warnings: status = "caution" note = " ".join(warnings) else: note = "可以尝试平移当前对象。" return { "translate_status": status, "translate_risk": risk, "translate_warnings": ";".join(warnings), "translate_blockers": ";".join(blockers), "translate_note": note, } def _boolean_overlap_distance(shape: TopoDS_Shape, requested_distance: float) -> float: diagonal = _shape_diagonal(shape) size_based = diagonal * 1e-5 if diagonal > 0 else 0.01 distance_based = abs(requested_distance) * 0.02 return min(max(size_based, distance_based, 0.001), max(abs(requested_distance) * 0.25, 0.01)) def _shape_cleaning_tolerance( source_shape: TopoDS_Shape, profile_shape: TopoDS_Shape, requested_distance: float, ) -> float: source_diagonal = _shape_diagonal(source_shape) profile_diagonal = _shape_diagonal(profile_shape) reference = max(source_diagonal, profile_diagonal, abs(requested_distance), 1.0) size_based = reference * 1e-7 distance_based = abs(requested_distance) * 1e-5 lower = max(size_based, distance_based, 1e-5) upper = max(reference * 1e-4, 0.02) return min(lower, upper) def _topology_shape_count(shape: TopoDS_Shape, shape_type) -> int: explorer = TopExp_Explorer(shape, shape_type) count = 0 while explorer.More(): count += 1 explorer.Next() return count def _defeature_faces(shape: TopoDS_Shape, faces: Iterable[TopoDS_Shape]) -> TopoDS_Shape: builder = BRepAlgoAPI_Defeaturing() builder.SetShape(shape) for face in faces: builder.AddFaceToRemove(topods.Face(face)) return _finalize_builder_result(builder, "existing fillet defeature") def _find_axis_aligned_edge( shape: TopoDS_Shape, axis_point: gp_Pnt, axis_dir: gp_Dir, expected_length: float, reference_radius: float, ) -> TopoDS_Shape | None: candidates = _axis_aligned_edge_candidates(shape, axis_point, axis_dir, expected_length, reference_radius) return candidates[0] if candidates else None def _axis_aligned_edge_candidates( shape: TopoDS_Shape, axis_point: gp_Pnt, axis_dir: gp_Dir, expected_length: float, reference_radius: float, ) -> list[TopoDS_Shape]: candidates: list[tuple[float, TopoDS_Shape]] = [] length_reference = max(expected_length, reference_radius, 1.0) # A 90-degree edge fillet restores a sharp edge about sqrt(2) * R away # from the fillet cylinder axis, so 1.25R misses the common box case. distance_limit = max(reference_radius * 1.8, length_reference * 0.08, 0.2) for edge in TopologyExplorer(shape, ignore_orientation=True).edges(): try: curve = BRepAdaptor_Curve(edge) if curve.GetType() != GeomAbs_Line: continue line = curve.Line() parallel = abs(_direction_dot(line.Direction(), axis_dir)) if parallel < 0.96: continue props = GProp_GProps() brepgprop.LinearProperties(edge, props) edge_length = props.Mass() if edge_length <= 1e-9: continue line_distance = _point_axis_distance(axis_point, axis_dir, line.Location()) center_distance = _point_axis_distance(axis_point, axis_dir, props.CentreOfMass()) length_penalty = 0.0 if expected_length > 1e-9: length_penalty = abs(edge_length - expected_length) / expected_length score = max(line_distance, center_distance) + length_penalty * max(reference_radius * 0.15, 0.05) if score <= distance_limit: candidates.append((score, edge)) except Exception: continue candidates.sort(key=lambda item: item[0]) return [edge for _score, edge in candidates] def _finalize_boolean_result(op, operation_name: str, *, use_glue: bool | None = None) -> TopoDS_Shape: op.SetNonDestructive(True) glue_enabled = "cut" not in operation_name.lower() if use_glue is None else bool(use_glue) if glue_enabled and hasattr(op, "SetGlue"): try: op.SetGlue(BOPAlgo_GlueFull) except Exception: pass if hasattr(op, "SetFuzzyValue"): try: op.SetFuzzyValue(1e-7) except Exception: pass op.Build() if not op.IsDone(): raise RuntimeError(f"{operation_name} Boolean operation failed.") raw_result = _ensure_valid_or_repaired_shape(op.Shape(), operation_name) try: _simplify_boolean_builder(op) simplified = _ensure_valid_or_repaired_shape( op.Shape(), f"{operation_name} simplify" ) unified = _unify_same_domain_shape(simplified) return _ensure_valid_or_repaired_shape(unified, f"{operation_name} unify") except Exception: unified = _unify_same_domain_shape(raw_result) return _ensure_valid_or_repaired_shape(unified, f"{operation_name} unify") def _simplify_boolean_builder(builder) -> None: if not hasattr(builder, "SimplifyResult"): return for args in ((True, True, 1e-5), (True, True)): try: builder.SimplifyResult(*args) return except TypeError: continue except Exception: return def _finalize_builder_result(builder, operation_name: str) -> TopoDS_Shape: builder.Build() if hasattr(builder, "IsDone") and not builder.IsDone(): raise RuntimeError(f"{operation_name} operation failed.") result = _ensure_valid_or_repaired_shape(builder.Shape(), operation_name) unified = _unify_same_domain_shape(result) return _ensure_valid_or_repaired_shape(unified, f"{operation_name} unify") def _cleanup_push_pull_result( result: TopoDS_Shape, source_shape: TopoDS_Shape, profile_shape: TopoDS_Shape, distance: float, ) -> TopoDS_Shape: base_tolerance = _shape_cleaning_tolerance(source_shape, profile_shape, distance) cleaned = result for multiplier in (1.0, 5.0, 20.0): tolerance = base_tolerance * multiplier for safe_input_mode in (True, False): candidate = _unify_same_domain_shape( cleaned, linear_tolerance=tolerance, angular_tolerance=1e-5, allow_internal_edges=False, safe_input_mode=safe_input_mode, ) candidate = _ensure_valid_or_repaired_shape(candidate, f"push/pull cleanup {multiplier:g}x") if _topology_shape_count(candidate, TopAbs_SOLID) == _topology_shape_count(result, TopAbs_SOLID): cleaned = candidate return cleaned def _unify_same_domain_shape( shape: TopoDS_Shape, linear_tolerance: float | None = None, angular_tolerance: float | None = None, allow_internal_edges: bool = False, safe_input_mode: bool = True, concat_bsplines: bool = False, ) -> TopoDS_Shape: try: unifier = ShapeUpgrade_UnifySameDomain(shape, True, True, concat_bsplines) unifier.SetSafeInputMode(safe_input_mode) if hasattr(unifier, "AllowInternalEdges"): unifier.AllowInternalEdges(allow_internal_edges) if linear_tolerance is not None and hasattr(unifier, "SetLinearTolerance"): unifier.SetLinearTolerance(max(float(linear_tolerance), 0.0)) if angular_tolerance is not None and hasattr(unifier, "SetAngularTolerance"): unifier.SetAngularTolerance(max(float(angular_tolerance), 0.0)) unifier.Build() unified = unifier.Shape() _ensure_valid_shape(unified) return unified except Exception: return shape def _ensure_valid_or_repaired_shape( shape: TopoDS_Shape, operation_name: str ) -> TopoDS_Shape: try: _ensure_valid_shape(shape) return shape except RuntimeError as original_error: repaired = _repair_shape(shape) try: _ensure_valid_shape(repaired) return repaired except RuntimeError: raise RuntimeError( f"{operation_name} returned an invalid B-Rep shape, and automatic repair did not fix it." ) from original_error def _repair_shape(shape: TopoDS_Shape) -> TopoDS_Shape: if shape.IsNull(): return shape try: fixer = ShapeFix_Shape(shape) fixer.Perform() repaired = fixer.Shape() if repaired.IsNull(): return shape return repaired except Exception: return shape def _ensure_valid_shape(shape: TopoDS_Shape) -> None: if shape.IsNull(): raise RuntimeError("Operation returned a null shape.") analyzer = BRepCheck_Analyzer(shape) if not analyzer.IsValid(): raise RuntimeError("Operation returned an invalid B-Rep shape.") try: _shape_bounds(shape) except RuntimeError as exc: raise RuntimeError("Operation returned a shape without usable geometry.") from exc def _solid_state(solid: TopoDS_Shape, point: gp_Pnt) -> str: classifier = BRepClass3d_SolidClassifier(solid, point, 1e-6) state = classifier.State() if state == TopAbs_IN: return "inside" if state == TopAbs_OUT: return "outside" return "on/unknown" def _state_summary(states: list[str]) -> str: if not states: return "unknown" counts: dict[str, int] = {} for state in states: counts[state] = counts.get(state, 0) + 1 if len(counts) == 1: return states[0] return ", ".join(f"{state}:{count}" for state, count in sorted(counts.items())) def _cylinder_resize_readiness( info: dict[str, object], new_diameter: float | None = None, ) -> dict[str, object]: risk = "low" status = "ready" warnings: list[str] = [] blockers: list[str] = [] guess = str(info.get("feature_guess", "cylindrical face")) confidence = str(info.get("confidence", "low")) angular_span = float(info.get("angular_span", 0.0)) if guess == "round/fillet candidate": risk = "high" warnings.append("当前圆柱面更像圆角/倒圆,调整圆柱孔径很可能误切圆角。") elif guess == "boss/outer-round candidate": risk = "high" warnings.append("当前圆柱面更像凸柱或外圆,调整圆柱孔径可能切掉外部结构。") elif guess != "hole/groove candidate": risk = "high" warnings.append("当前圆柱面还没有被识别为孔/槽候选。") if guess == "hole/groove candidate" and confidence == "low": risk = _max_risk(risk, "medium") warnings.append("孔/槽判断置信度较低。") if guess == "hole/groove candidate" and angular_span < math.tau * 0.92: risk = _max_risk(risk, "medium") warnings.append("这是局部圆柱面,更像槽或半孔,不是完整圆孔。") if new_diameter is not None: current_diameter = float(info.get("diameter", 0.0)) height_estimate = float(info.get("height_estimate", 0.0)) if new_diameter <= 0: status = "blocked" risk = "blocked" blockers.append("目标直径必须大于 0。") elif abs(new_diameter - current_diameter) <= max(current_diameter * 1e-5, 1e-6): status = "blocked" risk = "blocked" blockers.append("目标直径与当前直径几乎相同,不需要修改。") else: diameter_delta = abs(new_diameter - current_diameter) delta_ratio = diameter_delta / max(current_diameter, 1e-9) if new_diameter > current_diameter * 2.0: status = "blocked" risk = "blocked" blockers.append( "目标直径超过当前直径的 2 倍;当前受限 B-Rep 孔径编辑会直接阻止这种极端放大," "否则很容易出现布尔返回成功但孔壁没有真正变成目标直径。" ) elif delta_ratio > 1.0: risk = _max_risk(risk, "high") warnings.append("目标直径变化超过当前直径的 100%,很可能导致大范围误切或布尔失败。") elif delta_ratio > 0.35: risk = _max_risk(risk, "medium") warnings.append("目标直径变化超过当前直径的 35%,请确认预览范围。") if height_estimate > 0 and new_diameter > height_estimate * 2.0: status = "blocked" risk = "blocked" blockers.append( "目标直径超过圆柱面估算高度的 2 倍;当前版本无法稳定判断周边材料余量,已阻止。" ) elif height_estimate > 0 and new_diameter > height_estimate: risk = _max_risk(risk, "medium") warnings.append("目标直径超过圆柱面估算高度,可能不是常规孔径修改。") if new_diameter < current_diameter: if guess != "hole/groove candidate": status = "blocked" risk = "blocked" blockers.append("缩小孔径当前版本只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。") else: risk = _max_risk(risk, "high") warnings.append("缩小孔径会先补料再重切,属于高风险实验功能。") if risk in {"medium", "high"} and status != "blocked": status = "caution" if not warnings and not blockers: note = "可以尝试调整圆柱孔径。" else: note = " ".join(blockers + warnings) return { "resize_status": status, "resize_risk": risk, "resize_warnings": ";".join(warnings), "resize_blockers": ";".join(blockers), "resize_note": note, } def _cylinder_boss_resize_readiness( info: dict[str, object], new_diameter: float | None = None, ) -> dict[str, object]: risk = "low" status = "ready" warnings: list[str] = [] blockers: list[str] = [] guess = str(info.get("feature_guess", "cylindrical face")) confidence = str(info.get("confidence", "low")) angular_span = float(info.get("angular_span", 0.0)) current_diameter = float(info.get("diameter", 0.0)) height_estimate = float(info.get("height_estimate", 0.0)) if guess != "boss/outer-round candidate": blockers.append("凸台直径调整当前版本只支持明确的凸台/外圆柱候选。") if angular_span < math.tau * 0.92: blockers.append("凸台直径调整当前版本只支持接近完整圆柱的凸台,不处理局部外圆角或圆角面。") if current_diameter <= 1e-9: blockers.append("当前圆柱面的直径估算无效。") if guess == "boss/outer-round candidate" and confidence != "high": risk = _max_risk(risk, "medium") warnings.append("凸台判断置信度不是 high,修改后请重点检查结果。") if new_diameter is not None: if new_diameter <= 0: blockers.append("目标凸台直径必须大于 0。") elif current_diameter > 1e-9 and abs(new_diameter - current_diameter) <= max(current_diameter * 1e-5, 1e-6): blockers.append("目标凸台直径与当前直径几乎相同,不需要修改。") elif current_diameter > 1e-9: delta_ratio = abs(new_diameter - current_diameter) / current_diameter 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%,请确认预览范围。") if new_diameter < current_diameter * 0.15: risk = _max_risk(risk, "high") warnings.append("目标凸台直径非常小,可能生成很薄或断开的几何。") if height_estimate > 1e-9 and new_diameter > height_estimate * 3.0: risk = _max_risk(risk, "high") warnings.append("目标凸台直径超过圆柱面估算高度的 3 倍,几何比例异常。") elif height_estimate > 1e-9 and new_diameter > height_estimate * 1.5: risk = _max_risk(risk, "medium") warnings.append("目标凸台直径明显大于圆柱面估算高度,请确认单位。") if blockers: status = "blocked" risk = "blocked" elif risk in {"medium", "high"}: status = "caution" if not warnings and not blockers: note = "可以尝试调整圆柱凸台直径。" else: note = " ".join(blockers + warnings) return { "boss_resize_status": status, "boss_resize_risk": risk, "boss_resize_warnings": ";".join(warnings), "boss_resize_blockers": ";".join(blockers), "boss_resize_note": note, } def _cylinder_depth_readiness( info: dict[str, object], target_depth: float | None = None, ) -> dict[str, object]: risk = "low" status = "ready" warnings: list[str] = [] blockers: list[str] = [] guess = str(info.get("feature_guess", "cylindrical face")) confidence = str(info.get("confidence", "low")) angular_span = float(info.get("angular_span", 0.0)) end_type = str(info.get("cylinder_end_type", "unknown")) current_depth = float(info.get("hole_depth_estimate", 0.0)) manual_bottom_face_used = bool(info.get("manual_bottom_face_used")) if guess != "hole/groove candidate": blockers.append("孔深调整当前版本只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。") if end_type != "blind" and not manual_bottom_face_used: blockers.append("孔深调整当前版本只支持端部类型为 blind 的盲孔/盲槽。") elif end_type != "blind" and manual_bottom_face_used: risk = _max_risk(risk, "medium") warnings.append("端部类型不是明确 blind,当前按手动底面 Face ID 推断孔深方向。") if current_depth <= 1e-9: blockers.append("当前圆柱面没有可靠的深度估算。") if guess == "hole/groove candidate" and confidence == "low": risk = _max_risk(risk, "medium") warnings.append("孔/槽判断置信度较低。") if guess == "hole/groove candidate" and angular_span < math.tau * 0.92: risk = _max_risk(risk, "medium") warnings.append("这是局部圆柱面,更像槽或半孔,孔深调整会按局部槽处理。") if target_depth is not None: if target_depth <= 0: blockers.append("目标深度必须大于 0。") elif current_depth > 1e-9 and abs(target_depth - current_depth) <= max(current_depth * 1e-5, 1e-6): blockers.append("目标深度与当前深度几乎相同,不需要修改。") elif current_depth > 1e-9: delta_ratio = abs(target_depth - current_depth) / current_depth if delta_ratio > 1.0: risk = _max_risk(risk, "high") warnings.append("目标深度变化超过当前深度的 100%,很可能导致贯穿、误切或布尔失败。") elif delta_ratio > 0.35: risk = _max_risk(risk, "medium") warnings.append("目标深度变化超过当前深度的 35%,请确认预览范围。") if target_depth < current_depth * 0.08: risk = _max_risk(risk, "high") warnings.append("目标深度非常浅,补料后可能生成很薄的局部面。") if blockers: status = "blocked" risk = "blocked" elif risk in {"medium", "high"}: status = "caution" if not warnings and not blockers: note = "可以尝试调整盲孔/盲槽深度。" else: note = " ".join(blockers + warnings) return { "depth_status": status, "depth_risk": risk, "depth_warnings": ";".join(warnings), "depth_blockers": ";".join(blockers), "depth_note": note, } def _cylinder_suppress_readiness(info: dict[str, object]) -> dict[str, object]: risk = "low" status = "ready" warnings: list[str] = [] blockers: list[str] = [] guess = str(info.get("feature_guess", "cylindrical face")) confidence = str(info.get("confidence", "low")) angular_span = float(info.get("angular_span", 0.0)) end_type = str(info.get("cylinder_end_type", "unknown")) height = float(info.get("height_estimate", 0.0)) diameter = float(info.get("diameter", 0.0)) if guess != "hole/groove candidate": blockers.append("封堵圆柱孔当前版本只支持孔候选,不支持圆角、凸柱或未明确圆柱面。") if angular_span < math.tau * 0.92: blockers.append("封堵圆柱孔当前版本只支持接近完整圆柱的孔,不支持半孔/槽。") if end_type == "closed/internal": blockers.append("当前圆柱两端都像在材料内部,不像可封堵的外部孔。") if height <= 1e-9 or diameter <= 1e-9: blockers.append("当前圆柱孔的直径或高度估算无效。") if guess == "hole/groove candidate" and confidence != "high": risk = _max_risk(risk, "medium") warnings.append("孔判断置信度不是 high,封堵后请重点检查结果。") if end_type not in {"blind", "through/open-ended"}: risk = _max_risk(risk, "medium") warnings.append("孔端部类型不明确,补料范围可能不是期望的孔范围。") if blockers: status = "blocked" risk = "blocked" elif risk in {"medium", "high"}: status = "caution" if not warnings and not blockers: note = "可以尝试封堵该圆柱孔。" else: note = " ".join(blockers + warnings) return { "suppress_status": status, "suppress_risk": risk, "suppress_warnings": ";".join(warnings), "suppress_blockers": ";".join(blockers), "suppress_note": note, } def _edge_fillet_readiness( info: dict[str, object], radius: float | None = None, ) -> dict[str, object]: risk = "medium" status = "caution" warnings: list[str] = ["STEP 没有建模历史,边倒圆依赖当前 B-Rep 拓扑,部分边可能被 OCCT 拒绝。"] blockers: list[str] = [] curve = str(info.get("curve", "")) length = float(info.get("length", 0.0)) adjacent_count = int(info.get("adjacent_face_count", 0)) if curve != "line": blockers.append("添加圆角当前版本只支持直线Edge。") if length <= 1e-9: blockers.append("当前Edge长度无效。") if adjacent_count < 2: blockers.append("当前 Edge 没有检测到至少两个相邻 Face,不能可靠添加圆角。") elif adjacent_count > 2: risk = _max_risk(risk, "medium") warnings.append(f"当前 Edge 相邻 Face 数为 {adjacent_count},可能是复杂交汇边。") if radius is not None: if radius <= 0: blockers.append("圆角半径必须大于 0。") elif length > 1e-9: ratio = radius / length if ratio >= 0.45: blockers.append("圆角半径接近或超过Edge长度的一半,当前版本会直接阻止。") elif ratio > 0.25: risk = _max_risk(risk, "high") warnings.append("圆角半径超过Edge长度的 25%,很容易导致倒圆失败。") elif ratio > 0.12: risk = _max_risk(risk, "medium") warnings.append("圆角半径相对Edge长度偏大,请确认预览范围。") if blockers: status = "blocked" risk = "blocked" elif risk in {"medium", "high"}: status = "caution" if not warnings and not blockers: note = "可以尝试给该直线边添加圆角。" else: note = " ".join(blockers + warnings) return { "fillet_status": status, "fillet_risk": risk, "fillet_warnings": ";".join(warnings), "fillet_blockers": ";".join(blockers), "fillet_note": note, } def _edge_chamfer_readiness( info: dict[str, object], distance: float | None = None, ) -> dict[str, object]: risk = "medium" status = "caution" warnings: list[str] = ["STEP 没有建模历史,边倒角依赖当前 B-Rep 拓扑,部分边可能被 OCCT 拒绝。"] blockers: list[str] = [] curve = str(info.get("curve", "")) length = float(info.get("length", 0.0)) adjacent_count = int(info.get("adjacent_face_count", 0)) if curve != "line": blockers.append("添加倒角当前版本只支持直线Edge。") if length <= 1e-9: blockers.append("当前Edge长度无效。") if adjacent_count < 2: blockers.append("当前 Edge 没有检测到至少两个相邻 Face,不能可靠添加倒角。") elif adjacent_count > 2: risk = _max_risk(risk, "medium") warnings.append(f"当前 Edge 相邻 Face 数为 {adjacent_count},可能是复杂交汇边。") if distance is not None: if distance <= 0: blockers.append("倒角距离必须大于 0。") elif length > 1e-9: ratio = distance / length if ratio >= 0.45: blockers.append("倒角距离接近或超过Edge长度的一半,当前版本会直接阻止。") elif ratio > 0.25: risk = _max_risk(risk, "high") warnings.append("倒角距离超过Edge长度的 25%,很容易导致倒角失败。") elif ratio > 0.12: risk = _max_risk(risk, "medium") warnings.append("倒角距离相对Edge长度偏大,请确认预览范围。") if blockers: status = "blocked" risk = "blocked" elif risk in {"medium", "high"}: status = "caution" if not warnings and not blockers: note = "可以尝试给该直线边添加倒角。" else: note = " ".join(blockers + warnings) return { "chamfer_status": status, "chamfer_risk": risk, "chamfer_warnings": ";".join(warnings), "chamfer_blockers": ";".join(blockers), "chamfer_note": note, } def _max_risk(current: str, candidate: str) -> str: levels = {"low": 0, "medium": 1, "high": 2, "blocked": 3} return candidate if levels[candidate] > levels[current] else current def _resize_mode(current_diameter: float, target_diameter: float) -> str: return "enlarge" if target_diameter > current_diameter else "shrink" def _join_nonempty(*values: object) -> str: return ";".join(str(value) for value in values if value not in {"", None}) def _int_values(value: object) -> list[int]: if value is None or value == "": return [] if isinstance(value, int): return [value] if isinstance(value, (list, tuple, set)): result: list[int] = [] for item in value: try: result.append(int(item)) except (TypeError, ValueError): continue return result return [] def _dir_tuple(direction) -> tuple[float, float, float]: return (direction.X(), direction.Y(), direction.Z()) def _oriented_dir_tuple(direction, shape: TopoDS_Shape) -> tuple[float, float, float]: values = _dir_tuple(direction) if shape.Orientation() == TopAbs_REVERSED: return (-values[0], -values[1], -values[2]) return values def _neg_tuple(values: tuple[float, float, float]) -> tuple[float, float, float]: return (-values[0], -values[1], -values[2]) def _point_tuple(point) -> tuple[float, float, float]: return (point.X(), point.Y(), point.Z()) def _point_on_axis(axis_point: gp_Pnt, direction, parameter: float) -> gp_Pnt: return gp_Pnt( axis_point.X() + direction.X() * parameter, axis_point.Y() + direction.Y() * parameter, axis_point.Z() + direction.Z() * parameter, ) def _direction_dot(left, right) -> float: return left.X() * right.X() + left.Y() * right.Y() + left.Z() * right.Z() def _axis_parameter(axis_point: gp_Pnt, direction, point: gp_Pnt) -> float: return ( (point.X() - axis_point.X()) * direction.X() + (point.Y() - axis_point.Y()) * direction.Y() + (point.Z() - axis_point.Z()) * direction.Z() ) def _point_axis_distance(axis_point: gp_Pnt, direction, point: gp_Pnt) -> float: projected = _point_on_axis(axis_point, direction, _axis_parameter(axis_point, direction, point)) return _vec_from_points(projected, point).Magnitude() def _shape_axis_parameters(shape: TopoDS_Shape, axis_point: gp_Pnt, direction) -> list[float]: parameters: list[float] = [] try: for vertex in TopologyExplorer(shape, ignore_orientation=True).vertices(): point = BRep_Tool.Pnt(topods.Vertex(vertex)) parameters.append(_axis_parameter(axis_point, direction, point)) except Exception: parameters.clear() try: parameters.append(_axis_parameter(axis_point, direction, _surface_center(shape))) except Exception: pass return parameters def _shape_axis_interval(shape: TopoDS_Shape, axis_point: gp_Pnt, direction) -> tuple[float, float] | None: parameters = _shape_axis_parameters(shape, axis_point, direction) if not parameters: return None return (min(parameters), max(parameters)) def _shape_plane_interval( shape: TopoDS_Shape, origin: gp_Pnt, u_dir: gp_Dir, v_dir: gp_Dir, ) -> tuple[tuple[float, float], tuple[float, float]] | None: u_values = _shape_axis_parameters(shape, origin, u_dir) v_values = _shape_axis_parameters(shape, origin, v_dir) if not u_values or not v_values: return None return ((min(u_values), max(u_values)), (min(v_values), max(v_values))) def _plane_intervals_touch_or_overlap( left: tuple[tuple[float, float], tuple[float, float]], right: tuple[tuple[float, float], tuple[float, float]], tolerance: float, ) -> bool: return _intervals_touch_or_overlap(left[0], right[0], tolerance) and _intervals_touch_or_overlap(left[1], right[1], tolerance) def _intervals_touch_or_overlap( left: tuple[float, float], right: tuple[float, float], tolerance: float, ) -> bool: left_min, left_max = min(left), max(left) right_min, right_max = min(right), max(right) return left_max + tolerance >= right_min and right_max + tolerance >= left_min def _plane_basis_dirs(normal) -> tuple[gp_Dir, gp_Dir]: nx, ny, nz = _dir_tuple(normal) reference = (1.0, 0.0, 0.0) if abs(nx) < 0.85 else (0.0, 1.0, 0.0) u = _tuple_normalized(_tuple_cross((nx, ny, nz), reference)) or (1.0, 0.0, 0.0) v = _tuple_normalized(_tuple_cross((nx, ny, nz), u)) or (0.0, 1.0, 0.0) return gp_Dir(*u), gp_Dir(*v) def _edge_duplicate_key(edge: TopoDS_Shape, tolerance: float) -> tuple[object, ...] | None: try: curve = BRepAdaptor_Curve(edge) curve_type = curve.GetType() start = curve.Value(curve.FirstParameter()) end = curve.Value(curve.LastParameter()) except Exception: return None if curve_type == GeomAbs_Circle: try: circle = curve.Circle() endpoint_keys = sorted((_point_quantized_key(start, tolerance), _point_quantized_key(end, tolerance))) return ( "circle", _point_quantized_key(circle.Location(), tolerance), _direction_quantized_key(circle.Axis().Direction()), _number_quantized_key(float(circle.Radius()), tolerance), tuple(endpoint_keys), ) except Exception: pass endpoint_keys = sorted((_point_quantized_key(start, tolerance), _point_quantized_key(end, tolerance))) if curve_type == GeomAbs_Line: return ("line", tuple(endpoint_keys)) return (CURVE_TYPES.get(curve_type, f"type {curve_type}"), tuple(endpoint_keys)) def _point_quantized_key(point, tolerance: float) -> tuple[int, int, int]: scale = max(float(tolerance), 1e-9) return ( _number_quantized_key(float(point.X()), scale), _number_quantized_key(float(point.Y()), scale), _number_quantized_key(float(point.Z()), scale), ) def _direction_quantized_key(direction) -> tuple[int, int, int]: values = _tuple_normalized((float(direction.X()), float(direction.Y()), float(direction.Z()))) or (1.0, 0.0, 0.0) for value in values: if abs(value) > 1e-9: if value < 0: values = (-values[0], -values[1], -values[2]) break return ( int(round(values[0] * 1_000_000)), int(round(values[1] * 1_000_000)), int(round(values[2] * 1_000_000)), ) def _number_quantized_key(value: float, tolerance: float) -> int: return int(round(float(value) / max(float(tolerance), 1e-9))) def _surface_center(shape: TopoDS_Shape) -> gp_Pnt: props = GProp_GProps() brepgprop.SurfaceProperties(shape, props) return props.CentreOfMass() def _orientation_name(orientation) -> str: return ORIENTATION_TYPES.get(orientation, f"type {orientation}") def _format_tuple(values: tuple[float, float, float]) -> str: return "(" + ", ".join(f"{float(value):.6g}" for value in values) + ")" def _vec_from_points(a: gp_Pnt, b: gp_Pnt) -> gp_Vec: return gp_Vec(b.X() - a.X(), b.Y() - a.Y(), b.Z() - a.Z()) def _point_distance_sq( point: tuple[float, float, float], target: tuple[float, float, float], ) -> float: dx = point[0] - target[0] dy = point[1] - target[1] dz = point[2] - target[2] return dx * dx + dy * dy + dz * dz def _point_segment_distance_sq( point: tuple[float, float, float], start: tuple[float, float, float], end: tuple[float, float, float], ) -> float: vx = end[0] - start[0] vy = end[1] - start[1] vz = end[2] - start[2] wx = point[0] - start[0] wy = point[1] - start[1] wz = point[2] - start[2] length_sq = vx * vx + vy * vy + vz * vz if length_sq <= 1e-18: return _point_distance_sq(point, start) t = (wx * vx + wy * vy + wz * vz) / length_sq t = max(0.0, min(1.0, t)) projection = (start[0] + t * vx, start[1] + t * vy, start[2] + t * vz) return _point_distance_sq(point, projection) def _dot(vec: gp_Vec, direction) -> float: return vec.X() * direction.X() + vec.Y() * direction.Y() + vec.Z() * direction.Z() __all__ = [name for name, value in globals().items() if name.startswith("_") and callable(value)]