from __future__ import annotations import math from pathlib import Path from typing import Callable, Iterable from OCC.Core.BRep import BRep_Tool from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse from OCC.Core.BRepBndLib import brepbndlib from OCC.Core.BOPAlgo import BOPAlgo_GlueFull from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeVertex, BRepBuilderAPI_Transform from OCC.Core.BRepExtrema import BRepExtrema_DistShapeShape 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 from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES from .export import ExportMixin from .features import FeatureMixin from .operations import OperationMixin from .polydata import PolydataMixin from .transforms import TransformMixin from .geometry_utils import * # noqa: F403 from .model_types import PartNode, TopologyStats from .step_io import ( _load_plain_step, _load_with_xcaf, _parse_product_names, _prepare_shape_for_step_export, _write_step, ) class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, PolydataMixin): 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_logical_ids: list[int] = [] 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._quick_face_info_cache: dict[int, dict[str, object]] = {} self._face_info_cache: dict[int, dict[str, object]] = {} self._feature_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._same_domain_face_ids_cache: dict[int, list[int]] = {} self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None self._same_domain_internal_edge_ids_cache: set[int] | None = None self._same_domain_duplicate_edge_ids_cache: set[int] | None = None self._face_polydata_cache: dict[tuple[object, ...], object] = {} self._edge_polydata_cache: dict[tuple[object, ...], object] = {} self._polydata_cache_limit = 96 self._mesh_deflection: float | None = None 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_logical_ids.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._quick_face_info_cache.clear() self._face_info_cache.clear() self._feature_info_cache.clear() self._edge_info_cache.clear() self._face_edge_ids_cache.clear() self._edge_face_ids_cache.clear() self._same_domain_face_ids_cache.clear() self._edge_duplicate_key_ids_cache = None self._same_domain_internal_edge_ids_cache = None self._same_domain_duplicate_edge_ids_cache = None self._face_polydata_cache.clear() self._edge_polydata_cache.clear() self._mesh_deflection = None 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_logical_ids.append(face_id) 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_logical_ids.append(face_id) 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[object, object]: data: dict[object, object] = {part.id: part.shape for part in self.parts} data[SNAPSHOT_FACE_LOGICAL_IDS_KEY] = tuple(self.face_logical_ids) return data def restore_snapshot(self, snapshot: dict[object, object]) -> None: for part in self.parts: if part.id in snapshot: part.shape = snapshot[part.id] self.refresh_topology() logical_ids = snapshot.get(SNAPSHOT_FACE_LOGICAL_IDS_KEY) if isinstance(logical_ids, (list, tuple)) and len(logical_ids) == len(self.faces): self.face_logical_ids = [int(item) for item in logical_ids] self._quick_face_info_cache.clear() self._face_info_cache.clear() self._feature_info_cache.clear() self._same_domain_face_ids_cache.clear() def quick_face_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}") full_info = self._face_info_cache.get(face_id) if full_info is not None: return dict(full_info) cached = self._quick_face_info_cache.get(face_id) if cached is not None: return dict(cached) face = self.faces[face_id] props = GProp_GProps() brepgprop.SurfaceProperties(face, props) surf = BRepAdaptor_Surface(face) surface_type = surf.GetType() surface_label = SURFACE_TYPES.get(surface_type, f"type {surface_type}") info: dict[str, object] = { "kind": "face", "face_id": face_id, "topological_face_id": face_id, "logical_face_id": self.face_logical_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_label, "area": props.Mass(), "area_center": _point_tuple(props.CentreOfMass()), "u_range": (surf.FirstUParameter(), surf.LastUParameter()), "v_range": (surf.FirstVParameter(), surf.LastVParameter()), "boundary_edges": len(self._face_boundary_edge_ids(face_id)), "selection_info_mode": "quick", "selection_info_note": "快速选择信息;同域面、端盖、底面等深层识别会在执行编辑计划或手动扫描时再计算。", } 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"] info["push_pull_status"] = "candidate" info["feature_type"] = "可推拉平面候选" info["feature_source_face_id"] = face_id info["feature_highlight_face_ids"] = (face_id,) info["feature_edit_actions"] = "推拉平面" info.update( self._local_face_plane_size_info( face_id, face=face, surf=surf, center=_tuple_or_none(info.get("area_center")), ) ) 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=False) info["radius"] = radius info["diameter"] = 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["feature_source_face_id"] = face_id info["feature_highlight_face_ids"] = (face_id,) if u_span < math.tau * 0.92: info["slot_kind"] = "partial-cylindrical-groove" info["slot_angular_span"] = u_span info["slot_open_angle"] = max(math.tau - u_span, 0.0) info["slot_chord_width_estimate"] = 2.0 * radius * math.sin(min(u_span, math.tau) * 0.5) info["slot_sagitta_depth_estimate"] = radius * (1.0 - math.cos(min(u_span, math.tau) * 0.5)) info["slot_arc_length_estimate"] = radius * u_span 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() info["feature_reference_radius"] = cone.RefRadius() info["feature_reference_diameter"] = cone.RefRadius() * 2.0 elif surface_type == GeomAbs_Sphere: sphere = surf.Sphere() info["center"] = _point_tuple(sphere.Location()) info["radius"] = sphere.Radius() info["diameter"] = sphere.Radius() * 2.0 info["feature_sphere_radius"] = sphere.Radius() info["feature_sphere_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() info["feature_torus_major_radius"] = torus.MajorRadius() info["feature_torus_minor_radius"] = torus.MinorRadius() self._quick_face_info_cache[face_id] = dict(info) return dict(info) 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, "topological_face_id": face_id, "logical_face_id": self.face_logical_id(face_id), "face_region_logical_id": self.face_region_logical_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"] info.update( self._local_face_plane_size_info( face_id, face=face, surf=surf, center=_tuple_or_none(info.get("area_center")), ) ) 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 face_surface_kind(self, face_id: int) -> str: if face_id < 0 or face_id >= len(self.faces): return "" cached = self._face_info_cache.get(face_id) if cached is not None and cached.get("surface") is not None: return str(cached.get("surface")) try: surf = BRepAdaptor_Surface(self.faces[face_id]) surface_type = surf.GetType() except Exception: return "" return SURFACE_TYPES.get(surface_type, f"type {surface_type}") def face_cylinder_diameter(self, face_id: int) -> float | None: if face_id < 0 or face_id >= len(self.faces): return None cached = self._face_info_cache.get(face_id) if cached is not None and cached.get("diameter") is not None and cached.get("surface") == "cylinder": try: return float(cached["diameter"]) except (TypeError, ValueError): pass try: surf = BRepAdaptor_Surface(self.faces[face_id]) if surf.GetType() != GeomAbs_Cylinder: return None return float(surf.Cylinder().Radius()) * 2.0 except Exception: return None def cached_feature_info(self, face_id: int) -> dict[str, object] | None: cached = self._feature_info_cache.get(face_id) return dict(cached) if cached is not None else None 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}") if face_id in self._feature_info_cache: return dict(self._feature_info_cache[face_id]) info = self.face_info(face_id) surface = str(info.get("surface", "")) if surface == "cylinder": result = self._cylindrical_feature_info(face_id, info) elif surface == "plane": result = self._planar_feature_info(face_id, info) elif surface == "cone": result = self._conical_feature_info(face_id, info) elif surface == "sphere": result = self._spherical_feature_info(face_id, info) elif surface == "torus": result = self._toroidal_feature_info(face_id, info) else: 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解释为局部几何特征候选;" "复杂曲面局部面积修改需要更明确的边界/约束重建,当前不会把面积伪装成直接可改参数。" ), } ) self._feature_info_cache[face_id] = dict(result) return dict(result) def _toroidal_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]: boundary_edge_ids = self._face_boundary_edge_ids(face_id) major_radius = float(info.get("major_radius", 0.0) or 0.0) minor_radius = float(info.get("minor_radius", 0.0) or 0.0) 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": ( "这是从 STEP/B-Rep 环面直接识别出的几何候选;当前修改会围绕环面中心缩放所属零件/Solid," "主半径和小半径会等比例变化,不是 CAD 历史里的管径或圆角参数。" ), "feature_torus_major_radius": major_radius, "feature_torus_minor_radius": minor_radius, } ) return result def _spherical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]: boundary_edge_ids = self._face_boundary_edge_ids(face_id) radius = float(info.get("radius", 0.0) or 0.0) 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": ( "这是从 STEP/B-Rep 球面直接识别出的几何候选;修改会围绕球心缩放所属零件/Solid," "不是 CAD 历史里的球面或圆角参数。" ), "feature_sphere_radius": radius, "feature_sphere_diameter": radius * 2.0 if radius > 0 else "", } ) return result def _conical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]: boundary_edge_ids = self._face_boundary_edge_ids(face_id) reference_radius = float(info.get("reference_radius", 0.0) or 0.0) 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_reference_radius": reference_radius, "feature_reference_diameter": reference_radius * 2.0 if reference_radius > 0 else "", "feature_edit_actions": "修改圆锥参考半径/直径", "feature_mode": ( "这是从 STEP/B-Rep 圆锥面直接识别出的几何候选;修改会围绕圆锥轴做径向缩放," "不是 CAD 历史里的锥孔或倒角参数。" ), } ) 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) shell_info = self._planar_shell_region_info(face_id, coplanar_face_ids, info) if len(coplanar_face_ids) > 1: scope_note = f"已检测到 {len(coplanar_face_ids)} 个共面且相接/重叠的 face,推拉时会作为同一片平面区域处理。" else: scope_note = "当前 face 没有检测到可一起推拉的共面相接/重叠邻居。" edit_actions = "推拉平面" if shell_info.get("shell_region_status") == "candidate": edit_actions += ";调整薄壁/壳体厚度" 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": edit_actions, "feature_mode": "这是从 B-Rep 几何推断出的平面编辑候选,不是 CAD 历史特征。", **shell_info, } ) return result def _planar_shell_region_info( self, face_id: int, coplanar_face_ids: Iterable[int], info: dict[str, object], ) -> dict[str, object]: try: source_surf = BRepAdaptor_Surface(self.faces[face_id]) except Exception: return { "shell_region_status": "not-detected", "shell_region_note": "无法读取当前平面,不能估算薄壁/壳体区域。", } if source_surf.GetType() != GeomAbs_Plane: return {} plane = source_surf.Plane() normal = plane.Axis().Direction() u_dir, v_dir = _plane_basis_dirs(normal) valid_region_ids = sorted({int(item) for item in coplanar_face_ids if 0 <= int(item) < len(self.faces)}) if not valid_region_ids: valid_region_ids = [face_id] try: region_shape = _compound_from_shapes(self.faces[item] for item in valid_region_ids) source_interval = _shape_plane_interval(region_shape, plane.Location(), u_dir, v_dir) except Exception: source_interval = _shape_plane_interval(self.faces[face_id], plane.Location(), u_dir, v_dir) if source_interval is None: return { "shell_region_status": "not-detected", "shell_region_note": "当前平面区域缺少稳定投影范围,不能估算薄壁/壳体厚度。", } def interval_length(interval: tuple[float, float]) -> float: return max(float(max(interval) - min(interval)), 0.0) def overlap_length(left: tuple[float, float], right: tuple[float, float]) -> float: left_min, left_max = min(left), max(left) right_min, right_max = min(right), max(right) return max(min(left_max, right_max) - max(left_min, right_min), 0.0) source_area = max(interval_length(source_interval[0]) * interval_length(source_interval[1]), 1e-12) diagonal = max(_shape_diagonal(self.shape), 1.0) tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) source_solid_id = self.face_solid_ids[face_id] region_id_set = set(valid_region_ids) best: dict[str, object] | None = None best_score: tuple[float, float] | None = None for candidate_id, face in enumerate(self.faces): if candidate_id in region_id_set: continue if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id: continue try: candidate_surf = BRepAdaptor_Surface(face) except Exception: continue if candidate_surf.GetType() != GeomAbs_Plane: continue candidate_plane = candidate_surf.Plane() normal_dot = _direction_dot(normal, candidate_plane.Axis().Direction()) if abs(normal_dot) < 0.985: continue thickness = abs(_axis_parameter(plane.Location(), normal, candidate_plane.Location())) if thickness <= tolerance: continue interval = _shape_plane_interval(face, plane.Location(), u_dir, v_dir) if interval is None: continue overlap_area = overlap_length(source_interval[0], interval[0]) * overlap_length(source_interval[1], interval[1]) overlap_ratio = overlap_area / source_area if overlap_ratio <= 0.02: continue score = (thickness, -overlap_ratio) if best_score is None or score < best_score: best_score = score best = { "shell_opposite_face_id": candidate_id, "shell_thickness_estimate": thickness, "shell_signed_thickness": _axis_parameter(plane.Location(), normal, candidate_plane.Location()), "shell_overlap_ratio_estimate": overlap_ratio, "shell_opposite_normal_dot": normal_dot, } if best is None: return { "shell_region_status": "not-detected", "shell_region_note": "未找到与当前平面投影重叠的相对平面;暂不能估算局部壳体/薄壁厚度。", } thickness = float(best["shell_thickness_estimate"]) overlap_ratio = float(best["shell_overlap_ratio_estimate"]) thin_ratio = thickness / diagonal if overlap_ratio >= 0.55 and thin_ratio <= 0.08: confidence = "high" elif overlap_ratio >= 0.25 and thin_ratio <= 0.18: confidence = "medium" else: confidence = "low" kind = "thin-wall-opposite-plane-candidate" if thin_ratio <= 0.18 else "opposite-plane-region-candidate" return { "shell_region_kind": kind, "shell_region_status": "candidate", "shell_confidence": confidence, "shell_source_face_ids": tuple(valid_region_ids), **best, "shell_note": ( "通过同一 solid 内投影重叠的相对平面估算薄壁/壳体厚度;" "这是 B-Rep 几何近似,不等同于原 CAD 壳命令参数。" ), } def _cylindrical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]: side_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id] surf = BRepAdaptor_Surface(self.faces[face_id]) axis_range = self._cylindrical_axis_range(face_id, surf, side_face_ids) side_face_set = set(side_face_ids) boundary_edge_ids = self._region_boundary_edge_ids(side_face_ids) adjacent_face_ids = sorted(set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - side_face_set) domain_info = dict(info) domain_info["v_range"] = (axis_range["v_min"], axis_range["v_max"]) domain_info["height_estimate"] = axis_range["span"] end_faces = self._cylindrical_end_face_groups(face_id, adjacent_face_ids, domain_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, domain_info) fillet_info = self._cylindrical_existing_fillet_info(face_id, adjacent_face_ids, end_face_ids, domain_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 = "槽/半孔候选" edit_actions = "调整圆柱孔径;调整槽/半孔宽度;调整槽/半孔深度;调整槽/半孔圆弧长度;调整槽/半孔圆弧角度;调整槽孔总长度" else: feature_type = "圆柱孔候选" edit_actions = "调整圆柱孔径" 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( { *side_face_ids, *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": tuple(side_face_ids), "feature_side_face_ids": tuple(side_face_ids), "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), "same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]), "same_domain_height_estimate": axis_range["span"], "same_domain_range_source": axis_range["range_source"], "same_domain_face_ids": tuple(side_face_ids), "same_domain_face_count": len(side_face_ids), "same_domain_note": ( "已把同一实体内同轴、同半径且轴向连续/重叠的圆柱 face 当作同一几何区域。" if len(side_face_ids) > 1 else "当前圆柱 face 没有检测到可合并选择的同域圆柱面。" ), "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] = [] cap_scan_skipped = False 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): if len(self.faces) <= 1000: 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"]) else: cap_scan_skipped = True 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 = "没有在圆柱边界附近找到平面端面。" if cap_scan_skipped: note += " 当前模型 Face 数较多,已跳过全模型底面扫描以避免选择时卡顿;如需改盲孔/盲槽深度,可以手动填写底面 Face ID。" elif bottom_face_ids: if bottom_detection == "axis-cap-scan": note = "已通过轴线端部采样和轴线附近圆盘面扫描标记疑似底面;这是几何推断,不是 CAD 历史孔深。" else: note = "已根据圆柱轴线端部 inside/outside 采样标记疑似底面;这是几何推断,不是 CAD 历史孔深。" else: note = "已找到端面候选,但端部采样显示这些端面更像开口附近的相邻面。" if cap_scan_skipped: note += " 当前模型 Face 数较多,已跳过全模型底面扫描以避免选择时卡顿。" 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 face_logical_id(self, face_id: int) -> int: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") if face_id < len(self.face_logical_ids): return int(self.face_logical_ids[face_id]) return face_id def face_region_logical_id(self, face_id: int) -> int: face_ids = self.connected_same_domain_face_ids(face_id) or [face_id] return min(self.face_logical_id(item) for item in face_ids if 0 <= item < len(self.faces)) def face_ids_for_logical_id(self, logical_id: int) -> list[int]: logical_id = int(logical_id) return [face_id for face_id in range(len(self.faces)) if self.face_logical_id(face_id) == logical_id] def resolve_face_selection_id(self, face_or_logical_id: int) -> int | None: logical_matches = self.face_ids_for_logical_id(int(face_or_logical_id)) if logical_matches: return logical_matches[0] if 0 <= int(face_or_logical_id) < len(self.faces): return int(face_or_logical_id) return None def assign_logical_face_region(self, logical_id: int, face_ids: Iterable[int]) -> None: valid_face_ids = sorted({int(face_id) for face_id in face_ids if 0 <= int(face_id) < len(self.faces)}) if not valid_face_ids: return for face_id in valid_face_ids: self.face_logical_ids[face_id] = int(logical_id) self._quick_face_info_cache.pop(face_id, None) self._face_info_cache.pop(face_id, None) self._feature_info_cache.pop(face_id, None) 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 nearest_face_id_to_point( self, face_ids: Iterable[int], point: tuple[float, float, float] | None, ) -> int | None: valid_face_ids = [int(face_id) for face_id in face_ids if 0 <= int(face_id) < len(self.faces)] if not valid_face_ids: return None if point is None: return valid_face_ids[0] point_vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(float(point[0]), float(point[1]), float(point[2]))).Vertex() exact_face_ids = valid_face_ids if len(valid_face_ids) > 48: scored_face_ids: list[tuple[float, int]] = [] for face_id in valid_face_ids: scored_face_ids.append((self._face_bounds_distance_sq(face_id, point), face_id)) scored_face_ids.sort(key=lambda item: item[0]) exact_face_ids = [face_id for _distance, face_id in scored_face_ids[:48]] best_face_id: int | None = None best_distance = math.inf for face_id in exact_face_ids: try: extrema = BRepExtrema_DistShapeShape(point_vertex, self.faces[face_id]) extrema.Perform() if not extrema.IsDone(): continue distance = float(extrema.Value()) except Exception: center = _surface_center(self.faces[face_id]) distance = math.sqrt( _point_distance_sq( (float(point[0]), float(point[1]), float(point[2])), _point_tuple(center), ) ) if distance < best_distance: best_distance = distance best_face_id = face_id return best_face_id if best_face_id is not None else exact_face_ids[0] def _face_bounds_distance_sq(self, face_id: int, point: tuple[float, float, float]) -> float: px, py, pz = (float(point[0]), float(point[1]), float(point[2])) try: xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(self.faces[face_id]) dx = max(xmin - px, 0.0, px - xmax) dy = max(ymin - py, 0.0, py - ymax) dz = max(zmin - pz, 0.0, pz - zmax) return dx * dx + dy * dy + dz * dz except Exception: try: center = _surface_center(self.faces[face_id]) return _point_distance_sq((px, py, pz), _point_tuple(center)) except Exception: return math.inf def _adjacent_face_ids_for_edges(self, edge_ids: list[int], face_id: int) -> list[int]: if not edge_ids: return [] source_solid_id = self.face_solid_ids[face_id] adjacent: set[int] = set() for edge_id in edge_ids: if edge_id < 0 or edge_id >= len(self.edges): continue for candidate_id in self._edge_adjacent_face_ids(edge_id): if candidate_id == face_id: continue if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id: continue adjacent.add(candidate_id) return sorted(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) interval_tolerance = max(tolerance * 20.0, _shape_diagonal(self.shape) * 1e-6, 1e-4) plane = source_surf.Plane() axis_point = plane.Location() u_dir, v_dir = _plane_basis_dirs(plane.Axis().Direction()) candidates: dict[int, tuple[tuple[float, float], tuple[float, float]]] = {} for candidate_id, face in enumerate(self.faces): if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id: continue candidate_surf = BRepAdaptor_Surface(face) if not _surfaces_are_coplanar(source_surf, candidate_surf, tolerance): continue interval = _shape_plane_interval(face, axis_point, u_dir, v_dir) if interval is not None: candidates[candidate_id] = interval if face_id in candidates: visited = {face_id} queue = [face_id] while queue: current_id = queue.pop(0) current_interval = candidates[current_id] for candidate_id, candidate_interval in candidates.items(): if candidate_id in visited: continue if _plane_intervals_touch_or_overlap( current_interval, candidate_interval, interval_tolerance, ): visited.add(candidate_id) queue.append(candidate_id) return sorted(visited) 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 connected_same_domain_face_ids(self, face_id: int) -> list[int]: if face_id < 0 or face_id >= len(self.faces): return [] if face_id in self._same_domain_face_ids_cache: return list(self._same_domain_face_ids_cache[face_id]) source_surf = BRepAdaptor_Surface(self.faces[face_id]) surface_type = source_surf.GetType() if surface_type == GeomAbs_Plane: face_ids = self._connected_coplanar_planar_face_ids(face_id) elif surface_type == GeomAbs_Cylinder: face_ids = self._connected_cocylindrical_face_ids(face_id) else: face_ids = [face_id] face_ids = sorted(set(face_ids or [face_id])) for item in face_ids: self._same_domain_face_ids_cache[item] = list(face_ids) return list(face_ids) def _connected_cocylindrical_face_ids(self, face_id: int) -> list[int]: if face_id < 0 or face_id >= len(self.faces): return [] source_solid_id = self.face_solid_ids[face_id] source_surf = BRepAdaptor_Surface(self.faces[face_id]) if source_surf.GetType() != GeomAbs_Cylinder: return [face_id] cylinder = source_surf.Cylinder() axis = cylinder.Axis() axis_point = axis.Location() axis_dir = axis.Direction() radius = max(float(cylinder.Radius()), 0.0) diagonal = _shape_diagonal(self.shape) tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) interval_tolerance = max(tolerance * 50.0, diagonal * 1e-5, radius * 1e-4, 1e-3) source_interval = _shape_axis_interval(self.faces[face_id], axis_point, axis_dir) candidates: dict[int, tuple[float, float]] = {} for candidate_id, face in enumerate(self.faces): if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id: continue candidate_surf = BRepAdaptor_Surface(face) if not _surfaces_are_cocylindrical(source_surf, candidate_surf, tolerance): continue interval = _shape_axis_interval(face, axis_point, axis_dir) if interval is not None: candidates[candidate_id] = interval if source_interval is not None and face_id in candidates: visited = {face_id} queue = [face_id] while queue: current_id = queue.pop(0) current_interval = candidates[current_id] for candidate_id, candidate_interval in candidates.items(): if candidate_id in visited: continue if _intervals_touch_or_overlap( current_interval, candidate_interval, interval_tolerance, ): visited.add(candidate_id) queue.append(candidate_id) return sorted(visited) 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_cocylindrical(source_surf, candidate_surf, tolerance): visited.add(adjacent_id) queue.append(adjacent_id) return sorted(visited) def _cylindrical_axis_range( self, face_id: int, surf: BRepAdaptor_Surface | None = None, face_ids: Iterable[int] | None = None, ) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") surf = surf or BRepAdaptor_Surface(self.faces[face_id]) if surf.GetType() != GeomAbs_Cylinder: raise ValueError("Selected face is not cylindrical.") cyl = surf.Cylinder() axis = cyl.Axis() axis_point = axis.Location() axis_dir = axis.Direction() source_v_min = min(float(surf.FirstVParameter()), float(surf.LastVParameter())) source_v_max = max(float(surf.FirstVParameter()), float(surf.LastVParameter())) if face_ids is None: domain_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id] else: domain_face_ids = sorted({int(item) for item in face_ids if 0 <= int(item) < len(self.faces)}) if face_id not in domain_face_ids: domain_face_ids.append(face_id) domain_face_ids.sort() intervals: list[tuple[float, float]] = [] tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3) for item in domain_face_ids: item_surf = BRepAdaptor_Surface(self.faces[item]) if item_surf.GetType() != GeomAbs_Cylinder: continue if not _surfaces_are_cocylindrical(item_surf, surf, tolerance): continue interval = _shape_axis_interval(self.faces[item], axis_point, axis_dir) if interval is not None: intervals.append(interval) if intervals and len(domain_face_ids) > 1: v_min = min(interval[0] for interval in intervals) v_max = max(interval[1] for interval in intervals) range_source = "same-domain-cylinder-faces" else: v_min = source_v_min v_max = source_v_max range_source = "selected-face-v-range" return { "axis_point": axis_point, "axis_direction": axis_dir, "v_min": v_min, "v_max": v_max, "span": max(v_max - v_min, 0.0), "source_v_min": source_v_min, "source_v_max": source_v_max, "same_domain_face_ids": tuple(domain_face_ids), "same_domain_face_count": len(domain_face_ids), "range_source": range_source, } 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 _face_region_mapping_specs(self, face_ids: Iterable[int]) -> list[dict[str, object]]: specs: list[dict[str, object]] = [] seen_logical_ids: set[int] = set() diagonal = _shape_diagonal(self.shape) tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) for face_id in sorted({int(item) for item in face_ids if 0 <= int(item) < len(self.faces)}): logical_id = self.face_region_logical_id(face_id) if logical_id in seen_logical_ids: continue seen_logical_ids.add(logical_id) face = self.faces[face_id] surf = BRepAdaptor_Surface(face) if surf.GetType() == GeomAbs_Plane: plane = surf.Plane() normal = plane.Axis().Direction() u_dir, v_dir = _plane_basis_dirs(normal) interval = _shape_plane_interval(face, plane.Location(), u_dir, v_dir) if interval is None: continue specs.append( { "surface": "plane", "logical_id": logical_id, "part_id": self.face_part_ids[face_id], "point": _point_tuple(plane.Location()), "normal": _dir_tuple(normal), "u_dir": _dir_tuple(u_dir), "v_dir": _dir_tuple(v_dir), "interval": interval, "tolerance": tolerance, } ) elif surf.GetType() == GeomAbs_Cylinder: cylinder = surf.Cylinder() axis = cylinder.Axis() interval = _shape_axis_interval(face, axis.Location(), axis.Direction()) if interval is None: continue specs.append( { "surface": "cylinder", "logical_id": logical_id, "part_id": self.face_part_ids[face_id], "axis_point": _point_tuple(axis.Location()), "axis_direction": _dir_tuple(axis.Direction()), "radius": float(cylinder.Radius()), "interval": interval, "tolerance": tolerance, } ) return specs def _apply_face_region_mapping_specs(self, specs: Iterable[dict[str, object]]) -> None: for spec in specs: logical_id = int(spec.get("logical_id", -1)) if logical_id < 0: continue seed_face_ids = self._matching_face_ids_for_region_spec(spec) if not seed_face_ids: continue region_ids: set[int] = set() for seed_face_id in seed_face_ids: region_ids.update(self.connected_same_domain_face_ids(seed_face_id) or [seed_face_id]) self.assign_logical_face_region(logical_id, region_ids) def _matching_face_ids_for_region_spec(self, spec: dict[str, object]) -> list[int]: surface = str(spec.get("surface", "")) part_id = int(spec.get("part_id", -1)) tolerance_value = spec.get("tolerance") if tolerance_value is None: tolerance_value = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3) tolerance = float(tolerance_value) matches: list[int] = [] for face_id, face in enumerate(self.faces): if part_id >= 0 and self.face_part_ids[face_id] != part_id: continue surf = BRepAdaptor_Surface(face) if surface == "plane": if not _surface_matches_plane_spec(surf, spec, tolerance): continue plane = surf.Plane() u_dir = gp_Dir(*spec["u_dir"]) v_dir = gp_Dir(*spec["v_dir"]) interval = _shape_plane_interval(face, gp_Pnt(*spec["point"]), u_dir, v_dir) if interval is not None and _plane_intervals_touch_or_overlap(interval, spec["interval"], max(tolerance * 20.0, 1e-4)): matches.append(face_id) elif surface == "cylinder": if not _surface_matches_cylinder_spec(surf, spec, tolerance): continue interval = _shape_axis_interval(face, gp_Pnt(*spec["axis_point"]), gp_Dir(*spec["axis_direction"])) interval_tolerance = max(tolerance * 50.0, _shape_diagonal(self.shape) * 1e-5, float(spec.get("radius", 0.0)) * 1e-4, 1e-3) if interval is not None and _intervals_touch_or_overlap(interval, spec["interval"], interval_tolerance): matches.append(face_id) return matches 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 if curve_type == GeomAbs_Ellipse: ellipse = curve.Ellipse() info["center"] = _point_tuple(ellipse.Location()) info["axis"] = _dir_tuple(ellipse.Axis().Direction()) info["major_axis"] = _dir_tuple(ellipse.XAxis().Direction()) info["minor_axis"] = _dir_tuple(ellipse.YAxis().Direction()) info["major_radius"] = ellipse.MajorRadius() info["minor_radius"] = ellipse.MinorRadius() 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