from __future__ import annotations import math import re from dataclasses import dataclass 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_Fuse from OCC.Core.BRepBndLib import brepbndlib from OCC.Core.BRepBuilderAPI import 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.BRepMesh import BRepMesh_IncrementalMesh from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism from OCC.Core.Bnd import Bnd_Box from OCC.Core.GeomAbs import ( GeomAbs_BSplineCurve, GeomAbs_BSplineSurface, GeomAbs_BezierCurve, GeomAbs_BezierSurface, GeomAbs_Circle, GeomAbs_Cone, GeomAbs_Cylinder, GeomAbs_Ellipse, GeomAbs_Hyperbola, GeomAbs_Line, GeomAbs_OffsetSurface, GeomAbs_OtherCurve, GeomAbs_OtherSurface, GeomAbs_Parabola, GeomAbs_Plane, GeomAbs_Sphere, GeomAbs_SurfaceOfExtrusion, GeomAbs_SurfaceOfRevolution, GeomAbs_Torus, ) from OCC.Core.GProp import GProp_GProps from OCC.Core.IFSelect import IFSelect_RetDone from OCC.Core.Interface import Interface_Static from OCC.Core.STEPCAFControl import STEPCAFControl_Reader from OCC.Core.STEPControl import STEPControl_AsIs, STEPControl_Reader, STEPControl_Writer from OCC.Core.TDF import TDF_Label, TDF_LabelSequence from OCC.Core.TDocStd import TDocStd_Document from OCC.Core.TopAbs import ( TopAbs_EDGE, TopAbs_EXTERNAL, TopAbs_FACE, TopAbs_FORWARD, TopAbs_IN, TopAbs_INTERNAL, TopAbs_OUT, TopAbs_REVERSED, TopAbs_SOLID, ) from OCC.Core.TopExp import TopExp_Explorer from OCC.Core.TopLoc import TopLoc_Location from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods from OCC.Core.XCAFDoc import XCAFDoc_DocumentTool from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt, gp_Vec from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge SURFACE_TYPES = { GeomAbs_Plane: "plane", GeomAbs_Cylinder: "cylinder", GeomAbs_Cone: "cone", GeomAbs_Sphere: "sphere", GeomAbs_Torus: "torus", GeomAbs_BezierSurface: "bezier surface", GeomAbs_BSplineSurface: "b-spline surface", GeomAbs_SurfaceOfRevolution: "surface of revolution", GeomAbs_SurfaceOfExtrusion: "surface of extrusion", GeomAbs_OffsetSurface: "offset surface", GeomAbs_OtherSurface: "other surface", } CURVE_TYPES = { GeomAbs_Line: "line", GeomAbs_Circle: "circle", GeomAbs_Ellipse: "ellipse", GeomAbs_Hyperbola: "hyperbola", GeomAbs_Parabola: "parabola", GeomAbs_BezierCurve: "bezier curve", GeomAbs_BSplineCurve: "b-spline curve", GeomAbs_OtherCurve: "other curve", } ORIENTATION_TYPES = { TopAbs_FORWARD: "forward", TopAbs_REVERSED: "reversed", TopAbs_INTERNAL: "internal", TopAbs_EXTERNAL: "external", } @dataclass class PartNode: id: int name: str kind: str shape: TopoDS_Shape parent_id: int | None = None depth: int = 0 path: str = "" @dataclass class TopologyStats: parts: int solids: int faces: int edges: int vertices: int class StepModel: def __init__(self, filename: Path, parts: list[PartNode], shape: TopoDS_Shape): self.filename = filename self.parts = parts self.shape = shape self.faces: list[TopoDS_Shape] = [] self.face_part_ids: list[int] = [] self.face_solid_ids: list[int] = [] self.edges: list[TopoDS_Shape] = [] self.edge_part_ids: list[int] = [] self.solids: list[tuple[int, TopoDS_Shape]] = [] self.refresh_topology() @classmethod def load(cls, filename: str | Path) -> "StepModel": path = Path(filename) if not path.exists(): raise FileNotFoundError(path) product_names = _parse_product_names(path) parts, whole_shape = _load_with_xcaf(path, product_names) if not parts or whole_shape.IsNull(): whole_shape = _load_plain_step(path) fallback_name = product_names[0] if product_names else path.stem parts = [PartNode(1, fallback_name, "part", whole_shape, path=fallback_name)] return cls(path, parts, whole_shape) def display_parts(self) -> list[PartNode]: leaf_parts = [p for p in self.parts if p.kind == "part" and not p.shape.IsNull()] if leaf_parts: return leaf_parts return [p for p in self.parts if not p.shape.IsNull()] def stats(self) -> TopologyStats: topo = TopologyExplorer(self.shape, ignore_orientation=True) return TopologyStats( parts=len(self.display_parts()), solids=len(list(topo.solids())), faces=len(list(topo.faces())), edges=len(list(topo.edges())), vertices=len(list(topo.vertices())), ) def geometry_stats(self) -> dict[str, object]: surface_props = GProp_GProps() brepgprop.SurfaceProperties(self.shape, surface_props) info: dict[str, object] = { "surface_area": surface_props.Mass(), "surface_center": _point_tuple(surface_props.CentreOfMass()), } info.update(_shape_bounds_info(self.shape)) info.update(_shape_volume_info(self.shape)) return info def refresh_topology(self) -> None: self.shape = _compound_from_shapes([p.shape for p in self.display_parts()]) self.faces.clear() self.face_part_ids.clear() self.face_solid_ids.clear() self.edges.clear() self.edge_part_ids.clear() self.solids.clear() solid_id = 0 for part in self.display_parts(): part_solids = _explore(part.shape, TopAbs_SOLID) if part_solids: for solid in part_solids: self.solids.append((part.id, solid)) for face in _explore(solid, TopAbs_FACE): self.faces.append(face) self.face_part_ids.append(part.id) self.face_solid_ids.append(solid_id) solid_id += 1 else: for face in _explore(part.shape, TopAbs_FACE): self.faces.append(face) self.face_part_ids.append(part.id) self.face_solid_ids.append(-1) for edge in TopologyExplorer(part.shape, ignore_orientation=True).edges(): self.edges.append(edge) self.edge_part_ids.append(part.id) def part_by_id(self, part_id: int) -> PartNode | None: return next((p for p in self.parts if p.id == part_id), None) def snapshot(self) -> dict[int, TopoDS_Shape]: return {part.id: part.shape for part in self.parts} def restore_snapshot(self, snapshot: dict[int, TopoDS_Shape]) -> None: for part in self.parts: if part.id in snapshot: part.shape = snapshot[part.id] self.refresh_topology() def face_info(self, face_id: int) -> dict[str, object]: face = self.faces[face_id] props = GProp_GProps() brepgprop.SurfaceProperties(face, props) surf = BRepAdaptor_Surface(face) surface_type = surf.GetType() boundary_edges = len(list(TopologyExplorer(face, ignore_orientation=True).edges())) info: dict[str, object] = { "kind": "face", "face_id": face_id, "part_id": self.face_part_ids[face_id], "solid_id": self.face_solid_ids[face_id], "orientation": _orientation_name(face.Orientation()), "surface": SURFACE_TYPES.get(surface_type, f"type {surface_type}"), "area": props.Mass(), "area_center": _point_tuple(props.CentreOfMass()), "u_range": (surf.FirstUParameter(), surf.LastUParameter()), "v_range": (surf.FirstVParameter(), surf.LastVParameter()), "boundary_edges": boundary_edges, } info.update(_shape_bounds_info(face)) if surface_type == GeomAbs_Plane: plane = surf.Plane() direction = plane.Axis().Direction() push_pull_direction = self._plane_push_pull_direction(face_id, surf) info["plane_origin"] = _point_tuple(plane.Location()) info["normal"] = _dir_tuple(direction) info["oriented_normal"] = _oriented_dir_tuple(direction, face) info["push_pull_outward_direction"] = push_pull_direction["outward_direction"] info["push_pull_inward_direction"] = push_pull_direction["inward_direction"] info["push_pull_plus_side"] = push_pull_direction["plus_side_state"] info["push_pull_minus_side"] = push_pull_direction["minus_side_state"] info["push_pull_confidence"] = push_pull_direction["confidence"] info["push_pull_note"] = push_pull_direction["note"] elif surface_type == GeomAbs_Cylinder: cyl = surf.Cylinder() axis = cyl.Axis() radius = cyl.Radius() u_span = abs(surf.LastUParameter() - surf.FirstUParameter()) swept_area = max(radius * max(u_span, 1e-9), 1e-9) classification = self._classify_cylindrical_face(face_id, surf, detailed=True) info["radius"] = cyl.Radius() info["diameter"] = cyl.Radius() * 2.0 info["axis_point"] = _point_tuple(axis.Location()) info["axis"] = _dir_tuple(axis.Direction()) info["angular_span"] = u_span info["is_full_cylinder"] = u_span >= math.tau * 0.98 info["height_estimate"] = props.Mass() / swept_area info["feature_guess"] = classification["feature_guess"] info["confidence"] = classification["confidence"] info["material_toward_axis"] = classification["toward_axis"] info["material_away_axis"] = classification["away_axis"] info["material_vote_summary"] = classification["vote_summary"] info["material_sample_count"] = classification["sample_count"] info["note"] = classification["note"] info.update(self._cylinder_end_opening_info(face_id, surf)) info.update(_cylinder_resize_readiness(info)) elif surface_type == GeomAbs_Cone: cone = surf.Cone() info["axis_point"] = _point_tuple(cone.Location()) info["axis"] = _dir_tuple(cone.Axis().Direction()) info["reference_radius"] = cone.RefRadius() info["semi_angle"] = cone.SemiAngle() elif surface_type == GeomAbs_Sphere: sphere = surf.Sphere() info["center"] = _point_tuple(sphere.Location()) info["radius"] = sphere.Radius() info["diameter"] = sphere.Radius() * 2.0 elif surface_type == GeomAbs_Torus: torus = surf.Torus() info["center"] = _point_tuple(torus.Location()) info["axis"] = _dir_tuple(torus.Axis().Direction()) info["major_radius"] = torus.MajorRadius() info["minor_radius"] = torus.MinorRadius() return info def edge_info(self, edge_id: int) -> dict[str, object]: edge = self.edges[edge_id] props = GProp_GProps() brepgprop.LinearProperties(edge, props) curve = BRepAdaptor_Curve(edge) curve_type = curve.GetType() info: dict[str, object] = { "kind": "edge", "edge_id": edge_id, "part_id": self.edge_part_ids[edge_id], "orientation": _orientation_name(edge.Orientation()), "curve": CURVE_TYPES.get(curve_type, f"type {curve_type}"), "length": props.Mass(), "length_center": _point_tuple(props.CentreOfMass()), "first_parameter": curve.FirstParameter(), "last_parameter": curve.LastParameter(), "start_point": _point_tuple(curve.Value(curve.FirstParameter())), "end_point": _point_tuple(curve.Value(curve.LastParameter())), } info.update(_shape_bounds_info(edge)) if curve_type == GeomAbs_Line: line = curve.Line() info["line_origin"] = _point_tuple(line.Location()) info["direction"] = _dir_tuple(line.Direction()) if curve_type == GeomAbs_Circle: circle = curve.Circle() info["center"] = _point_tuple(circle.Location()) info["axis"] = _dir_tuple(circle.Axis().Direction()) info["radius"] = circle.Radius() info["diameter"] = circle.Radius() * 2.0 return info def solid_info(self, solid_id: int) -> dict[str, object]: if solid_id < 0 or solid_id >= len(self.solids): raise ValueError(f"Unknown solid id {solid_id}") part_id, solid = self.solids[solid_id] topo = TopologyExplorer(solid, ignore_orientation=True) surface_props = GProp_GProps() brepgprop.SurfaceProperties(solid, surface_props) info: dict[str, object] = { "kind": "solid", "solid_id": solid_id, "part_id": part_id, "faces": len(list(topo.faces())), "edges": len(list(topo.edges())), "vertices": len(list(topo.vertices())), "surface_area": surface_props.Mass(), "surface_center": _point_tuple(surface_props.CentreOfMass()), } info.update(_shape_bounds_info(solid)) info.update(_shape_volume_info(solid)) return info def part_info(self, part_id: int) -> dict[str, object]: part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") topo = TopologyExplorer(part.shape, ignore_orientation=True) info: dict[str, object] = { "kind": part.kind, "part_id": part.id, "name": part.name, "path": part.path, "parent_id": part.parent_id if part.parent_id is not None else "", "depth": part.depth, "solids": len(list(topo.solids())), "faces": len(list(topo.faces())), "edges": len(list(topo.edges())), "vertices": len(list(topo.vertices())), } info.update(_shape_bounds_info(part.shape)) info.update(_shape_volume_info(part.shape)) return info def editable_feature_candidates( self, limit: int = 160, detailed: bool = False, progress_callback: Callable[[], None] | None = None, ) -> list[dict[str, object]]: per_type_limit = max(1, limit // 2) candidates: list[dict[str, object]] = [] for item in self.cylindrical_feature_candidates( limit=per_type_limit, include_end_info=False, progress_callback=progress_callback, ): candidates.append( { "operation_key": "resize_cylinder", "operation": "调整圆柱孔径", "face_id": item["face_id"], "part_id": item["part_id"], "solid_id": item["solid_id"], "surface": "cylinder", "feature_guess": item["feature_guess"], "current_value": item["diameter"], "current_value_label": "diameter", "status": item["resize_status"], "risk": item["resize_risk"], "confidence": item["confidence"], "note": item["resize_note"], } ) plane_count = 0 for face_id, face in enumerate(self.faces): if progress_callback is not None and face_id % 30 == 0: progress_callback() if plane_count >= per_type_limit: break surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: continue props = GProp_GProps() brepgprop.SurfaceProperties(face, props) if detailed: direction_info = self._plane_push_pull_direction(face_id, surf) confidence = str(direction_info["confidence"]) risk = "low" if confidence == "high" else "medium" status = "ready" if confidence == "high" else "caution" note = str(direction_info["note"]) else: confidence = "pending" risk = "medium" status = "caution" note = "快速扫描:推拉方向会在选中 face 或执行编辑前再详细判断。" candidates.append( { "operation_key": "push_pull_plane", "operation": "推拉平面", "face_id": face_id, "part_id": self.face_part_ids[face_id], "solid_id": self.face_solid_ids[face_id], "surface": "plane", "feature_guess": "planar push/pull candidate", "current_value": props.Mass(), "current_value_label": "area", "status": status, "risk": risk, "confidence": confidence, "note": note, } ) plane_count += 1 status_order = {"ready": 0, "caution": 1, "blocked": 2} risk_order = {"low": 0, "medium": 1, "high": 2, "blocked": 3} operation_order = {"resize_cylinder": 0, "push_pull_plane": 1} candidates.sort( key=lambda item: ( status_order.get(str(item["status"]), 9), risk_order.get(str(item["risk"]), 9), operation_order.get(str(item["operation_key"]), 9), int(item["face_id"]), ) ) return candidates[:limit] def cylindrical_feature_candidates( self, limit: int = 100, include_end_info: bool = False, progress_callback: Callable[[], None] | None = None, ) -> list[dict[str, object]]: candidates: list[dict[str, object]] = [] for face_id, face in enumerate(self.faces): if progress_callback is not None and face_id % 30 == 0: progress_callback() surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: continue cyl = surf.Cylinder() props = GProp_GProps() brepgprop.SurfaceProperties(face, props) radius = cyl.Radius() u_span = abs(surf.LastUParameter() - surf.FirstUParameter()) v_span = abs(surf.LastVParameter() - surf.FirstVParameter()) swept_area = max(radius * max(u_span, 1e-9), 1e-9) height_estimate = props.Mass() / swept_area boundary_edges = len(list(TopologyExplorer(face, ignore_orientation=True).edges())) classification = self._classify_cylindrical_face(face_id, surf) candidate = { "face_id": face_id, "part_id": self.face_part_ids[face_id], "solid_id": self.face_solid_ids[face_id], "radius": radius, "diameter": radius * 2.0, "axis": _dir_tuple(cyl.Axis().Direction()), "area": props.Mass(), "angular_span": u_span, "height_estimate": height_estimate, "param_height": v_span, "boundary_edges": boundary_edges, "feature_guess": classification["feature_guess"], "material_toward_axis": classification["toward_axis"], "material_away_axis": classification["away_axis"], "material_vote_summary": classification["vote_summary"], "material_sample_count": classification["sample_count"], "confidence": classification["confidence"], "note": classification["note"], } if include_end_info: candidate.update(self._cylinder_end_opening_info(face_id, surf)) candidate.update(_cylinder_resize_readiness(candidate)) candidates.append(candidate) if len(candidates) >= limit: break return candidates def cylindrical_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) if info.get("surface") != "cylinder" or "diameter" not in info: return { "status": "blocked", "risk": "blocked", "message": "当前选中的 face 不是圆柱面,不能执行圆柱切削。", } current_diameter = float(info["diameter"]) readiness = _cylinder_resize_readiness(info, new_diameter) resize_mode = _resize_mode(current_diameter, new_diameter) cutter_plan = self._bounded_cylinder_cutter_plan(face_id, new_diameter) fill_plan = self._bounded_cylinder_fill_plan(face_id) if resize_mode == "shrink" else {} return { "status": readiness["resize_status"], "risk": readiness["resize_risk"], "message": readiness["resize_note"], "warnings": readiness["resize_warnings"], "blockers": readiness["resize_blockers"], "face_id": face_id, "part_id": info["part_id"], "solid_id": info["solid_id"], "current_diameter": current_diameter, "target_diameter": new_diameter, "delta_diameter": new_diameter - current_diameter, "resize_mode": resize_mode, "feature_guess": info.get("feature_guess"), "confidence": info.get("confidence"), "angular_span": info.get("angular_span"), "height_estimate": info.get("height_estimate"), "material_vote_summary": info.get("material_vote_summary"), "material_sample_count": info.get("material_sample_count"), **cutter_plan, **fill_plan, } def _bounded_cylinder_cutter_plan(self, face_id: int, new_diameter: float) -> dict[str, object]: face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: return { "cutter_strategy": "unavailable", "cutter_note": "selected face is not cylindrical", } cyl = surf.Cylinder() old_radius = cyl.Radius() new_radius = new_diameter / 2.0 v1 = surf.FirstVParameter() v2 = surf.LastVParameter() v_min = min(v1, v2) v_max = max(v1, v2) span = max(v_max - v_min, 0.0) end_info = self._cylinder_end_opening_info(face_id, surf) base_margin = min(max(new_radius * 0.05, abs(new_radius - old_radius) * 0.5, 0.02), max(span * 0.05, 0.2)) closed_margin = min(base_margin, max(span * 0.005, 0.02)) start_margin = base_margin if end_info["start_end_open"] else closed_margin end_margin = base_margin if end_info["end_end_open"] else closed_margin start_parameter = v_min - start_margin end_parameter = v_max + end_margin height = max(end_parameter - start_parameter, 1e-6) axis = cyl.Axis() direction = axis.Direction() axis_point = axis.Location() start = gp_Pnt( axis_point.X() + direction.X() * start_parameter, axis_point.Y() + direction.Y() * start_parameter, axis_point.Z() + direction.Z() * start_parameter, ) return { "cutter_strategy": "bounded-to-selected-cylinder-v-range", "cutter_note": "有限长度切削:按选中圆柱面的 V 参数范围生成 cutter,减少贯穿整个零件的误切风险。", "cutter_start_parameter": start_parameter, "cutter_end_parameter": end_parameter, "cutter_height": height, "cutter_margin": base_margin, "cutter_start_margin": start_margin, "cutter_end_margin": end_margin, "cutter_radius": new_radius, "cutter_axis_point": _point_tuple(axis_point), "cutter_axis_direction": _dir_tuple(direction), "cutter_start_point": _point_tuple(start), **end_info, } def _bounded_cylinder_fill_plan(self, face_id: int) -> dict[str, object]: face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: return { "fill_strategy": "unavailable", "fill_note": "selected face is not cylindrical", } cyl = surf.Cylinder() radius = cyl.Radius() v1 = surf.FirstVParameter() v2 = surf.LastVParameter() v_min = min(v1, v2) v_max = max(v1, v2) height = max(v_max - v_min, 1e-6) overlap = min(max(radius * 0.001, 0.001), 0.05) axis = cyl.Axis() direction = axis.Direction() axis_point = axis.Location() start = _point_on_axis(axis_point, direction, v_min) return { "fill_strategy": "bounded-fill-then-recut", "fill_note": "缩小孔径实验策略:先在原圆柱面范围内补料,再按目标直径重切。补料不向开口端外伸。", "fill_start_parameter": v_min, "fill_end_parameter": v_max, "fill_height": height, "fill_radius": radius + overlap, "fill_radius_overlap": overlap, "fill_start_point": _point_tuple(start), } def _cylinder_end_opening_info(self, face_id: int, surf: BRepAdaptor_Surface) -> dict[str, object]: solid_id = self.face_solid_ids[face_id] fallback = { "cylinder_end_type": "unknown", "hole_depth_estimate": abs(surf.LastVParameter() - surf.FirstVParameter()), "start_end_state": "unknown", "end_end_state": "unknown", "start_end_open": False, "end_end_open": False, "open_end_count": 0, "closed_end_count": 0, "end_sample_offset": "", "end_sample_note": "no owning solid was found", } if solid_id < 0 or solid_id >= len(self.solids): return fallback solid = self.solids[solid_id][1] cyl = surf.Cylinder() axis = cyl.Axis() axis_point = axis.Location() direction = axis.Direction() v1 = surf.FirstVParameter() v2 = surf.LastVParameter() v_min = min(v1, v2) v_max = max(v1, v2) span = max(v_max - v_min, 0.0) radius = cyl.Radius() offset = min(max(radius * 0.08, span * 0.02, 0.05), max(span * 0.25, 0.2)) start_probe = _point_on_axis(axis_point, direction, v_min - offset) end_probe = _point_on_axis(axis_point, direction, v_max + offset) start_state = _solid_state(solid, start_probe) end_state = _solid_state(solid, end_probe) start_open = start_state == "outside" end_open = end_state == "outside" start_closed = start_state == "inside" end_closed = end_state == "inside" open_count = int(start_open) + int(end_open) closed_count = int(start_closed) + int(end_closed) if open_count == 2: end_type = "through/open-ended" note = "both axis-end probes are outside material" elif open_count == 1 and closed_count == 1: end_type = "blind" note = "one axis-end probe is outside material and the other is inside material" elif closed_count == 2: end_type = "closed/internal" note = "both axis-end probes are inside material" else: end_type = "unclear" note = "axis-end probes did not produce a clear open/closed pattern" return { "cylinder_end_type": end_type, "hole_depth_estimate": span, "start_end_state": start_state, "end_end_state": end_state, "start_end_open": start_open, "end_end_open": end_open, "open_end_count": open_count, "closed_end_count": closed_count, "end_sample_offset": offset, "end_sample_note": note, } def _classify_cylindrical_face( self, face_id: int, surf: BRepAdaptor_Surface, detailed: bool = False, ) -> dict[str, object]: solid_id = self.face_solid_ids[face_id] if solid_id < 0 or solid_id >= len(self.solids): return { "feature_guess": "cylindrical face", "toward_axis": "unknown", "away_axis": "unknown", "vote_summary": "hole=0, boss=0, unclear=0", "sample_count": 0, "confidence": "low", "note": "no owning solid was found", } solid = self.solids[solid_id][1] radius = surf.Cylinder().Radius() angular_span = abs(surf.LastUParameter() - surf.FirstUParameter()) boundary_edges = len(list(TopologyExplorer(self.faces[face_id], ignore_orientation=True).edges())) solid_diagonal = _shape_diagonal(solid) is_partial_cylinder = angular_span < math.tau * 0.92 is_small_radius = solid_diagonal > 0 and radius <= solid_diagonal * 0.04 samples = self._sample_cylinder_material_states(surf, solid, detailed=detailed) sample_count = len(samples) if sample_count == 0: return { "feature_guess": "cylindrical face", "toward_axis": "unknown", "away_axis": "unknown", "vote_summary": "hole=0, boss=0, unclear=0", "sample_count": 0, "confidence": "low", "note": "could not sample cylinder material sides", } toward_states = [sample["toward"] for sample in samples] away_states = [sample["away"] for sample in samples] hole_votes = sum(1 for sample in samples if sample["toward"] == "outside" and sample["away"] == "inside") boss_votes = sum(1 for sample in samples if sample["toward"] == "inside" and sample["away"] == "outside") unclear_votes = sample_count - hole_votes - boss_votes vote_summary = f"hole={hole_votes}, boss={boss_votes}, unclear={unclear_votes}" threshold = max(1, math.ceil(sample_count * 0.6)) base = { "toward_axis": _state_summary(toward_states), "away_axis": _state_summary(away_states), "vote_summary": vote_summary, "sample_count": sample_count, } if hole_votes >= threshold: confidence = "high" if hole_votes == sample_count and not is_partial_cylinder else "medium" return { "feature_guess": "hole/groove candidate", "confidence": confidence, "note": "axis side is mostly empty and outer side is mostly material", **base, } if is_partial_cylinder and is_small_radius: return { "feature_guess": "round/fillet candidate", "confidence": "medium" if boundary_edges >= 4 else "low", "note": "partial small-radius cylinder; may be a fillet or blend", **base, } if boss_votes >= threshold: return { "feature_guess": "boss/outer-round candidate", "confidence": "high" if boss_votes == sample_count and not is_partial_cylinder else "medium", "note": "axis side is mostly material and outer side is mostly empty", **base, } return { "feature_guess": "cylindrical face", "confidence": "low", "note": "material sampling did not produce a clear inside/outside pattern", **base, } def _sample_cylinder_material_states( self, surf: BRepAdaptor_Surface, solid: TopoDS_Shape, detailed: bool = False, ) -> list[dict[str, str]]: cyl = surf.Cylinder() axis = cyl.Axis() axis_point = axis.Location() axis_dir = axis.Direction() radius = cyl.Radius() u_first = surf.FirstUParameter() u_last = surf.LastUParameter() v = (surf.FirstVParameter() + surf.LastVParameter()) / 2.0 u_span = u_last - u_first fractions = [0.5] if detailed and abs(u_span) > 0.2: fractions = [0.25, 0.5, 0.75] samples: list[dict[str, str]] = [] for fraction in fractions: u = u_first + u_span * fraction point = surf.Value(u, v) axis_to_point = _vec_from_points(axis_point, point) projection = _dot(axis_to_point, axis_dir) center = gp_Pnt( axis_point.X() + axis_dir.X() * projection, axis_point.Y() + axis_dir.Y() * projection, axis_point.Z() + axis_dir.Z() * projection, ) radial = _vec_from_points(center, point) radial_len = radial.Magnitude() if radial_len <= 1e-9: continue unit = gp_Vec(radial.X() / radial_len, radial.Y() / radial_len, radial.Z() / radial_len) epsilon = min(max(radius * 0.03, 0.05), 1.0) toward_point = gp_Pnt( point.X() - unit.X() * epsilon, point.Y() - unit.Y() * epsilon, point.Z() - unit.Z() * epsilon, ) away_point = gp_Pnt( point.X() + unit.X() * epsilon, point.Y() + unit.Y() * epsilon, point.Z() + unit.Z() * epsilon, ) samples.append( { "toward": _solid_state(solid, toward_point), "away": _solid_state(solid, away_point), } ) return samples def _plane_push_pull_direction(self, face_id: int, surf: BRepAdaptor_Surface) -> dict[str, object]: face = self.faces[face_id] direction = surf.Plane().Axis().Direction() axis_tuple = _dir_tuple(direction) oriented_tuple = _oriented_dir_tuple(direction, face) fallback = { "outward_direction": oriented_tuple, "inward_direction": _neg_tuple(oriented_tuple), "plus_side_state": "unknown", "minus_side_state": "unknown", "confidence": "low", "note": "falling back to topology-oriented plane normal", } solid_id = self.face_solid_ids[face_id] if solid_id < 0 or solid_id >= len(self.solids): fallback["note"] = "no owning solid was found; using topology-oriented plane normal" return fallback solid = self.solids[solid_id][1] props = GProp_GProps() brepgprop.SurfaceProperties(face, props) sample = props.CentreOfMass() diagonal = _shape_diagonal(solid) epsilon = min(max(diagonal * 1e-4, 0.05), 1.0) plus_point = gp_Pnt( sample.X() + direction.X() * epsilon, sample.Y() + direction.Y() * epsilon, sample.Z() + direction.Z() * epsilon, ) minus_point = gp_Pnt( sample.X() - direction.X() * epsilon, sample.Y() - direction.Y() * epsilon, sample.Z() - direction.Z() * epsilon, ) plus_state = _solid_state(solid, plus_point) minus_state = _solid_state(solid, minus_point) if plus_state == "outside" and minus_state == "inside": return { "outward_direction": axis_tuple, "inward_direction": _neg_tuple(axis_tuple), "plus_side_state": plus_state, "minus_side_state": minus_state, "confidence": "high", "note": "positive plane normal side is outside material", } if plus_state == "inside" and minus_state == "outside": return { "outward_direction": _neg_tuple(axis_tuple), "inward_direction": axis_tuple, "plus_side_state": plus_state, "minus_side_state": minus_state, "confidence": "high", "note": "negative plane normal side is outside material", } fallback["plus_side_state"] = plus_state fallback["minus_side_state"] = minus_state fallback["note"] = "inside/outside sampling was unclear; using topology-oriented plane normal" return fallback def export_all(self, filename: str | Path) -> None: _write_step(self.shape, Path(filename)) def export_part(self, part_id: int, filename: str | Path) -> None: part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") _write_step(part.shape, Path(filename)) def export_solid(self, solid_id: int, filename: str | Path) -> None: if solid_id < 0 or solid_id >= len(self.solids): raise ValueError(f"Unknown solid id {solid_id}") _write_step(self.solids[solid_id][1], Path(filename)) def export_face(self, face_id: int, filename: str | Path) -> None: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") _write_step(self.faces[face_id], Path(filename)) def push_pull_preview_polydata(self, face_id: int, distance: float, deflection: float = 0.8): face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: raise ValueError("Push/pull preview currently supports planar faces only.") direction_info = self._plane_push_pull_direction(face_id, surf) outward = direction_info["outward_direction"] vec = gp_Vec( float(outward[0]) * distance, float(outward[1]) * distance, float(outward[2]) * distance, ) preview_shape = BRepPrimAPI_MakePrism(face, vec).Shape() BRepMesh_IncrementalMesh(preview_shape, deflection) return _shape_faces_polydata(preview_shape) def push_pull_face(self, face_id: int, distance: float) -> str: face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: raise ValueError("Push/pull currently supports planar faces only.") direction_info = self._plane_push_pull_direction(face_id, surf) outward = direction_info["outward_direction"] vec = gp_Vec( float(outward[0]) * distance, float(outward[1]) * distance, float(outward[2]) * distance, ) tool_shape = BRepPrimAPI_MakePrism(face, vec).Shape() 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}") op = BRepAlgoAPI_Fuse(part.shape, tool_shape) if distance >= 0 else BRepAlgoAPI_Cut(part.shape, tool_shape) op.Build() if not op.IsDone(): raise RuntimeError("Boolean operation failed.") result = op.Shape() _ensure_valid_shape(result) part.shape = result self.refresh_topology() action = "fused outward prism" if distance >= 0 else "cut inward prism" return ( "Planar face push/pull completed: " f"{action}, semantic_distance={distance:g}, " f"outward_direction={_format_tuple(outward)}, " f"direction_confidence={direction_info['confidence']}." ) def enlarge_cylindrical_hole(self, face_id: int, new_diameter: float) -> str: return self.resize_cylindrical_hole(face_id, new_diameter) def resize_cylindrical_hole(self, face_id: int, new_diameter: float) -> str: plan = self.cylindrical_resize_plan(face_id, new_diameter) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: raise ValueError("Hole resize currently supports cylindrical faces only.") cyl = surf.Cylinder() old_radius = cyl.Radius() new_radius = new_diameter / 2.0 if new_radius <= 0: raise ValueError("Target diameter must be greater than 0.") part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") direction = cyl.Axis().Direction() 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) fuse.Build() if not fuse.IsDone(): raise RuntimeError("Cylinder fill/fuse failed.") source_shape = fuse.Shape() _ensure_valid_shape(source_shape) 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) op.Build() if not op.IsDone(): raise RuntimeError("Cylinder cut failed.") result = op.Shape() _ensure_valid_shape(result) part.shape = result self.refresh_topology() 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}." ) def build_face_polydata( self, face_ids: Iterable[int] | None = None, part_ids: Iterable[int] | None = None, deflection: float = 0.8, ): import vtk selected_faces = set(face_ids) if face_ids is not None else None selected_parts = set(part_ids) if part_ids is not None else None BRepMesh_IncrementalMesh(self.shape, deflection) points = vtk.vtkPoints() polys = vtk.vtkCellArray() face_arr = vtk.vtkIntArray() face_arr.SetName("face_id") part_arr = vtk.vtkIntArray() part_arr.SetName("part_id") solid_arr = vtk.vtkIntArray() solid_arr.SetName("solid_id") for face_id, face in enumerate(self.faces): part_id = self.face_part_ids[face_id] if selected_faces is not None and face_id not in selected_faces: continue if selected_parts is not None and part_id not in selected_parts: continue 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) face_arr.InsertNextValue(face_id) part_arr.InsertNextValue(part_id) solid_arr.InsertNextValue(self.face_solid_ids[face_id]) poly = vtk.vtkPolyData() poly.SetPoints(points) poly.SetPolys(polys) poly.GetCellData().AddArray(face_arr) poly.GetCellData().AddArray(part_arr) poly.GetCellData().AddArray(solid_arr) return poly def build_snapshot_polydata(self, snapshot: dict[int, TopoDS_Shape], deflection: float = 0.8): shape = _compound_from_shapes(snapshot.values()) BRepMesh_IncrementalMesh(shape, deflection) return _shape_faces_polydata(shape) def build_edge_polydata(self, deflection: float = 0.8): import vtk points = vtk.vtkPoints() lines = vtk.vtkCellArray() edge_arr = vtk.vtkIntArray() edge_arr.SetName("edge_id") part_arr = vtk.vtkIntArray() part_arr.SetName("part_id") for edge_id, edge in enumerate(self.edges): samples = discretize_edge(edge, deflection) if len(samples) < 2: continue polyline = vtk.vtkPolyLine() polyline.GetPointIds().SetNumberOfIds(len(samples)) for i, coords in enumerate(samples): point_id = points.InsertNextPoint(float(coords[0]), float(coords[1]), float(coords[2])) polyline.GetPointIds().SetId(i, point_id) lines.InsertNextCell(polyline) edge_arr.InsertNextValue(edge_id) part_arr.InsertNextValue(self.edge_part_ids[edge_id]) poly = vtk.vtkPolyData() poly.SetPoints(points) poly.SetLines(lines) poly.GetCellData().AddArray(edge_arr) poly.GetCellData().AddArray(part_arr) return poly def _load_with_xcaf(path: Path, product_names: list[str]) -> tuple[list[PartNode], TopoDS_Shape]: doc = TDocStd_Document("pythonocc-step-document") shape_tool = XCAFDoc_DocumentTool.ShapeTool(doc.Main()) reader = STEPCAFControl_Reader() reader.SetColorMode(True) reader.SetLayerMode(True) reader.SetNameMode(True) reader.SetMatMode(True) reader.SetGDTMode(True) status = reader.ReadFile(str(path)) if status != IFSelect_RetDone: raise ValueError(f"Could not read STEP file: {path}") if not reader.Transfer(doc): raise ValueError(f"Could not transfer STEP document: {path}") parts: list[PartNode] = [] free_shapes = TDF_LabelSequence() shape_tool.GetFreeShapes(free_shapes) def next_name(label: TDF_Label, index: int) -> str: label_name = str(label.GetLabelName()).strip() if label_name: return label_name if index - 1 < len(product_names): return product_names[index - 1] return f"Part {index}" def add_node( name: str, kind: str, shape: TopoDS_Shape, parent_id: int | None, depth: int, path_text: str, ) -> PartNode: node = PartNode(len(parts) + 1, name, kind, shape, parent_id, depth, path_text) parts.append(node) return node def transformed_shape(label: TDF_Label, locations: list[TopLoc_Location]) -> TopoDS_Shape: shape = shape_tool.GetShape(label) if shape.IsNull() or not locations: return shape location = TopLoc_Location() for loc in locations: location = location.Multiplied(loc) return BRepBuilderAPI_Transform(shape, location.Transformation()).Shape() def walk(label: TDF_Label, parent_id: int | None, depth: int, locations: list[TopLoc_Location], path_names: list[str]): name = next_name(label, len(parts) + 1) label_path = " / ".join(path_names + [name]) if shape_tool.IsAssembly(label): node = add_node(name, "assembly", transformed_shape(label, locations), parent_id, depth, label_path) components = TDF_LabelSequence() shape_tool.GetComponents(label, components) for i in range(1, components.Length() + 1): component = components.Value(i) if shape_tool.IsReference(component): referred = TDF_Label() shape_tool.GetReferredShape(component, referred) loc = shape_tool.GetLocation(component) walk(referred, node.id, depth + 1, locations + [loc], path_names + [name]) else: walk(component, node.id, depth + 1, locations, path_names + [name]) return if shape_tool.IsSimpleShape(label) or shape_tool.IsShape(label): add_node(name, "part", transformed_shape(label, locations), parent_id, depth, label_path) for i in range(1, free_shapes.Length() + 1): walk(free_shapes.Value(i), None, 0, [], []) display_shapes = [p.shape for p in parts if p.kind == "part" and not p.shape.IsNull()] if not display_shapes: display_shapes = [p.shape for p in parts if not p.shape.IsNull()] return parts, _compound_from_shapes(display_shapes) def _load_plain_step(path: Path) -> TopoDS_Shape: reader = STEPControl_Reader() status = reader.ReadFile(str(path)) if status != IFSelect_RetDone: raise ValueError(f"Could not read STEP file: {path}") if not reader.TransferRoots(): raise ValueError(f"Could not transfer STEP roots: {path}") return reader.Shape() def _parse_product_names(path: Path) -> list[str]: text = path.read_text(errors="ignore") names = re.findall(r"PRODUCT\('((?:''|[^'])*)'", text) return [name.replace("''", "'") for name in names if name.strip()] def _write_step(shape: TopoDS_Shape, filename: Path) -> None: if shape.IsNull(): raise ValueError("Cannot export a null shape.") filename.parent.mkdir(parents=True, exist_ok=True) Interface_Static.SetCVal("write.step.schema", "AP214IS") writer = STEPControl_Writer() writer.Transfer(shape, STEPControl_AsIs) status = writer.Write(str(filename)) if status != IFSelect_RetDone: raise IOError(f"Could not write STEP file: {filename}") 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 _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]: box = Bnd_Box() brepbndlib.Add(shape, box) return box.Get() 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 _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.") 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)) 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("目标直径与当前直径几乎相同,不需要修改。") elif 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 _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 _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 _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 _dot(vec: gp_Vec, direction) -> float: return vec.X() * direction.X() + vec.Y() * direction.Y() + vec.Z() * direction.Z()