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_Defeaturing, 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.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet from OCC.Core.BRepGProp import brepgprop from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism from OCC.Core.Bnd import Bnd_Box from OCC.Core.GeomAbs import ( GeomAbs_BSplineCurve, GeomAbs_BSplineSurface, GeomAbs_BezierCurve, GeomAbs_BezierSurface, GeomAbs_Circle, GeomAbs_Cone, GeomAbs_Cylinder, GeomAbs_Ellipse, GeomAbs_Hyperbola, GeomAbs_Line, GeomAbs_OffsetSurface, GeomAbs_OtherCurve, GeomAbs_OtherSurface, GeomAbs_Parabola, GeomAbs_Plane, GeomAbs_Sphere, GeomAbs_SurfaceOfExtrusion, GeomAbs_SurfaceOfRevolution, GeomAbs_Torus, ) from OCC.Core.GProp import GProp_GProps from OCC.Core.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.ShapeFix import ShapeFix_Shape from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain 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, 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.XCAFDoc import XCAFDoc_DocumentTool from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge 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.edge_solid_ids: list[int] = [] self.solids: list[tuple[int, TopoDS_Shape]] = [] self._face_info_cache: dict[int, dict[str, object]] = {} self._edge_info_cache: dict[int, dict[str, object]] = {} self._face_edge_ids_cache: dict[int, list[int]] = {} self._edge_face_ids_cache: dict[int, list[int]] = {} 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 part_topology_stats(self, part_id: int) -> TopologyStats: 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) return TopologyStats( parts=1, 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.edge_solid_ids.clear() self.solids.clear() self._face_info_cache.clear() self._edge_info_cache.clear() self._face_edge_ids_cache.clear() self._edge_face_ids_cache.clear() solid_id = 0 for part in self.display_parts(): part_solids = _explore(part.shape, TopAbs_SOLID) part_solid_edge_maps: list[tuple[int, TopTools_IndexedDataMapOfShapeListOfShape]] = [] part_solid_entries: list[tuple[int, TopoDS_Shape]] = [] if part_solids: for solid in part_solids: current_solid_id = solid_id self.solids.append((part.id, solid)) part_solid_entries.append((current_solid_id, solid)) edge_map = TopTools_IndexedDataMapOfShapeListOfShape() topexp.MapShapesAndAncestors(solid, TopAbs_EDGE, TopAbs_SOLID, edge_map) part_solid_edge_maps.append((current_solid_id, edge_map)) solid_id += 1 part_edge_map = TopTools_IndexedMapOfShape() topexp.MapShapes(part.shape, TopAbs_EDGE, part_edge_map) part_edge_ids_by_index: dict[int, int] = {} for local_edge_index in range(1, part_edge_map.Size() + 1): edge = part_edge_map.FindKey(local_edge_index) edge_id = len(self.edges) part_edge_ids_by_index[local_edge_index] = edge_id self.edges.append(edge) self.edge_part_ids.append(part.id) self.edge_solid_ids.append(_mapped_edge_solid_id(edge, part_solid_edge_maps)) self._edge_face_ids_cache[edge_id] = [] if part_solids: for current_solid_id, solid in part_solid_entries: for face in _explore(solid, TopAbs_FACE): face_id = len(self.faces) self.faces.append(face) self.face_part_ids.append(part.id) self.face_solid_ids.append(current_solid_id) self._cache_face_edge_links(face_id, face, part_edge_map, part_edge_ids_by_index) else: for face in _explore(part.shape, TopAbs_FACE): face_id = len(self.faces) self.faces.append(face) self.face_part_ids.append(part.id) self.face_solid_ids.append(-1) self._cache_face_edge_links(face_id, face, part_edge_map, part_edge_ids_by_index) def _cache_face_edge_links( self, face_id: int, face: TopoDS_Shape, part_edge_map: TopTools_IndexedMapOfShape, part_edge_ids_by_index: dict[int, int], ) -> None: face_edge_ids: list[int] = [] face_edge_map = TopTools_IndexedMapOfShape() topexp.MapShapes(face, TopAbs_EDGE, face_edge_map) for local_face_edge_index in range(1, face_edge_map.Size() + 1): local_part_edge_index = part_edge_map.FindIndex(face_edge_map.FindKey(local_face_edge_index)) edge_id = part_edge_ids_by_index.get(local_part_edge_index) if edge_id is None: continue face_edge_ids.append(edge_id) self._edge_face_ids_cache.setdefault(edge_id, []).append(face_id) self._face_edge_ids_cache[face_id] = face_edge_ids 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]: if face_id in self._face_info_cache: return dict(self._face_info_cache[face_id]) 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)) info.update(_cylinder_boss_resize_readiness(info)) info.update(_cylinder_depth_readiness(info)) info.update(_cylinder_suppress_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() self._face_info_cache[face_id] = dict(info) return dict(info) def feature_info(self, face_id: int) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) surface = str(info.get("surface", "")) if surface == "cylinder": return self._cylindrical_feature_info(face_id, info) if surface == "plane": return self._planar_feature_info(face_id, info) boundary_edge_ids = self._face_boundary_edge_ids(face_id) result = dict(info) result.update( { "kind": "feature", "feature_type": "暂不支持的局部曲面候选", "feature_source_face_id": face_id, "feature_face_ids": (face_id,), "feature_highlight_face_ids": (face_id,), "feature_boundary_edge_ids": tuple(boundary_edge_ids), "feature_edit_actions": "当前第一版只能查看该局部曲面,暂不支持直接编辑。", "feature_mode": "Feature 模式会把选中的 face 解释为局部几何特征候选。", } ) return result def _planar_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]: coplanar_face_ids = self._connected_coplanar_planar_face_ids(face_id) boundary_edge_ids = self._region_boundary_edge_ids(coplanar_face_ids) if len(coplanar_face_ids) > 1: scope_note = f"已检测到 {len(coplanar_face_ids)} 个共享边且共面的 face,推拉时会作为同一片平面区域处理。" else: scope_note = "当前 face 没有检测到可一起推拉的共享边共面邻居。" result = dict(info) result.update( { "kind": "feature", "feature_type": "可推拉平面候选", "feature_source_face_id": face_id, "feature_face_ids": tuple(coplanar_face_ids), "feature_highlight_face_ids": tuple(coplanar_face_ids), "feature_boundary_edge_ids": tuple(boundary_edge_ids), "feature_adjacent_face_ids": tuple( sorted(set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - set(coplanar_face_ids)) ), "push_pull_scope_face_ids": tuple(coplanar_face_ids), "push_pull_scope_face_count": len(coplanar_face_ids), "push_pull_scope_note": scope_note, "feature_edit_actions": "推拉平面", "feature_mode": "这是从 B-Rep 几何推断出的平面编辑候选,不是 CAD 历史特征。", } ) return result def _cylindrical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]: boundary_edge_ids = self._face_boundary_edge_ids(face_id) adjacent_face_ids = self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id) end_faces = self._cylindrical_end_face_groups(face_id, adjacent_face_ids, info) end_face_ids = end_faces["end_face_ids"] bottom_face_ids = end_faces["bottom_face_ids"] opening_face_ids = end_faces["opening_face_ids"] slot_info = self._cylindrical_slot_info(face_id, adjacent_face_ids, end_face_ids, info) fillet_info = self._cylindrical_existing_fillet_info(face_id, adjacent_face_ids, end_face_ids, info) guess = str(info.get("feature_guess", "cylindrical face")) angular_span = float(info.get("angular_span", 0.0)) if guess == "hole/groove candidate": if angular_span < math.tau * 0.92: feature_type = "槽/半孔候选" else: feature_type = "圆柱孔候选" if info.get("cylinder_end_type") == "blind" and bottom_face_ids: edit_actions = "调整圆柱孔径;调整盲孔深度" else: edit_actions = "调整圆柱孔径;孔深调整需要明确盲孔底面" if angular_span >= math.tau * 0.92: edit_actions += ";封堵圆柱孔" elif guess == "round/fillet candidate": feature_type = "圆角/倒圆候选" edit_actions = "当前可识别已有圆角半径和相邻支撑面;已有圆角半径修改尚未实现。" elif guess == "boss/outer-round candidate": feature_type = "凸台/外圆候选" edit_actions = "调整圆柱凸台直径。" else: feature_type = "未明确圆柱特征" edit_actions = "可尝试调整圆柱孔径,但风险较高。" highlight_face_ids = tuple( sorted( { face_id, *end_face_ids, *slot_info.get("feature_slot_boundary_face_ids", ()), *fillet_info.get("feature_existing_fillet_support_face_ids", ()), } ) ) result = dict(info) result.update( { "kind": "feature", "feature_type": feature_type, "feature_source_face_id": face_id, "feature_face_ids": (face_id,), "feature_side_face_ids": (face_id,), "feature_end_face_ids": tuple(end_face_ids), "feature_bottom_face_ids": tuple(bottom_face_ids), "feature_opening_face_ids": tuple(opening_face_ids), "feature_highlight_face_ids": highlight_face_ids, "feature_boundary_edge_ids": tuple(boundary_edge_ids), "feature_adjacent_face_ids": tuple(adjacent_face_ids), "feature_start_end_face_ids": tuple(end_faces["start_end_face_ids"]), "feature_end_end_face_ids": tuple(end_faces["end_end_face_ids"]), "feature_bottom_confidence": end_faces["bottom_confidence"], "feature_bottom_detection": end_faces["bottom_detection"], "feature_bottom_note": end_faces["bottom_note"], "feature_edit_actions": edit_actions, "feature_mode": "这是从 B-Rep 圆柱面、相邻面和材料采样推断出的局部特征候选。", **slot_info, **fillet_info, } ) return result def _cylindrical_slot_info( self, face_id: int, adjacent_face_ids: list[int], end_face_ids: Iterable[int], info: dict[str, object], ) -> dict[str, object]: guess = str(info.get("feature_guess", "cylindrical face")) angular_span = float(info.get("angular_span", 0.0)) if guess != "hole/groove candidate" or angular_span >= math.tau * 0.92: return {} radius = max(float(info.get("radius", 0.0)), 0.0) span = min(max(angular_span, 0.0), math.tau) boundary_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids)) chord_width = 2.0 * radius * math.sin(span / 2.0) if radius > 0 else 0.0 sagitta_depth = radius * (1.0 - math.cos(min(span, math.pi) / 2.0)) if radius > 0 else 0.0 return { "slot_kind": "partial-cylindrical-groove", "slot_status": "candidate", "slot_angular_span": angular_span, "slot_open_angle": max(math.tau - span, 0.0), "slot_chord_width_estimate": chord_width, "slot_arc_length_estimate": radius * span, "slot_sagitta_depth_estimate": sagitta_depth, "feature_slot_face_ids": (face_id,), "feature_slot_boundary_face_ids": tuple(boundary_face_ids), "slot_note": ( "这是由局部圆柱面推断出的槽/半孔候选;宽度和深度是几何估算," "不是 CAD 历史里的参数。" ), } def _cylindrical_existing_fillet_info( self, face_id: int, adjacent_face_ids: list[int], end_face_ids: Iterable[int], info: dict[str, object], ) -> dict[str, object]: if str(info.get("feature_guess", "cylindrical face")) != "round/fillet candidate": return {} radius = max(float(info.get("radius", 0.0)), 0.0) angular_span = min(max(float(info.get("angular_span", 0.0)), 0.0), math.tau) support_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids)) return { "existing_fillet_kind": "cylindrical-round-face", "existing_fillet_status": "candidate", "existing_fillet_radius_estimate": radius, "existing_fillet_angular_span": angular_span, "existing_fillet_arc_length_estimate": radius * angular_span, "feature_existing_fillet_face_ids": (face_id,), "feature_existing_fillet_support_face_ids": tuple(support_face_ids), "existing_fillet_note": ( "这是由局部小半径圆柱面推断出的已有圆角/倒圆候选;" "第一版可尝试使用 defeature + 重新倒圆修改半径;" "复杂 blend 或支撑面不明确时可能失败并回滚。" ), } def _cylindrical_end_face_groups( self, face_id: int, adjacent_face_ids: list[int], info: dict[str, object], ) -> dict[str, object]: axis_point_values = info.get("axis_point") axis_values = info.get("axis") v_range = info.get("v_range") if not isinstance(axis_point_values, tuple) or not isinstance(axis_values, tuple) or not isinstance(v_range, tuple): return { "end_face_ids": [], "start_end_face_ids": [], "end_end_face_ids": [], "bottom_face_ids": [], "opening_face_ids": [], "bottom_note": "缺少圆柱轴线或参数范围,无法判断端面/底面。", } axis_point = gp_Pnt(*axis_point_values) axis_dir = gp_Dir(float(axis_values[0]), float(axis_values[1]), float(axis_values[2])) v_min = min(float(v_range[0]), float(v_range[1])) v_max = max(float(v_range[0]), float(v_range[1])) span = max(v_max - v_min, 1e-9) radius = float(info.get("radius", 0.0)) tolerance = max(span * 0.08, radius * 0.2, 0.05) start_end_face_ids: list[int] = [] end_end_face_ids: list[int] = [] for adjacent_id in adjacent_face_ids: match = self._axis_end_match_for_planar_face( adjacent_id, axis_point, axis_dir, v_min, v_max, tolerance, radial_tolerance=None, ) if match == "start": start_end_face_ids.append(adjacent_id) elif match == "end": end_end_face_ids.append(adjacent_id) if info.get("cylinder_end_type") == "blind" and (not start_end_face_ids or not end_end_face_ids): scanned = self._axis_cap_face_candidates( face_id, axis_point, axis_dir, v_min, v_max, radius, span, set(adjacent_face_ids), ) if not start_end_face_ids: start_end_face_ids.extend(scanned["start"]) if not end_end_face_ids: end_end_face_ids.extend(scanned["end"]) bottom_face_ids: list[int] = [] opening_face_ids: list[int] = [] if info.get("start_end_open") is True: opening_face_ids.extend(start_end_face_ids) elif info.get("start_end_state") == "inside": bottom_face_ids.extend(start_end_face_ids) if info.get("end_end_open") is True: opening_face_ids.extend(end_end_face_ids) elif info.get("end_end_state") == "inside": bottom_face_ids.extend(end_end_face_ids) end_face_ids = sorted({*start_end_face_ids, *end_end_face_ids}) bottom_face_ids = sorted(set(bottom_face_ids)) opening_face_ids = sorted(set(opening_face_ids)) bottom_detection = "axis-cap-scan" if any( face_id not in adjacent_face_ids for face_id in bottom_face_ids ) else "adjacent-end-face" bottom_confidence = "medium" if bottom_detection == "axis-cap-scan" else "high" if not end_face_ids: note = "没有在圆柱边界附近找到平面端面。" elif bottom_face_ids: if bottom_detection == "axis-cap-scan": note = "已通过轴线端部采样和轴线附近圆盘面扫描标记疑似底面;这是几何推断,不是 CAD 历史孔深。" else: note = "已根据圆柱轴线端部 inside/outside 采样标记疑似底面;这是几何推断,不是 CAD 历史孔深。" else: note = "已找到端面候选,但端部采样显示这些端面更像开口附近的相邻面。" return { "end_face_ids": end_face_ids, "start_end_face_ids": sorted(set(start_end_face_ids)), "end_end_face_ids": sorted(set(end_end_face_ids)), "bottom_face_ids": bottom_face_ids, "opening_face_ids": opening_face_ids, "bottom_confidence": bottom_confidence if bottom_face_ids else "none", "bottom_detection": bottom_detection if bottom_face_ids else "none", "bottom_note": note, } def _axis_end_match_for_planar_face( self, face_id: int, axis_point: gp_Pnt, axis_dir: gp_Dir, v_min: float, v_max: float, tolerance: float, radial_tolerance: float | None, ) -> str | None: surf = BRepAdaptor_Surface(self.faces[face_id]) if surf.GetType() != GeomAbs_Plane: return None normal = surf.Plane().Axis().Direction() if abs(_direction_dot(normal, axis_dir)) < 0.65: return None if radial_tolerance is not None: center = _surface_center(self.faces[face_id]) if _point_axis_distance(axis_point, axis_dir, center) > radial_tolerance: return None parameters = _shape_axis_parameters(self.faces[face_id], axis_point, axis_dir) if not parameters: return None start_distance = min(abs(parameter - v_min) for parameter in parameters) end_distance = min(abs(parameter - v_max) for parameter in parameters) if min(start_distance, end_distance) > tolerance: return None return "start" if start_distance <= end_distance else "end" def _axis_cap_face_candidates( self, face_id: int, axis_point: gp_Pnt, axis_dir: gp_Dir, v_min: float, v_max: float, radius: float, span: float, adjacent_face_ids: set[int], ) -> dict[str, list[int]]: source_solid_id = self.face_solid_ids[face_id] tolerance = max(span * 0.12, radius * 0.35, 0.08) radial_tolerance = max(radius * 1.2, tolerance) start: list[int] = [] end: list[int] = [] for candidate_id in range(len(self.faces)): if candidate_id == face_id or candidate_id in adjacent_face_ids: continue if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id: continue match = self._axis_end_match_for_planar_face( candidate_id, axis_point, axis_dir, v_min, v_max, tolerance, radial_tolerance=radial_tolerance, ) if match == "start": start.append(candidate_id) elif match == "end": end.append(candidate_id) return {"start": sorted(set(start)), "end": sorted(set(end))} def _bottom_face_axis_parameter( self, bottom_face_ids: Iterable[int], axis_point: gp_Pnt, axis_dir: gp_Dir, expected_parameter: float, ) -> float | None: candidates: list[float] = [] for bottom_face_id in bottom_face_ids: if bottom_face_id < 0 or bottom_face_id >= len(self.faces): continue parameters = _shape_axis_parameters(self.faces[bottom_face_id], axis_point, axis_dir) if not parameters: continue candidates.append(sum(parameters) / len(parameters)) if not candidates: return None return min(candidates, key=lambda parameter: abs(parameter - expected_parameter)) def _face_boundary_edge_ids(self, face_id: int) -> list[int]: if face_id in self._face_edge_ids_cache: return list(self._face_edge_ids_cache[face_id]) if face_id < 0 or face_id >= len(self.faces): return [] face_edges = list(TopologyExplorer(self.faces[face_id], ignore_orientation=True).edges()) edge_ids: list[int] = [] for edge_id, edge in enumerate(self.edges): if any(_same_shape(edge, face_edge) for face_edge in face_edges): edge_ids.append(edge_id) self._face_edge_ids_cache[face_id] = list(edge_ids) return edge_ids def face_boundary_edge_ids(self, face_id: int) -> list[int]: if face_id < 0 or face_id >= len(self.faces): return [] return self._face_boundary_edge_ids(face_id) def nearest_edge_id_to_point( self, edge_ids: Iterable[int], point: tuple[float, float, float] | None, ) -> int | None: valid_edge_ids = [int(edge_id) for edge_id in edge_ids if 0 <= int(edge_id) < len(self.edges)] if not valid_edge_ids: return None if point is None: return valid_edge_ids[0] px, py, pz = (float(point[0]), float(point[1]), float(point[2])) best_edge_id: int | None = None best_distance = math.inf for edge_id in valid_edge_ids: try: samples = discretize_edge(self.edges[edge_id], 0.35) except Exception: samples = [] if len(samples) < 2: try: curve = BRepAdaptor_Curve(self.edges[edge_id]) samples = [ _point_tuple(curve.Value(curve.FirstParameter())), _point_tuple(curve.Value(curve.LastParameter())), ] except Exception: samples = [] if not samples: continue sample_points = [(float(coords[0]), float(coords[1]), float(coords[2])) for coords in samples] if len(sample_points) == 1: distance = _point_distance_sq((px, py, pz), sample_points[0]) else: distance = min( _point_segment_distance_sq((px, py, pz), start, end) for start, end in zip(sample_points, sample_points[1:]) ) if distance < best_distance: best_distance = distance best_edge_id = edge_id return best_edge_id if best_edge_id is not None else valid_edge_ids[0] def _adjacent_face_ids_for_edges(self, edge_ids: list[int], face_id: int) -> list[int]: if not edge_ids: return [] target_edges = [self.edges[edge_id] for edge_id in edge_ids if 0 <= edge_id < len(self.edges)] source_solid_id = self.face_solid_ids[face_id] adjacent: list[int] = [] for candidate_id, candidate in enumerate(self.faces): if candidate_id == face_id: continue if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id: continue candidate_edges = TopologyExplorer(candidate, ignore_orientation=True).edges() if any(_same_shape(candidate_edge, target_edge) for candidate_edge in candidate_edges for target_edge in target_edges): adjacent.append(candidate_id) return adjacent def _connected_coplanar_planar_face_ids(self, face_id: int) -> list[int]: if face_id < 0 or face_id >= len(self.faces): return [] source_surf = BRepAdaptor_Surface(self.faces[face_id]) if source_surf.GetType() != GeomAbs_Plane: return [face_id] source_solid_id = self.face_solid_ids[face_id] tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3) visited = {face_id} queue = [face_id] while queue: current_id = queue.pop(0) for adjacent_id in self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(current_id), current_id): if adjacent_id in visited: continue if source_solid_id >= 0 and self.face_solid_ids[adjacent_id] != source_solid_id: continue candidate_surf = BRepAdaptor_Surface(self.faces[adjacent_id]) if _surfaces_are_coplanar(source_surf, candidate_surf, tolerance): visited.add(adjacent_id) queue.append(adjacent_id) return sorted(visited) def _region_boundary_edge_ids(self, face_ids: Iterable[int]) -> list[int]: counts: dict[int, int] = {} for face_id in face_ids: for edge_id in self._face_boundary_edge_ids(face_id): counts[edge_id] = counts.get(edge_id, 0) + 1 return sorted(edge_id for edge_id, count in counts.items() if count == 1) def _push_pull_profile_shape(self, face_ids: Iterable[int]) -> TopoDS_Shape: profile_faces = [self.faces[face_id] for face_id in face_ids if 0 <= face_id < len(self.faces)] if not profile_faces: raise ValueError("No planar faces were found for push/pull.") return _unify_same_domain_shape(_compound_from_shapes(profile_faces)) def edge_info(self, edge_id: int) -> dict[str, object]: if edge_id in self._edge_info_cache: return dict(self._edge_info_cache[edge_id]) 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], "solid_id": self._edge_solid_id(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 adjacent_face_ids = self._edge_adjacent_face_ids(edge_id) info["adjacent_face_ids"] = tuple(adjacent_face_ids) info["adjacent_face_count"] = len(adjacent_face_ids) self._edge_info_cache[edge_id] = dict(info) return dict(info) def _edge_solid_id(self, edge_id: int) -> int: if edge_id < 0 or edge_id >= len(self.edges): return -1 return self.edge_solid_ids[edge_id] if edge_id < len(self.edge_solid_ids) else -1 def edge_ids_for_solid(self, solid_id: int) -> list[int]: if solid_id < 0 or solid_id >= len(self.solids): raise ValueError(f"Unknown solid id {solid_id}") return [edge_id for edge_id in range(len(self.edges)) if self._edge_solid_id(edge_id) == solid_id] def _edge_adjacent_face_ids(self, edge_id: int) -> list[int]: if edge_id < 0 or edge_id >= len(self.edges): return [] if edge_id in self._edge_face_ids_cache: return list(self._edge_face_ids_cache[edge_id]) edge = self.edges[edge_id] part_id = self.edge_part_ids[edge_id] solid_id = self._edge_solid_id(edge_id) adjacent: list[int] = [] for face_id, face in enumerate(self.faces): if self.face_part_ids[face_id] != part_id: continue if solid_id >= 0 and self.face_solid_ids[face_id] != solid_id: continue if any(_same_shape(candidate, edge) for candidate in TopologyExplorer(face, ignore_orientation=True).edges()): adjacent.append(face_id) return adjacent 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 // 5) candidates: list[dict[str, object]] = [] diameter_count = 0 boss_diameter_count = 0 depth_count = 0 suppress_count = 0 existing_fillet_count = 0 depth_limit = max(2, min(per_type_limit, limit // 12)) suppress_limit = max(2, min(per_type_limit, limit // 12)) existing_fillet_limit = max(2, min(per_type_limit, limit // 12)) cylinder_scan_limit = max(per_type_limit * 4, 24) for item in self.cylindrical_feature_candidates( limit=cylinder_scan_limit, include_end_info=True, progress_callback=progress_callback, ): feature_guess = str(item["feature_guess"]) if existing_fillet_count < existing_fillet_limit and feature_guess == "round/fillet candidate": feature = self.feature_info(int(item["face_id"])) support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ())) support_note = ( f"支撑 Face: {support_face_ids}。" if support_face_ids else "暂未识别出稳定支撑 Face。" ) candidates.append( { "operation_key": "inspect_existing_fillet", "operation": "修改已有圆角半径", "target_kind": "face", "target_id": item["face_id"], "face_id": item["face_id"], "part_id": item["part_id"], "solid_id": item["solid_id"], "surface": "cylinder", "feature_guess": feature_guess, "current_value": feature.get("existing_fillet_radius_estimate", item["radius"]), "current_value_label": "radius", "status": "caution", "risk": "medium" if len(support_face_ids) >= 2 else "high", "confidence": item["confidence"], "note": ( "这是已有圆角/倒圆候选;点击后会选中并预填目标半径," "再点击“修改已有圆角半径”会尝试 defeature 后重新倒圆。" f" {support_note}" ), } ) existing_fillet_count += 1 if diameter_count < per_type_limit and feature_guess != "round/fillet candidate": candidates.append( { "operation_key": "resize_cylinder", "operation": "调整圆柱孔径", "target_kind": "face", "target_id": item["face_id"], "face_id": item["face_id"], "part_id": item["part_id"], "solid_id": item["solid_id"], "surface": "cylinder", "feature_guess": feature_guess, "current_value": item["diameter"], "current_value_label": "diameter", "status": item["resize_status"], "risk": item["resize_risk"], "confidence": item["confidence"], "note": item["resize_note"], } ) diameter_count += 1 boss_info = _cylinder_boss_resize_readiness(item) if boss_diameter_count < per_type_limit and boss_info["boss_resize_status"] != "blocked": candidates.append( { "operation_key": "resize_boss", "operation": "调整圆柱凸台直径", "target_kind": "face", "target_id": item["face_id"], "face_id": item["face_id"], "part_id": item["part_id"], "solid_id": item["solid_id"], "surface": "cylinder", "feature_guess": item["feature_guess"], "current_value": item["diameter"], "current_value_label": "diameter", "status": boss_info["boss_resize_status"], "risk": boss_info["boss_resize_risk"], "confidence": item["confidence"], "note": boss_info["boss_resize_note"], } ) boss_diameter_count += 1 suppress_info = _cylinder_suppress_readiness(item) if suppress_count < suppress_limit and suppress_info["suppress_status"] != "blocked": candidates.append( { "operation_key": "suppress_cylinder", "operation": "封堵圆柱孔", "target_kind": "face", "target_id": item["face_id"], "face_id": item["face_id"], "part_id": item["part_id"], "solid_id": item["solid_id"], "surface": "cylinder", "feature_guess": item["feature_guess"], "current_value": item["diameter"], "current_value_label": "diameter", "status": suppress_info["suppress_status"], "risk": suppress_info["suppress_risk"], "confidence": item["confidence"], "note": suppress_info["suppress_note"], } ) suppress_count += 1 depth_info = _cylinder_depth_readiness(item) if depth_count < depth_limit and depth_info["depth_status"] != "blocked": feature = self.feature_info(int(item["face_id"])) bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ())) if not bottom_face_ids: continue depth_context = self._blind_cylindrical_depth_context( int(item["face_id"]), item, feature, float(item["hole_depth_estimate"]), ) current_depth = float(depth_context.get("depth_current_depth", item["hole_depth_estimate"])) candidates.append( { "operation_key": "resize_depth", "operation": "调整盲孔深度", "target_kind": "face", "target_id": item["face_id"], "face_id": item["face_id"], "part_id": item["part_id"], "solid_id": item["solid_id"], "surface": "cylinder", "feature_guess": item["feature_guess"], "current_value": current_depth, "current_value_label": "depth", "status": depth_info["depth_status"], "risk": depth_info["depth_risk"], "confidence": item["confidence"], "note": ( f"{depth_info['depth_note']} " f"底面: {bottom_face_ids}; " f"来源: {feature.get('feature_bottom_detection')}; " f"深度来源: {depth_context.get('depth_current_depth_source', 'cylinder-v-range')}。" ), } ) depth_count += 1 if ( diameter_count >= per_type_limit and boss_diameter_count >= per_type_limit and suppress_count >= suppress_limit and depth_count >= depth_limit and existing_fillet_count >= existing_fillet_limit ): break 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": "推拉平面", "target_kind": "face", "target_id": face_id, "face_id": face_id, "part_id": self.face_part_ids[face_id], "solid_id": self.face_solid_ids[face_id], "surface": "plane", "feature_guess": "planar push/pull candidate", "current_value": props.Mass(), "current_value_label": "area", "status": status, "risk": risk, "confidence": confidence, "note": note, } ) plane_count += 1 fillet_edge_count = 0 chamfer_edge_count = 0 edge_length_count = 0 edge_type_limit = max(1, per_type_limit // 3) for edge_id, edge in enumerate(self.edges): if progress_callback is not None and edge_id % 80 == 0: progress_callback() if ( fillet_edge_count >= edge_type_limit and chamfer_edge_count >= edge_type_limit and edge_length_count >= edge_type_limit ): break curve = BRepAdaptor_Curve(edge) if curve.GetType() != GeomAbs_Line: continue props = GProp_GProps() brepgprop.LinearProperties(edge, props) length = props.Mass() if length <= 1e-9: continue solid_id = self._edge_solid_id(edge_id) if fillet_edge_count < edge_type_limit: candidates.append( { "operation_key": "fillet_edge", "operation": "给边添加圆角", "target_kind": "edge", "target_id": edge_id, "edge_id": edge_id, "part_id": self.edge_part_ids[edge_id], "solid_id": solid_id, "surface": "edge", "feature_guess": "linear edge fillet candidate", "current_value": length, "current_value_label": "length", "status": "caution", "risk": "medium", "confidence": "pending", "note": "快速扫描:添加圆角半径会在执行前根据边长和相邻面再详细判断。", } ) fillet_edge_count += 1 if chamfer_edge_count < edge_type_limit: candidates.append( { "operation_key": "chamfer_edge", "operation": "给边添加倒角", "target_kind": "edge", "target_id": edge_id, "edge_id": edge_id, "part_id": self.edge_part_ids[edge_id], "solid_id": solid_id, "surface": "edge", "feature_guess": "linear edge chamfer candidate", "current_value": length, "current_value_label": "length", "status": "caution", "risk": "medium", "confidence": "pending", "note": "快速扫描:倒角距离会在执行前根据边长和相邻面再详细判断。", } ) chamfer_edge_count += 1 if edge_length_count < edge_type_limit: candidates.append( { "operation_key": "resize_edge_length", "operation": "调整直线边长度", "target_kind": "edge", "target_id": edge_id, "edge_id": edge_id, "part_id": self.edge_part_ids[edge_id], "solid_id": solid_id, "surface": "edge", "feature_guess": "linear edge length candidate", "current_value": length, "current_value_label": "length", "status": "caution", "risk": "medium", "confidence": "pending", "note": "快速扫描:第一版边长调整会在执行前尝试寻找可推拉的端面,找不到端面会阻止。", } ) edge_length_count += 1 status_order = {"ready": 0, "caution": 1, "blocked": 2} risk_order = {"low": 0, "medium": 1, "high": 2, "blocked": 3} operation_order = { "resize_cylinder": 0, "resize_boss": 1, "suppress_cylinder": 2, "resize_depth": 3, "inspect_existing_fillet": 4, "push_pull_plane": 5, "fillet_edge": 6, "chamfer_edge": 7, "resize_edge_length": 8, } candidates.sort( key=lambda item: ( status_order.get(str(item["status"]), 9), risk_order.get(str(item["risk"]), 9), operation_order.get(str(item["operation_key"]), 9), int(item.get("target_id", item.get("face_id", item.get("edge_id", -1)))), ) ) return candidates[:limit] def cylindrical_feature_candidates( self, limit: int = 100, include_end_info: bool = False, 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)) candidate.update(_cylinder_boss_resize_readiness(candidate)) candidates.append(candidate) if len(candidates) >= limit: break return candidates def cylindrical_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) if info.get("surface") != "cylinder" or "diameter" not in info: return { "status": "blocked", "risk": "blocked", "message": "当前选中的 face 不是圆柱面,不能执行圆柱切削。", } current_diameter = float(info["diameter"]) readiness = _cylinder_resize_readiness(info, new_diameter) resize_mode = _resize_mode(current_diameter, new_diameter) delta_diameter = new_diameter - current_diameter diameter_delta_ratio = abs(delta_diameter) / max(current_diameter, 1e-9) height_estimate = float(info.get("height_estimate", 0.0)) target_to_height_ratio = new_diameter / height_estimate if height_estimate > 1e-9 else "" feature = self.feature_info(face_id) cutter_plan = self._bounded_cylinder_cutter_plan(face_id, new_diameter, feature) fill_plan = self._bounded_cylinder_fill_plan(face_id) if resize_mode == "shrink" else {} return { "status": readiness["resize_status"], "risk": readiness["resize_risk"], "message": readiness["resize_note"], "warnings": readiness["resize_warnings"], "blockers": readiness["resize_blockers"], "face_id": face_id, "part_id": info["part_id"], "solid_id": info["solid_id"], "current_diameter": current_diameter, "target_diameter": new_diameter, "delta_diameter": delta_diameter, "diameter_delta_ratio": diameter_delta_ratio, "target_to_height_ratio": target_to_height_ratio, "resize_mode": resize_mode, "feature_type": feature.get("feature_type"), "feature_bottom_face_ids": feature.get("feature_bottom_face_ids"), "feature_opening_face_ids": feature.get("feature_opening_face_ids"), "feature_bottom_note": feature.get("feature_bottom_note"), "feature_guess": info.get("feature_guess"), "confidence": info.get("confidence"), "angular_span": info.get("angular_span"), "height_estimate": 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 cylindrical_boss_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) if info.get("surface") != "cylinder" or "diameter" not in info: return { "status": "blocked", "risk": "blocked", "message": "当前选中的 face 不是圆柱面,不能调整圆柱凸台直径。", } current_diameter = float(info["diameter"]) readiness = _cylinder_boss_resize_readiness(info, new_diameter) resize_mode = _resize_mode(current_diameter, new_diameter) delta_diameter = new_diameter - current_diameter diameter_delta_ratio = abs(delta_diameter) / max(current_diameter, 1e-9) height_estimate = float(info.get("height_estimate", 0.0)) target_to_height_ratio = new_diameter / height_estimate if height_estimate > 1e-9 else "" feature = self.feature_info(face_id) tool_plan = self._bounded_boss_resize_tool_plan(face_id, new_diameter) return { "status": readiness["boss_resize_status"], "risk": readiness["boss_resize_risk"], "message": readiness["boss_resize_note"], "warnings": readiness["boss_resize_warnings"], "blockers": readiness["boss_resize_blockers"], "face_id": face_id, "part_id": info["part_id"], "solid_id": info["solid_id"], "current_diameter": current_diameter, "target_diameter": new_diameter, "delta_diameter": delta_diameter, "diameter_delta_ratio": diameter_delta_ratio, "target_to_height_ratio": target_to_height_ratio, "resize_mode": resize_mode, "feature_type": feature.get("feature_type"), "feature_guess": info.get("feature_guess"), "confidence": info.get("confidence"), "angular_span": info.get("angular_span"), "height_estimate": info.get("height_estimate"), "material_vote_summary": info.get("material_vote_summary"), "material_sample_count": info.get("material_sample_count"), "feature_adjacent_face_ids": feature.get("feature_adjacent_face_ids"), "feature_boundary_edge_ids": feature.get("feature_boundary_edge_ids"), **tool_plan, } def cylindrical_suppress_plan(self, face_id: int) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) if info.get("surface") != "cylinder" or "diameter" not in info: return { "status": "blocked", "risk": "blocked", "message": "当前选中的 face 不是圆柱面,不能封堵圆柱孔。", } readiness = _cylinder_suppress_readiness(info) feature = self.feature_info(face_id) fill_plan = self._bounded_cylinder_fill_plan(face_id) return { "status": readiness["suppress_status"], "risk": readiness["suppress_risk"], "message": readiness["suppress_note"], "warnings": readiness["suppress_warnings"], "blockers": readiness["suppress_blockers"], "face_id": face_id, "part_id": info["part_id"], "solid_id": info["solid_id"], "diameter": info.get("diameter"), "radius": info.get("radius"), "angular_span": info.get("angular_span"), "height_estimate": info.get("height_estimate"), "feature_type": feature.get("feature_type"), "feature_guess": info.get("feature_guess"), "confidence": info.get("confidence"), "material_vote_summary": info.get("material_vote_summary"), "cylinder_end_type": info.get("cylinder_end_type"), "feature_bottom_face_ids": feature.get("feature_bottom_face_ids"), "feature_opening_face_ids": feature.get("feature_opening_face_ids"), "feature_bottom_note": feature.get("feature_bottom_note"), **fill_plan, } def cylindrical_depth_plan(self, face_id: int, target_depth: 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 不是圆柱面,不能调整盲孔深度。", } feature = self.feature_info(face_id) context = self._blind_cylindrical_depth_context(face_id, info, feature, target_depth) depth_info = dict(info) if context.get("context_status") == "ready" and isinstance(context.get("depth_current_depth"), (int, float)): depth_info["hole_depth_estimate"] = float(context["depth_current_depth"]) readiness = _cylinder_depth_readiness(depth_info, target_depth) if context.get("context_status") == "blocked": readiness = dict(readiness) readiness["depth_status"] = "blocked" readiness["depth_risk"] = "blocked" readiness["depth_blockers"] = _join_nonempty( readiness.get("depth_blockers"), context.get("context_message"), ) readiness["depth_note"] = readiness["depth_blockers"] current_depth = float(depth_info.get("hole_depth_estimate", 0.0)) delta_depth = target_depth - current_depth depth_delta_ratio = abs(delta_depth) / max(current_depth, 1e-9) plan = { "status": readiness["depth_status"], "risk": readiness["depth_risk"], "message": readiness["depth_note"], "warnings": readiness["depth_warnings"], "blockers": readiness["depth_blockers"], "face_id": face_id, "part_id": info["part_id"], "solid_id": info["solid_id"], "current_depth": current_depth, "target_depth": target_depth, "delta_depth": delta_depth, "depth_delta_ratio": depth_delta_ratio, "depth_mode": "deepen" if delta_depth > 0 else "shallow", "diameter": info.get("diameter"), "radius": info.get("radius"), "feature_type": feature.get("feature_type"), "feature_guess": info.get("feature_guess"), "confidence": info.get("confidence"), "angular_span": info.get("angular_span"), "material_vote_summary": info.get("material_vote_summary"), "cylinder_end_type": info.get("cylinder_end_type"), "start_end_state": info.get("start_end_state"), "end_end_state": info.get("end_end_state"), "feature_bottom_face_ids": feature.get("feature_bottom_face_ids"), "feature_opening_face_ids": feature.get("feature_opening_face_ids"), "feature_bottom_confidence": feature.get("feature_bottom_confidence"), "feature_bottom_detection": feature.get("feature_bottom_detection"), "feature_bottom_note": feature.get("feature_bottom_note"), } plan.update(context) return plan def _blind_cylindrical_depth_context( self, face_id: int, info: dict[str, object], feature: dict[str, object], target_depth: float, ) -> dict[str, object]: face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: return { "context_status": "blocked", "context_message": "当前选中的 face 不是圆柱面。", } bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ())) if info.get("cylinder_end_type") != "blind" or not bottom_face_ids: return { "context_status": "blocked", "context_message": "第一版孔深调整只支持已经识别出疑似底面的盲孔/盲槽。", } start_open = info.get("start_end_open") is True end_open = info.get("end_end_open") is True if start_open == end_open: return { "context_status": "blocked", "context_message": "圆柱端部开口方向不唯一,不能可靠判断孔深方向。", } cyl = surf.Cylinder() radius = float(cyl.Radius()) axis = cyl.Axis() axis_point = axis.Location() axis_dir = axis.Direction() v_min = min(float(surf.FirstVParameter()), float(surf.LastVParameter())) v_max = max(float(surf.FirstVParameter()), float(surf.LastVParameter())) if start_open: open_parameter = v_min nominal_bottom_parameter = v_max direction_sign = 1.0 else: open_parameter = v_max nominal_bottom_parameter = v_min direction_sign = -1.0 bottom_parameter = self._bottom_face_axis_parameter( bottom_face_ids, axis_point, axis_dir, nominal_bottom_parameter, ) current_depth_source = "bottom-face-axis-parameter" if bottom_parameter is not None else "cylinder-v-range" if bottom_parameter is None: bottom_parameter = nominal_bottom_parameter current_depth = max(abs(bottom_parameter - open_parameter), 1e-9) target_bottom_parameter = open_parameter + direction_sign * target_depth delta_depth = target_depth - current_depth depth_mode = "deepen" if delta_depth > 0 else "shallow" tool_direction = ( axis_dir.X() * direction_sign, axis_dir.Y() * direction_sign, axis_dir.Z() * direction_sign, ) open_margin = min(max(radius * 0.05, abs(delta_depth) * 0.2, 0.02), max(current_depth * 0.1, 0.2)) bottom_overlap = min(max(radius * 0.02, abs(delta_depth) * 0.05, 0.01), max(current_depth * 0.03, 0.08)) if depth_mode == "deepen": start_parameter = open_parameter - direction_sign * open_margin end_parameter = target_bottom_parameter tool_height = target_depth + open_margin tool_role = "cutter" tool_strategy = "bounded-blind-depth-cut" tool_radius = radius radius_overlap = 0.0 tool_note = "加深盲孔:沿识别出的开口到疑似底面方向,使用有限长度圆柱 cutter 延伸切削。" else: start_parameter = target_bottom_parameter end_parameter = bottom_parameter + direction_sign * bottom_overlap tool_height = current_depth - target_depth + bottom_overlap tool_role = "fill" tool_strategy = "bounded-bottom-fill" radius_overlap = min(max(radius * 0.001, 0.001), 0.05) tool_radius = radius + radius_overlap tool_note = "变浅盲孔:从目标新底面到旧底面方向补料,并让补料半径略有重叠以便和原实体合并。" return { "context_status": "ready", "depth_tool_strategy": tool_strategy, "depth_tool_role": tool_role, "depth_tool_note": tool_note, "depth_axis_direction": tool_direction, "depth_open_parameter": open_parameter, "depth_bottom_parameter": bottom_parameter, "depth_nominal_bottom_parameter": nominal_bottom_parameter, "depth_bottom_parameter_source": current_depth_source, "depth_current_depth": current_depth, "depth_current_depth_source": current_depth_source, "depth_target_bottom_parameter": target_bottom_parameter, "depth_tool_start_parameter": start_parameter, "depth_tool_end_parameter": end_parameter, "depth_tool_height": max(tool_height, 1e-6), "depth_tool_radius": tool_radius, "depth_tool_radius_overlap": radius_overlap, "depth_open_point": _point_tuple(_point_on_axis(axis_point, axis_dir, open_parameter)), "depth_current_bottom_point": _point_tuple(_point_on_axis(axis_point, axis_dir, bottom_parameter)), "depth_target_bottom_point": _point_tuple(_point_on_axis(axis_point, axis_dir, target_bottom_parameter)), "depth_tool_start_point": _point_tuple(_point_on_axis(axis_point, axis_dir, start_parameter)), } def _bounded_cylinder_cutter_plan( self, face_id: int, new_diameter: float, feature: dict[str, object] | None = None, ) -> dict[str, object]: face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: return { "cutter_strategy": "unavailable", "cutter_note": "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 feature = feature or self.feature_info(face_id) bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ())) opening_face_ids = tuple(feature.get("feature_opening_face_ids", ())) bottom_protection = bool(bottom_face_ids) if bottom_protection: bottom_note = "检测到疑似盲孔底面,封闭端 cutter 只保留很小余量,避免明显加深孔。" else: bottom_note = "未检测到明确疑似底面,按端部开口/封闭采样设置 cutter 余量。" start_parameter = v_min - start_margin end_parameter = v_max + end_margin height = max(end_parameter - start_parameter, 1e-6) axis = cyl.Axis() direction = axis.Direction() axis_point = axis.Location() start = gp_Pnt( axis_point.X() + direction.X() * start_parameter, axis_point.Y() + direction.Y() * start_parameter, axis_point.Z() + direction.Z() * start_parameter, ) return { "cutter_strategy": "bounded-to-selected-cylinder-v-range", "cutter_note": "有限长度切削:按选中圆柱面的 V 参数范围生成 cutter,减少贯穿整个零件的误切风险。", "cutter_start_parameter": start_parameter, "cutter_end_parameter": end_parameter, "cutter_height": height, "cutter_margin": base_margin, "cutter_start_margin": start_margin, "cutter_end_margin": end_margin, "cutter_radius": new_radius, "cutter_axis_point": _point_tuple(axis_point), "cutter_axis_direction": _dir_tuple(direction), "cutter_start_point": _point_tuple(start), "cutter_bottom_protection": bottom_protection, "cutter_protected_bottom_face_ids": bottom_face_ids, "cutter_opening_face_ids": opening_face_ids, "cutter_bottom_note": bottom_note, **end_info, } def _bounded_cylinder_fill_plan(self, face_id: int) -> dict[str, object]: face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: return { "fill_strategy": "unavailable", "fill_note": "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 _bounded_boss_resize_tool_plan(self, face_id: int, new_diameter: float) -> dict[str, object]: face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: return { "boss_tool_strategy": "unavailable", "boss_tool_note": "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, 1e-6) delta_radius = abs(new_radius - old_radius) base_radius = max(old_radius, new_radius) axial_margin = min( max(base_radius * 0.001, delta_radius * 0.01, span * 0.001, 0.001), max(span * 0.01, 0.02), ) radial_overlap = min(max(old_radius * 0.001, 0.001), 0.05) start_parameter = v_min - axial_margin end_parameter = v_max + axial_margin height = max(end_parameter - start_parameter, 1e-6) axis = cyl.Axis() direction = axis.Direction() axis_point = axis.Location() start = _point_on_axis(axis_point, direction, start_parameter) resize_mode = _resize_mode(old_radius * 2.0, new_diameter) return { "boss_tool_strategy": "bounded-cylinder-fuse" if resize_mode == "enlarge" else "bounded-annular-cut", "boss_tool_note": ( "扩大凸台会在选中圆柱面的 V 范围内生成目标半径圆柱并 Fuse;" "缩小凸台会生成环形 cutter 并 Cut。第一版会给轴向两端保留少量重叠," "让布尔结果更容易和原实体合并。" ), "boss_tool_start_parameter": start_parameter, "boss_tool_end_parameter": end_parameter, "boss_tool_height": height, "boss_tool_axial_margin": axial_margin, "boss_tool_radius": new_radius, "boss_tool_old_radius": old_radius, "boss_tool_outer_radius": old_radius + radial_overlap if resize_mode == "shrink" else new_radius, "boss_tool_inner_radius": new_radius if resize_mode == "shrink" else "", "boss_tool_radial_overlap": radial_overlap if resize_mode == "shrink" else "", "boss_tool_axis_point": _point_tuple(axis_point), "boss_tool_axis_direction": _dir_tuple(direction), "boss_tool_start_point": _point_tuple(start), } 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 is_fillet_radius = solid_diagonal > 0 and radius <= solid_diagonal * 0.12 is_quarter_roundish = 0.15 <= angular_span <= math.pi * 1.05 is_fillet_like_partial = ( is_partial_cylinder and is_quarter_roundish and is_fillet_radius and boundary_edges >= 4 ) samples = self._sample_cylinder_material_states(surf, solid, detailed=detailed) sample_count = len(samples) if sample_count == 0: return { "feature_guess": "cylindrical face", "toward_axis": "unknown", "away_axis": "unknown", "vote_summary": "hole=0, boss=0, unclear=0", "sample_count": 0, "confidence": "low", "note": "could not sample cylinder material sides", } toward_states = [sample["toward"] for sample in samples] away_states = [sample["away"] for sample in samples] hole_votes = sum(1 for sample in samples if sample["toward"] == "outside" and sample["away"] == "inside") boss_votes = sum(1 for sample in samples if sample["toward"] == "inside" and sample["away"] == "outside") unclear_votes = sample_count - hole_votes - boss_votes vote_summary = f"hole={hole_votes}, boss={boss_votes}, unclear={unclear_votes}" threshold = max(1, math.ceil(sample_count * 0.6)) base = { "toward_axis": _state_summary(toward_states), "away_axis": _state_summary(away_states), "vote_summary": vote_summary, "sample_count": sample_count, } if hole_votes >= threshold: confidence = "high" if hole_votes == sample_count and not is_partial_cylinder else "medium" return { "feature_guess": "hole/groove candidate", "confidence": confidence, "note": "axis side is mostly empty and outer side is mostly material", **base, } if is_partial_cylinder and (is_small_radius or is_fillet_like_partial): return { "feature_guess": "round/fillet candidate", "confidence": "medium" if boundary_edges >= 4 else "low", "note": "partial small-radius cylinder; may be a fillet or blend", **base, } if boss_votes >= threshold: return { "feature_guess": "boss/outer-round candidate", "confidence": "high" if boss_votes == sample_count and not is_partial_cylinder else "medium", "note": "axis side is mostly material and outer side is mostly empty", **base, } return { "feature_guess": "cylindrical face", "confidence": "low", "note": "material sampling did not produce a clear inside/outside pattern", **base, } def _sample_cylinder_material_states( self, surf: BRepAdaptor_Surface, solid: TopoDS_Shape, detailed: bool = False, ) -> list[dict[str, str]]: cyl = surf.Cylinder() axis = cyl.Axis() axis_point = axis.Location() axis_dir = axis.Direction() radius = cyl.Radius() u_first = surf.FirstUParameter() u_last = surf.LastUParameter() v = (surf.FirstVParameter() + surf.LastVParameter()) / 2.0 u_span = u_last - u_first fractions = [0.5] if detailed and abs(u_span) > 0.2: fractions = [0.25, 0.5, 0.75] samples: list[dict[str, str]] = [] for fraction in fractions: u = u_first + u_span * fraction point = surf.Value(u, v) axis_to_point = _vec_from_points(axis_point, point) projection = _dot(axis_to_point, axis_dir) center = gp_Pnt( axis_point.X() + axis_dir.X() * projection, axis_point.Y() + axis_dir.Y() * projection, axis_point.Z() + axis_dir.Z() * projection, ) radial = _vec_from_points(center, point) radial_len = radial.Magnitude() if radial_len <= 1e-9: continue unit = gp_Vec(radial.X() / radial_len, radial.Y() / radial_len, radial.Z() / radial_len) epsilon = min(max(radius * 0.03, 0.05), 1.0) toward_point = gp_Pnt( point.X() - unit.X() * epsilon, point.Y() - unit.Y() * epsilon, point.Z() - unit.Z() * epsilon, ) away_point = gp_Pnt( point.X() + unit.X() * epsilon, point.Y() + unit.Y() * epsilon, point.Z() + unit.Z() * epsilon, ) samples.append( { "toward": _solid_state(solid, toward_point), "away": _solid_state(solid, away_point), } ) return samples def _plane_push_pull_direction(self, face_id: int, surf: BRepAdaptor_Surface) -> dict[str, object]: face = self.faces[face_id] direction = surf.Plane().Axis().Direction() axis_tuple = _dir_tuple(direction) oriented_tuple = _oriented_dir_tuple(direction, face) fallback = { "outward_direction": oriented_tuple, "inward_direction": _neg_tuple(oriented_tuple), "plus_side_state": "unknown", "minus_side_state": "unknown", "confidence": "low", "note": "falling back to topology-oriented plane normal", } solid_id = self.face_solid_ids[face_id] if solid_id < 0 or solid_id >= len(self.solids): fallback["note"] = "no owning solid was found; using topology-oriented plane normal" return fallback solid = self.solids[solid_id][1] props = GProp_GProps() brepgprop.SurfaceProperties(face, props) sample = props.CentreOfMass() diagonal = _shape_diagonal(solid) epsilon = min(max(diagonal * 1e-4, 0.05), 1.0) plus_point = gp_Pnt( sample.X() + direction.X() * epsilon, sample.Y() + direction.Y() * epsilon, sample.Z() + direction.Z() * epsilon, ) minus_point = gp_Pnt( sample.X() - direction.X() * epsilon, sample.Y() - direction.Y() * epsilon, sample.Z() - direction.Z() * epsilon, ) plus_state = _solid_state(solid, plus_point) minus_state = _solid_state(solid, minus_point) if plus_state == "outside" and minus_state == "inside": return { "outward_direction": axis_tuple, "inward_direction": _neg_tuple(axis_tuple), "plus_side_state": plus_state, "minus_side_state": minus_state, "confidence": "high", "note": "positive plane normal side is outside material", } if plus_state == "inside" and minus_state == "outside": return { "outward_direction": _neg_tuple(axis_tuple), "inward_direction": axis_tuple, "plus_side_state": plus_state, "minus_side_state": minus_state, "confidence": "high", "note": "negative plane normal side is outside material", } fallback["plus_side_state"] = plus_state fallback["minus_side_state"] = minus_state fallback["note"] = "inside/outside sampling was unclear; using topology-oriented plane normal" return fallback def export_all(self, filename: str | Path) -> None: export_shape = _compound_from_shapes( _prepare_shape_for_step_export(part.shape) for part in self.display_parts() ) _write_step(export_shape, Path(filename)) def export_quality_info(self, scope: str, target_id: int | None = None) -> dict[str, object]: if scope == "all": return _shape_quality_info("当前完整模型", self.shape, expect_solid=False) if scope == "part": if target_id is None: raise ValueError("Part id is required.") part = self.part_by_id(target_id) if part is None: raise ValueError(f"Unknown part id {target_id}") info = _shape_quality_info(f"零件 {part.id}: {part.name}", part.shape, expect_solid=True) info["part_id"] = part.id return info if scope == "solid": if target_id is None or target_id < 0 or target_id >= len(self.solids): raise ValueError(f"Unknown solid id {target_id}") part_id, solid = self.solids[target_id] info = _shape_quality_info(f"Solid {target_id}", solid, expect_solid=True) info["part_id"] = part_id info["solid_id"] = target_id return info if scope == "face": if target_id is None or target_id < 0 or target_id >= len(self.faces): raise ValueError(f"Unknown face id {target_id}") info = _shape_quality_info(f"Face {target_id}", self.faces[target_id], expect_solid=False) info["part_id"] = self.face_part_ids[target_id] info["solid_id"] = self.face_solid_ids[target_id] info["face_id"] = target_id return info if scope == "edge": if target_id is None or target_id < 0 or target_id >= len(self.edges): raise ValueError(f"Unknown edge id {target_id}") info = _shape_quality_info(f"Edge {target_id}", self.edges[target_id], expect_solid=False) info["part_id"] = self.edge_part_ids[target_id] info["solid_id"] = self._edge_solid_id(target_id) info["edge_id"] = target_id return info if scope == "feature": if target_id is None or target_id < 0 or target_id >= len(self.faces): raise ValueError(f"Unknown feature source face id {target_id}") feature = self.feature_info(target_id) face_ids = _int_values(feature.get("feature_highlight_face_ids")) or [target_id] shape = _compound_from_shapes(self.faces[face_id] for face_id in face_ids if 0 <= face_id < len(self.faces)) info = _shape_quality_info(f"Feature from face {target_id}", shape, expect_solid=False) info["part_id"] = self.face_part_ids[target_id] info["solid_id"] = self.face_solid_ids[target_id] info["face_id"] = target_id info["feature_face_ids"] = tuple(face_ids) return info raise ValueError(f"Unknown export quality scope: {scope}") 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(_prepare_shape_for_step_export(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(_prepare_shape_for_step_export(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 export_edge(self, edge_id: int, filename: str | Path) -> None: if edge_id < 0 or edge_id >= len(self.edges): raise ValueError(f"Unknown edge id {edge_id}") _write_step(self.edges[edge_id], Path(filename)) def export_feature(self, face_id: int, filename: str | Path) -> None: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown feature source face id {face_id}") feature = self.feature_info(face_id) face_ids = _int_values(feature.get("feature_highlight_face_ids")) or [face_id] shapes = [self.faces[item] for item in face_ids if 0 <= item < len(self.faces)] if not shapes: raise ValueError("Feature export did not find any valid faces.") _write_step(_compound_from_shapes(shapes), Path(filename)) def translate_part_plan(self, part_id: int, vector: tuple[float, float, float]) -> dict[str, object]: part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") readiness = _translation_readiness(vector, part.shape) return { "status": readiness["translate_status"], "risk": readiness["translate_risk"], "message": readiness["translate_note"], "warnings": readiness["translate_warnings"], "blockers": readiness["translate_blockers"], "target_kind": "part", "part_id": part.id, "name": part.name, "translation_vector": vector, "translation_distance": _vector_length(vector), "bbox_diagonal": _shape_diagonal(part.shape), } def translate_solid_plan(self, solid_id: int, vector: tuple[float, float, float]) -> 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] readiness = _translation_readiness(vector, solid) part = self.part_by_id(part_id) part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0 warnings = readiness["translate_warnings"] risk = readiness["translate_risk"] status = readiness["translate_status"] if part_solid_count <= 1 and status != "blocked": warnings = _join_nonempty(warnings, "当前 part 只有一个 solid,平移 solid 实际会移动整个 part shape。") if risk == "low": risk = "medium" status = "caution" return { "status": status, "risk": risk, "message": _join_nonempty(readiness["translate_note"], warnings), "warnings": warnings, "blockers": readiness["translate_blockers"], "target_kind": "solid", "part_id": part_id, "solid_id": solid_id, "part_solid_count": part_solid_count, "translation_vector": vector, "translation_distance": _vector_length(vector), "bbox_diagonal": _shape_diagonal(solid), } def translate_part(self, part_id: int, vector: tuple[float, float, float]) -> str: plan = self.translate_part_plan(part_id, vector) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") part.shape = _translated_shape_by_vector(part.shape, vector) _ensure_valid_shape(part.shape) self.refresh_topology() return ( f"Part translated: part {part_id}, vector={_format_tuple(vector)}, " f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}." ) def translate_solid(self, solid_id: int, vector: tuple[float, float, float]) -> str: plan = self.translate_solid_plan(solid_id, vector) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) 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}") part_solids = _explore(part.shape, TopAbs_SOLID) if len(part_solids) <= 1: part.shape = _translated_shape_by_vector(part.shape, vector) else: translated = _translated_shape_by_vector(solid, vector) replaced = False shapes: list[TopoDS_Shape] = [] for item in part_solids: if not replaced and _same_shape(item, solid): shapes.append(translated) 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() return ( f"Solid translated: solid {solid_id}, part {part_id}, vector={_format_tuple(vector)}, " f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}." ) def rotate_part_plan(self, part_id: int, axis: str, angle_degrees: float) -> dict[str, object]: part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") readiness = _rotation_readiness(axis, angle_degrees) return { "status": readiness["rotate_status"], "risk": readiness["rotate_risk"], "message": readiness["rotate_note"], "warnings": readiness["rotate_warnings"], "blockers": readiness["rotate_blockers"], "target_kind": "part", "part_id": part.id, "name": part.name, "rotation_axis": axis.upper(), "rotation_angle_degrees": angle_degrees, "rotation_center": _shape_center(part.shape), "bbox_diagonal": _shape_diagonal(part.shape), } def rotate_solid_plan(self, solid_id: int, axis: str, angle_degrees: float) -> 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] readiness = _rotation_readiness(axis, angle_degrees) part = self.part_by_id(part_id) part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0 warnings = readiness["rotate_warnings"] risk = readiness["rotate_risk"] status = readiness["rotate_status"] if part_solid_count <= 1 and status != "blocked": warnings = _join_nonempty(warnings, "当前 part 只有一个 solid,旋转 solid 实际会旋转整个 part shape。") if risk == "low": risk = "medium" status = "caution" return { "status": status, "risk": risk, "message": _join_nonempty(readiness["rotate_note"], warnings), "warnings": warnings, "blockers": readiness["rotate_blockers"], "target_kind": "solid", "part_id": part_id, "solid_id": solid_id, "part_solid_count": part_solid_count, "rotation_axis": axis.upper(), "rotation_angle_degrees": angle_degrees, "rotation_center": _shape_center(solid), "bbox_diagonal": _shape_diagonal(solid), } def rotate_part(self, part_id: int, axis: str, angle_degrees: float) -> str: plan = self.rotate_part_plan(part_id, axis, angle_degrees) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") part.shape = _rotated_shape(part.shape, str(plan["rotation_axis"]), float(plan["rotation_angle_degrees"]), plan["rotation_center"]) _ensure_valid_shape(part.shape) self.refresh_topology() return ( f"Part rotated: part {part_id}, axis={plan['rotation_axis']}, " f"angle={float(plan['rotation_angle_degrees']):g}, center={_format_tuple(plan['rotation_center'])}, " f"risk={plan['risk']}." ) def rotate_solid(self, solid_id: int, axis: str, angle_degrees: float) -> str: plan = self.rotate_solid_plan(solid_id, axis, angle_degrees) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) 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}") part_solids = _explore(part.shape, TopAbs_SOLID) if len(part_solids) <= 1: part.shape = _rotated_shape(part.shape, str(plan["rotation_axis"]), float(plan["rotation_angle_degrees"]), plan["rotation_center"]) else: rotated = _rotated_shape(solid, str(plan["rotation_axis"]), float(plan["rotation_angle_degrees"]), plan["rotation_center"]) replaced = False shapes: list[TopoDS_Shape] = [] for item in part_solids: if not replaced and _same_shape(item, solid): shapes.append(rotated) 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() return ( f"Solid rotated: solid {solid_id}, part {part_id}, axis={plan['rotation_axis']}, " f"angle={float(plan['rotation_angle_degrees']):g}, center={_format_tuple(plan['rotation_center'])}, " f"risk={plan['risk']}." ) 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,边倒圆会作用在整个 part shape 上,请导出前检查结果。", ) readiness["fillet_note"] = _join_nonempty(readiness["fillet_note"], readiness["fillet_warnings"]) length = float(info.get("length", 0.0)) radius_to_length_ratio = radius / max(length, 1e-9) return { "status": readiness["fillet_status"], "risk": readiness["fillet_risk"], "message": readiness["fillet_note"], "warnings": readiness["fillet_warnings"], "blockers": readiness["fillet_blockers"], "edge_id": edge_id, "part_id": info["part_id"], "solid_id": info.get("solid_id", -1), "curve": info.get("curve"), "edge_length": length, "target_radius": radius, "radius_to_length_ratio": radius_to_length_ratio, "adjacent_face_ids": info.get("adjacent_face_ids", ()), "adjacent_face_count": info.get("adjacent_face_count", 0), "start_point": info.get("start_point"), "end_point": info.get("end_point"), "direction": info.get("direction"), } def edge_chamfer_plan(self, edge_id: int, distance: float) -> dict[str, object]: if edge_id < 0 or edge_id >= len(self.edges): raise ValueError(f"Unknown edge id {edge_id}") info = self.edge_info(edge_id) readiness = _edge_chamfer_readiness(info, distance) part_id = int(info["part_id"]) part_stats = None try: part_stats = self.part_topology_stats(part_id) except Exception: part_stats = None if part_stats is not None and part_stats.solids != 1: readiness = dict(readiness) if readiness["chamfer_status"] != "blocked": readiness["chamfer_status"] = "caution" readiness["chamfer_risk"] = _max_risk(str(readiness["chamfer_risk"]), "high") readiness["chamfer_warnings"] = _join_nonempty( readiness["chamfer_warnings"], f"当前零件包含 {part_stats.solids} 个 solid,边倒角会作用在整个 part shape 上,请导出前检查结果。", ) readiness["chamfer_note"] = _join_nonempty(readiness["chamfer_note"], readiness["chamfer_warnings"]) length = float(info.get("length", 0.0)) distance_to_length_ratio = distance / max(length, 1e-9) return { "status": readiness["chamfer_status"], "risk": readiness["chamfer_risk"], "message": readiness["chamfer_note"], "warnings": readiness["chamfer_warnings"], "blockers": readiness["chamfer_blockers"], "edge_id": edge_id, "part_id": info["part_id"], "solid_id": info.get("solid_id", -1), "curve": info.get("curve"), "edge_length": length, "target_distance": distance, "distance_to_length_ratio": distance_to_length_ratio, "adjacent_face_ids": info.get("adjacent_face_ids", ()), "adjacent_face_count": info.get("adjacent_face_count", 0), "start_point": info.get("start_point"), "end_point": info.get("end_point"), "direction": info.get("direction"), } def straight_edge_length_plan(self, edge_id: int, target_length: 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)) delta_length = float(target_length) - current_length base: dict[str, object] = { "edge_id": edge_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id", -1), "curve": info.get("curve"), "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"), "direction": info.get("direction"), "resize_strategy": "move-edge-end-plane-by-push-pull", } warnings: list[str] = [ "第一版边长调整是受限功能:只移动直线边端点附近的平面端面,不是通用参数化边长编辑。" ] blockers: list[str] = [] risk = "low" status = "ready" if info.get("curve") != "line": blockers.append("当前 edge 不是直线,第一版不能调整长度。") if current_length <= 1e-9: blockers.append("当前 edge 长度无效。") if target_length <= 1e-9: blockers.append("目标边长必须大于 0。") if abs(delta_length) <= max(current_length * 1e-7, 1e-7): blockers.append("目标边长与当前边长几乎相同,不需要修改。") if not blockers: ratio = abs(delta_length) / max(current_length, 1e-9) if ratio > 0.5: risk = _max_risk(risk, "high") warnings.append("长度变化超过当前边长的 50%,布尔运算失败或形状异常的概率较高。") elif ratio > 0.25: risk = _max_risk(risk, "medium") warnings.append("长度变化超过当前边长的 25%,请确认预览范围。") candidate: dict[str, object] | None = None if not blockers: candidate = self._straight_edge_length_end_face_candidate(info, delta_length) if candidate is None: blockers.append("没有找到可用于改变这条直线边长度的平面端面。") else: base.update(candidate) push_plan = self.push_pull_plan(int(candidate["end_face_id"]), float(candidate["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) base.update( { "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", ""), } ) if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers) elif risk != "low": status = "caution" message = " ".join(warnings) else: message = "可以尝试通过端面推拉调整这条直线边长度。" base.update( { "status": status, "risk": risk, "message": message, "warnings": ";".join(warnings), "blockers": ";".join(blockers), } ) return base def _straight_edge_length_end_face_candidate( self, edge_info: dict[str, object], delta_length: float, ) -> 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]]] = [] endpoint_specs = [ ("起点端", start, _tuple_scale(axis, -delta_length)), ("终点端", end, _tuple_scale(axis, delta_length)), ] for 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_label": endpoint_label, "end_face_plane_distance": plane_distance, "end_face_axis_alignment": axis_alignment, "end_face_movement_alignment": movement_alignment, "end_face_outward_direction": outward, "end_face_push_pull_confidence": face_info.get("push_pull_confidence", ""), "push_pull_distance": push_pull_distance, "desired_movement_vector": desired_vector, }, ) ) if not candidates: return None candidates.sort(key=lambda item: item[0]) return candidates[0][1] def existing_fillet_resize_plan(self, face_id: int, target_radius: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") info = self.face_info(face_id) if info.get("surface") != "cylinder" or "radius" not in info: return { "status": "blocked", "risk": "blocked", "message": "当前选中的 face 不是圆柱圆角面,不能修改已有圆角半径。", "blockers": "当前选中的 face 不是圆柱圆角面。", "warnings": "", "face_id": face_id, } feature = self.feature_info(face_id) feature_guess = str(info.get("feature_guess", "")) current_radius = float(feature.get("existing_fillet_radius_estimate", info["radius"])) support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ())) warnings: list[str] = [] blockers: list[str] = [] risk = "medium" status = "caution" if feature_guess != "round/fillet candidate": blockers.append("当前圆柱面没有被识别为已有圆角/倒圆候选。") if target_radius <= 0: blockers.append("目标圆角半径必须大于 0。") if current_radius <= 0: blockers.append("当前圆角半径估算无效。") if current_radius > 0 and abs(target_radius - current_radius) <= max(current_radius * 1e-5, 1e-6): blockers.append("目标圆角半径与当前估算半径几乎相同,不需要修改。") if len(support_face_ids) < 2: blockers.append("第一版只对识别到至少两个支撑 face 的已有圆角候选开放。") part_id = int(info.get("part_id", -1)) part_stats = None try: part_stats = self.part_topology_stats(part_id) except Exception: part_stats = None if part_stats is not None and part_stats.solids != 1: blockers.append( f"当前零件包含 {part_stats.solids} 个 solid;已有圆角半径修改第一版只对单 solid 零件开放。" ) height_estimate = float(info.get("height_estimate", 0.0)) angular_span = float(info.get("angular_span", 0.0)) radius_delta = target_radius - current_radius radius_delta_ratio = abs(radius_delta) / max(current_radius, 1e-9) if not blockers: if radius_delta_ratio > 1.0: risk = "high" warnings.append("目标半径变化超过当前半径的 100%,defeature/refillet 很可能失败。") elif radius_delta_ratio > 0.35: risk = _max_risk(risk, "high") warnings.append("目标半径变化超过当前半径的 35%,请谨慎检查结果。") if height_estimate > 0 and target_radius > height_estimate * 0.5: risk = _max_risk(risk, "high") warnings.append("目标半径超过圆角长度估算的一半,几何比例异常。") if angular_span > math.pi * 1.25: risk = _max_risk(risk, "high") warnings.append("当前圆角圆弧跨度较大,可能不是普通边圆角。") if str(info.get("confidence", "low")) != "high": warnings.append("已有圆角识别置信度不是 high,执行结果需要重点检查。") if blockers: status = "blocked" risk = "blocked" message = " ".join(blockers + warnings) else: message = "将尝试先移除已有圆角面,再在恢复出的锐边上按目标半径重新倒圆。" if warnings: message += " " + " ".join(warnings) return { "status": status, "risk": risk, "message": message, "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": info.get("part_id"), "solid_id": info.get("solid_id"), "feature_type": feature.get("feature_type"), "feature_guess": feature_guess, "confidence": info.get("confidence"), "current_radius": current_radius, "target_radius": target_radius, "delta_radius": radius_delta, "radius_delta_ratio": radius_delta_ratio, "height_estimate": info.get("height_estimate"), "angular_span": info.get("angular_span"), "axis_point": info.get("axis_point"), "axis": info.get("axis"), "feature_existing_fillet_support_face_ids": support_face_ids, "feature_boundary_edge_ids": feature.get("feature_boundary_edge_ids"), "resize_strategy": "defeature-existing-fillet-face-then-refillet-axis-edge", "resize_note": ( "第一版已有圆角半径修改只支持由圆柱面表示的直线边圆角。" "执行后 face/edge ID 会重建,请重新选择对象确认结果。" ), } def push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: return { "status": "blocked", "risk": "blocked", "message": "当前选中的 face 不是平面,不能执行推拉平面。", "blockers": "当前选中的 face 不是平面。", "warnings": "", "face_id": face_id, "part_id": self.face_part_ids[face_id], "solid_id": self.face_solid_ids[face_id], "distance": distance, } info = self.face_info(face_id) scope_face_ids = self._connected_coplanar_planar_face_ids(face_id) if len(scope_face_ids) > 1: scope_note = f"将一起推拉 {len(scope_face_ids)} 个共享边且共面的 face,减少 STEP 碎面导致的贴块缝。" else: scope_note = "只推拉当前 face。" direction_confidence = str(info.get("push_pull_confidence", "low")) bbox_diagonal = float(info.get("bbox_diagonal", 0.0)) distance_abs = abs(distance) warnings: list[str] = [] blockers: list[str] = [] risk = "low" status = "ready" if distance_abs <= 1e-9: status = "blocked" risk = "blocked" blockers.append("推拉距离为 0,不需要修改。") if direction_confidence != "high": risk = _max_risk(risk, "medium") warnings.append("推拉方向判断置信度较低,可能不是期望的内外方向。") if bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.2: risk = _max_risk(risk, "high") warnings.append("推拉距离超过当前 face 包围盒对角线的 20%,容易导致布尔失败或大范围变形。") elif bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.08: risk = _max_risk(risk, "medium") warnings.append("推拉距离相对当前 face 尺寸偏大,请确认预览范围。") if risk in {"medium", "high"} and status != "blocked": status = "caution" if blockers: message = " ".join(blockers + warnings) elif warnings: message = " ".join(warnings) else: message = "可以尝试推拉该平面。" return { "status": status, "risk": risk, "message": message, "warnings": ";".join(warnings), "blockers": ";".join(blockers), "face_id": face_id, "part_id": info["part_id"], "solid_id": info["solid_id"], "distance": distance, "surface": info.get("surface"), "area": info.get("area"), "bbox_diagonal": info.get("bbox_diagonal"), "outward_direction": info.get("push_pull_outward_direction"), "direction_confidence": direction_confidence, "direction_note": info.get("push_pull_note"), "push_pull_scope_face_ids": tuple(scope_face_ids), "push_pull_scope_face_count": len(scope_face_ids), "push_pull_scope_note": scope_note, } def 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), } ] outer = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape() inner = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape() ring_cut = BRepAlgoAPI_Cut(outer, inner) ring = _finalize_boolean_result(ring_cut, "boss annular cutter preview") BRepMesh_IncrementalMesh(ring, deflection) return [ { "role": "cutter", "label": "凸台缩小环形切削预览", "polydata": _shape_faces_polydata(ring), } ] 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, deflection: float = 0.8, ) -> list[dict[str, object]]: plan = self.cylindrical_depth_plan(face_id, target_depth) 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): plan = self.straight_edge_length_plan(edge_id, target_length) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) return self.push_pull_preview_polydata(int(plan["end_face_id"]), float(plan["push_pull_distance"]), deflection) def resize_straight_edge_length(self, edge_id: int, target_length: float) -> str: plan = self.straight_edge_length_plan(edge_id, target_length) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) push_result = self.push_pull_face(int(plan["end_face_id"]), float(plan["push_pull_distance"])) return ( "Straight edge length resize completed: " 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"risk={plan['risk']}. {push_result}" ) def push_pull_face(self, face_id: int, distance: float) -> str: plan = self.push_pull_plan(face_id, distance) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Plane: raise ValueError("Push/pull currently supports planar faces only.") part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") outward = plan["outward_direction"] scope_face_ids = _int_values(plan.get("push_pull_scope_face_ids")) or [face_id] profile_shape = self._push_pull_profile_shape(scope_face_ids) overlap = _boolean_overlap_distance(part.shape, distance) start_offset = -overlap if distance >= 0 else overlap tool_distance = distance + overlap if distance >= 0 else distance - overlap tool_face = _translated_shape(profile_shape, outward, start_offset) vec = gp_Vec( float(outward[0]) * tool_distance, float(outward[1]) * tool_distance, float(outward[2]) * tool_distance, ) tool_shape = BRepPrimAPI_MakePrism(tool_face, vec).Shape() op = BRepAlgoAPI_Fuse(part.shape, tool_shape) if distance >= 0 else BRepAlgoAPI_Cut(part.shape, tool_shape) result = _finalize_boolean_result(op, "push/pull") result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance) part.shape = result self.refresh_topology() action = "fused outward prism" if distance >= 0 else "cut inward prism" return ( "Planar face push/pull completed: " f"{action}, semantic_distance={distance:g}, " f"tool_overlap={overlap:g}, " f"scope_faces={len(scope_face_ids)}, " f"outward_direction={_format_tuple(outward)}, " f"direction_confidence={plan['direction_confidence']}, " f"risk={plan['risk']}." ) def resize_existing_fillet(self, face_id: int, target_radius: float) -> str: plan = self.existing_fillet_resize_plan(face_id, target_radius) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = int(plan["part_id"]) part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") source_face = topods.Face(self.faces[face_id]) defeatured = _defeature_faces(part.shape, [source_face]) axis_point = gp_Pnt(*plan["axis_point"]) axis_dir = gp_Dir(*plan["axis"]) root_edges = _axis_aligned_edge_candidates( defeatured, axis_point, axis_dir, expected_length=float(plan.get("height_estimate") or 0.0), reference_radius=float(plan["current_radius"]), ) if not root_edges: raise RuntimeError( "已尝试移除已有圆角面,但没有找到可重新倒圆的轴向锐边;" "该圆角可能不是简单直线边圆角。" ) result = None failures: list[str] = [] for index, root_edge in enumerate(root_edges[:16], start=1): try: maker = BRepFilletAPI_MakeFillet(defeatured) maker.Add(float(target_radius), topods.Edge(root_edge)) result = _finalize_builder_result(maker, f"existing fillet resize candidate {index}") break except Exception as exc: failures.append(str(exc)) if result is None: detail = failures[-1] if failures else "没有可用的候选边。" raise RuntimeError( "已移除已有圆角面,但所有候选锐边都无法重新倒圆;" f"该圆角可能是复杂 blend 或支撑面不适合重建。最后错误:{detail}" ) part.shape = result self.refresh_topology() return ( "Existing fillet radius resize completed: " f"face {face_id}, current_radius={float(plan['current_radius']):g}, " f"target_radius={target_radius:g}, " f"delta_radius={float(plan['delta_radius']):g}, " f"support_faces={plan.get('feature_existing_fillet_support_face_ids')}, " f"risk={plan['risk']}." ) def fillet_edge(self, edge_id: int, radius: float) -> str: plan = self.edge_fillet_plan(edge_id, radius) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = self.edge_part_ids[edge_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") maker = BRepFilletAPI_MakeFillet(part.shape) maker.Add(radius, topods.Edge(self.edges[edge_id])) result = _finalize_builder_result(maker, "edge fillet") part.shape = result self.refresh_topology() return ( f"Edge fillet completed: edge {edge_id}, radius={radius:g}, " f"edge_length={float(plan['edge_length']):g}, " f"radius_to_length_ratio={float(plan['radius_to_length_ratio']):g}, " f"risk={plan['risk']}." ) def chamfer_edge(self, edge_id: int, distance: float) -> str: plan = self.edge_chamfer_plan(edge_id, distance) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = self.edge_part_ids[edge_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") maker = BRepFilletAPI_MakeChamfer(part.shape) maker.Add(distance, topods.Edge(self.edges[edge_id])) result = _finalize_builder_result(maker, "edge chamfer") part.shape = result self.refresh_topology() return ( f"Edge chamfer completed: edge {edge_id}, distance={distance:g}, " f"edge_length={float(plan['edge_length']):g}, " f"distance_to_length_ratio={float(plan['distance_to_length_ratio']):g}, " f"risk={plan['risk']}." ) def enlarge_cylindrical_hole(self, face_id: int, new_diameter: float) -> str: return self.resize_cylindrical_hole(face_id, new_diameter) def resize_cylindrical_hole(self, face_id: int, new_diameter: float) -> str: plan = self.cylindrical_resize_plan(face_id, new_diameter) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: raise ValueError("Hole resize currently supports cylindrical faces only.") cyl = surf.Cylinder() old_radius = cyl.Radius() new_radius = new_diameter / 2.0 if new_radius <= 0: raise ValueError("Target diameter must be greater than 0.") part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") direction = cyl.Axis().Direction() source_shape = part.shape if plan["resize_mode"] == "shrink": fill_start = gp_Pnt(*plan["fill_start_point"]) fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) filler = BRepPrimAPI_MakeCylinder( fill_axis, float(plan["fill_radius"]), float(plan["fill_height"]), ).Shape() fuse = BRepAlgoAPI_Fuse(part.shape, filler) source_shape = _finalize_boolean_result(fuse, "cylinder fill/fuse") cutter_start = gp_Pnt(*plan["cutter_start_point"]) cutter_axis = gp_Ax2(cutter_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) cutter = BRepPrimAPI_MakeCylinder(cutter_axis, new_radius, float(plan["cutter_height"])).Shape() op = BRepAlgoAPI_Cut(source_shape, cutter) result = _finalize_boolean_result(op, "cylinder cut") part.shape = result self.refresh_topology() 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 resize_cylindrical_boss(self, face_id: int, new_diameter: float) -> str: plan = self.cylindrical_boss_resize_plan(face_id, new_diameter) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") start = gp_Pnt(*plan["boss_tool_start_point"]) direction = gp_Dir(*plan["boss_tool_axis_direction"]) axis = gp_Ax2(start, direction) height = float(plan["boss_tool_height"]) if plan["resize_mode"] == "enlarge": tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape() op = BRepAlgoAPI_Fuse(part.shape, tool) result = _finalize_boolean_result(op, "cylindrical boss fuse") action = "enlarged by bounded fuse" else: outer = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape() inner = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape() ring_cut = BRepAlgoAPI_Cut(outer, inner) ring = _finalize_boolean_result(ring_cut, "cylindrical boss annular cutter") op = BRepAlgoAPI_Cut(part.shape, ring) result = _finalize_boolean_result(op, "cylindrical boss cut") action = "shrunk by bounded annular cut" part.shape = result self.refresh_topology() return ( f"Cylindrical boss resize completed: diameter {float(plan['current_diameter']):g} -> {new_diameter:g}, " f"mode={plan['resize_mode']}, action={action}, risk={plan['risk']}, " f"feature={plan['feature_guess']}, tool={plan['boss_tool_strategy']}, " f"height={float(plan['boss_tool_height']):g}." ) def suppress_cylindrical_hole(self, face_id: int) -> str: plan = self.cylindrical_suppress_plan(face_id) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() != GeomAbs_Cylinder: raise ValueError("Cylinder suppress currently supports cylindrical faces only.") direction = surf.Cylinder().Axis().Direction() fill_start = gp_Pnt(*plan["fill_start_point"]) fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z())) filler = BRepPrimAPI_MakeCylinder( fill_axis, float(plan["fill_radius"]), float(plan["fill_height"]), ).Shape() fuse = BRepAlgoAPI_Fuse(part.shape, filler) result = _finalize_boolean_result(fuse, "cylinder suppress/fill") part.shape = result self.refresh_topology() return ( f"Cylindrical hole suppress completed: face {face_id}, " f"diameter={float(plan['diameter']):g}, " f"height={float(plan['fill_height']):g}, " f"risk={plan['risk']}, feature={plan['feature_guess']}." ) def resize_cylindrical_depth(self, face_id: int, target_depth: float) -> str: plan = self.cylindrical_depth_plan(face_id, target_depth) if plan["status"] == "blocked": raise ValueError(str(plan["message"])) part_id = self.face_part_ids[face_id] part = self.part_by_id(part_id) if part is None: raise ValueError(f"Unknown part id {part_id}") start = gp_Pnt(*plan["depth_tool_start_point"]) direction = gp_Dir(*plan["depth_axis_direction"]) axis = gp_Ax2(start, direction) tool = BRepPrimAPI_MakeCylinder( axis, float(plan["depth_tool_radius"]), float(plan["depth_tool_height"]), ).Shape() if plan["depth_mode"] == "deepen": op = BRepAlgoAPI_Cut(part.shape, tool) result = _finalize_boolean_result(op, "blind depth cut") action = "deepened by bounded cut" else: op = BRepAlgoAPI_Fuse(part.shape, tool) result = _finalize_boolean_result(op, "blind depth fill/fuse") action = "made shallower by bounded fill" part.shape = result self.refresh_topology() return ( f"Blind cylindrical depth completed: depth {float(plan['current_depth']):g} -> {target_depth:g}, " f"mode={plan['depth_mode']}, action={action}, " f"risk={plan['risk']}, feature={plan['feature_guess']}, " f"tool={plan['depth_tool_strategy']}, height={float(plan['depth_tool_height']):g}." ) 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, edge_ids: Iterable[int] | None = None, part_ids: Iterable[int] | None = None, deflection: float = 0.8, ): import vtk selected_edges = set(edge_ids) if edge_ids is not None else None selected_parts = set(part_ids) if part_ids is not None else None 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): part_id = self.edge_part_ids[edge_id] if selected_edges is not None and edge_id not in selected_edges: continue if selected_parts is not None and part_id not in selected_parts: continue 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(part_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 _prepare_shape_for_step_export(shape: TopoDS_Shape) -> TopoDS_Shape: if shape.IsNull(): return shape try: repaired = _repair_shape(shape) unified = _unify_same_domain_shape(repaired) repaired_unified = _repair_shape(unified) if repaired_unified.IsNull(): return shape return repaired_unified except Exception: return shape 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 _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]: 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 _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 _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 _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_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_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) distance_limit = max(reference_radius * 1.25, 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) -> TopoDS_Shape: op.SetNonDestructive(True) 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: op.SimplifyResult(True, True) 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 _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 candidate = _unify_same_domain_shape( cleaned, linear_tolerance=tolerance, angular_tolerance=1e-5, allow_internal_edges=False, ) 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, ) -> TopoDS_Shape: try: unifier = ShapeUpgrade_UnifySameDomain(shape, True, True, False) unifier.SetSafeInputMode(True) 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.") 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 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: risk = _max_risk(risk, "high") warnings.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)) if guess != "hole/groove candidate": blockers.append("孔深调整第一版只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。") if end_type != "blind": blockers.append("孔深调整第一版只支持端部类型为 blind 的盲孔/盲槽。") 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 _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()