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_MakeFace, BRepBuilderAPI_MakeVertex, BRepBuilderAPI_MakeWire, 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, TopAbs_VERTEX, TopAbs_WIRE, ) from OCC.Core.TopExp import TopExp_Explorer, topexp from OCC.Core.TopLoc import TopLoc_Location from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_IndexedMapOfShape from OCC.Core.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._face_first_level_topology_cache: dict[int, dict[str, object]] = {} self._cylindrical_first_level_topology_cache: dict[int, dict[str, object]] = {} self._local_face_deform_readiness_cache: dict[int, dict[str, object]] = {} 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._face_first_level_topology_cache.clear() self._cylindrical_first_level_topology_cache.clear() self._local_face_deform_readiness_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() self._face_first_level_topology_cache.clear() self._cylindrical_first_level_topology_cache.clear() self._local_face_deform_readiness_cache.clear() def _restore_face_logical_ids_if_count_matches(self, logical_ids: Iterable[int]) -> bool: previous = tuple(int(item) for item in logical_ids) if len(previous) != len(self.faces): return False self.face_logical_ids = list(previous) self._quick_face_info_cache.clear() self._face_info_cache.clear() self._feature_info_cache.clear() self._same_domain_face_ids_cache.clear() self._face_first_level_topology_cache.clear() self._cylindrical_first_level_topology_cache.clear() self._local_face_deform_readiness_cache.clear() return True 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)) info.update(self._face_boundary_wire_info(face)) if surface_type == GeomAbs_Plane: plane = surf.Plane() direction = plane.Axis().Direction() info["plane_origin"] = _point_tuple(plane.Location()) info["normal"] = _dir_tuple(direction) info["oriented_normal"] = _oriented_dir_tuple(direction, face) info["push_pull_confidence"] = "unchecked" info["push_pull_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_deform_readiness(face_id)) 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) 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"] = "cylindrical face" info["confidence"] = "unchecked" info["material_toward_axis"] = "not sampled" info["material_away_axis"] = "not sampled" info["material_vote_summary"] = "quick selection skips material sampling" info["material_sample_count"] = 0 info["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)) info.update(self._face_boundary_wire_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_deform_readiness(face_id)) 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 associated_feature_infos( self, face_id: int, *, max_depth: int = 3, max_scan_faces: int = 72, max_features: int = 10, ) -> list[dict[str, object]]: """Detect editable feature candidates near the selected face. STEP does not store a dependable CAD feature-history graph, so this uses a shallow shared-edge walk. It can cross small cap/support faces to reach a nearby hole, slot, boss, or analytic surface, but it avoids scanning an entire solid through large carrier planes. """ if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") source_info = self.feature_info(face_id) source_area = max(float(source_info.get("area", 0.0) or 0.0), 1e-12) source_feature_faces = set(_int_values(source_info.get("feature_face_ids"))) or {face_id} visited = {face_id} frontier: list[tuple[int, int]] = [(face_id, 0)] candidate_hops: dict[int, int] = {} while frontier and len(visited) < max_scan_faces: current_id, depth = frontier.pop(0) if depth >= max_depth: continue edge_ids = self._face_boundary_edge_ids(current_id) neighbors = sorted(set(self._adjacent_face_ids_for_edges(edge_ids, current_id)) - {current_id}) for neighbor_id in neighbors: candidate_hops[neighbor_id] = min(candidate_hops.get(neighbor_id, depth + 1), depth + 1) if neighbor_id in visited or len(visited) >= max_scan_faces: continue visited.add(neighbor_id) expand = True if neighbor_id != face_id and depth >= 1: quick = self.quick_face_info(neighbor_id) neighbor_area = float(quick.get("area", 0.0) or 0.0) if str(quick.get("surface", "")) == "plane" and neighbor_area > source_area * 8.0: expand = False if expand: frontier.append((neighbor_id, depth + 1)) results: list[dict[str, object]] = [] seen_features: set[tuple[str, frozenset[int]]] = set() for candidate_id, hop_count in sorted(candidate_hops.items(), key=lambda item: (item[1], item[0])): if candidate_id in source_feature_faces: continue try: info = self.feature_info(candidate_id) except Exception: continue surface = str(info.get("surface", "") or "") feature_guess = str(info.get("feature_guess", "") or "") feature_type = str(info.get("feature_type", "") or "") is_semantic = bool( info.get("prismatic_extrusion_status") == "candidate" or surface in {"cone", "sphere", "torus"} or ( surface == "cylinder" and feature_guess in { "hole/groove candidate", "boss/outer-round candidate", "round/fillet candidate", } ) ) if not is_semantic: continue identity_face_ids = _int_values(info.get("feature_face_ids")) if info.get("prismatic_profile_status") == "candidate": identity_face_ids = ( _int_values(info.get("prismatic_highlight_face_ids")) or _int_values(info.get("feature_highlight_face_ids")) ) feature_faces = frozenset(identity_face_ids or [candidate_id]) identity = (feature_type or feature_guess or surface, feature_faces) if identity in seen_features: continue seen_features.add(identity) related = dict(info) related.update( { "association_source_face_id": candidate_id, "association_hop_count": hop_count, "association_relation": "shared-edge-topology", "association_priority": ( 0 if surface == "cylinder" else (1 if surface in {"cone", "sphere", "torus"} else 2) ), } ) results.append(related) results.sort( key=lambda item: ( int(item.get("association_priority", 9)), int(item.get("association_hop_count", 99)), int(item.get("association_source_face_id", 0)), ) ) return results[:max_features] 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_type": prismatic_info.get("feature_type", "可推拉平面候选"), "feature_source_face_id": face_id, "feature_face_ids": (face_id,), "feature_highlight_face_ids": (face_id,), "feature_highlight_face_ids": tuple(sorted(highlight_face_ids)), "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) boundary_info: dict[str, object] = {} axis_point = _tuple_or_none(info.get("axis_point")) axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis"))) if axis_point is not None and axis_direction is not None: try: circles = self._conical_face_circle_boundaries(face_id, axis_point, axis_direction) except Exception: circles = [] if len(circles) == 2: sorted_circles = sorted(circles, key=lambda item: float(item["radius"])) small = sorted_circles[0] large = sorted_circles[1] small_center = tuple(float(value) for value in small["center"]) large_center = tuple(float(value) for value in large["center"]) height = _vector_length(_tuple_sub(large_center, small_center)) small_radius = float(small["radius"]) large_radius = float(large["radius"]) if height > 1e-9 and large_radius > small_radius > 1e-9: boundary_info.update( { "feature_cone_small_radius": small_radius, "feature_cone_small_diameter": small_radius * 2.0, "feature_cone_large_radius": large_radius, "feature_cone_large_diameter": large_radius * 2.0, "feature_cone_height": height, "feature_cone_boundary_half_angle_degrees": math.degrees( math.atan((large_radius - small_radius) / height) ), } ) 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 历史里的锥孔或倒角参数。" ), **boundary_info, } ) 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) prismatic_info = self._planar_rectangular_profile_info(face_id, coplanar_face_ids, info, shell_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 += ";调整薄壁/壳体厚度" if prismatic_info.get("prismatic_profile_status") == "candidate": edit_actions = "调整规则矩形轮廓长度/宽度" if prismatic_info.get("prismatic_extrusion_status") == "candidate": edit_actions += ";调整棱柱高度/凹槽深度" else: edit_actions += ";沿法向推拉" highlight_face_ids = set(coplanar_face_ids) highlight_face_ids.update(_int_values(prismatic_info.get("prismatic_highlight_face_ids"))) 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, **prismatic_info, } ) return result def _planar_rectangular_profile_info( self, face_id: int, coplanar_face_ids: Iterable[int], info: dict[str, object], shell_info: dict[str, object], ) -> dict[str, object]: region_ids = sorted({int(item) for item in coplanar_face_ids}) if region_ids != [face_id]: return { "prismatic_profile_status": "not-detected", "prismatic_profile_note": "共面区域包含多个 Face,暂不把整体包围盒当作规则矩形特征尺寸。", } edge_ids = self._face_boundary_edge_ids(face_id) if len(edge_ids) != 4: return { "prismatic_profile_status": "not-detected", "prismatic_profile_note": "规则矩形轮廓需要恰好四条边。", } direction_groups: list[dict[str, object]] = [] for edge_id in edge_ids: try: curve = BRepAdaptor_Curve(self.edges[edge_id]) if curve.GetType() != GeomAbs_Line: return { "prismatic_profile_status": "not-detected", "prismatic_profile_note": "轮廓含非直线边,不按规则矩形特征处理。", } start = _point_tuple(curve.Value(curve.FirstParameter())) end = _point_tuple(curve.Value(curve.LastParameter())) vector = _tuple_sub(end, start) length = math.sqrt(_tuple_dot(vector, vector)) direction = _tuple_normalized(vector) except Exception: direction = None length = 0.0 if direction is None or length <= 1e-9: return { "prismatic_profile_status": "not-detected", "prismatic_profile_note": "矩形轮廓存在退化边或无法读取的直线边。", } matched_group = None for group in direction_groups: group_direction = _tuple_or_none(group.get("direction")) if group_direction is not None and abs(_tuple_dot(direction, group_direction)) >= 0.999: matched_group = group break if matched_group is None: matched_group = {"direction": direction, "lengths": [], "edge_ids": []} direction_groups.append(matched_group) matched_group["lengths"].append(length) matched_group["edge_ids"].append(edge_id) if len(direction_groups) != 2 or any(len(group["lengths"]) != 2 for group in direction_groups): return { "prismatic_profile_status": "not-detected", "prismatic_profile_note": "四条边没有形成两组稳定的平行对边。", } first_direction = _tuple_or_none(direction_groups[0].get("direction")) second_direction = _tuple_or_none(direction_groups[1].get("direction")) if first_direction is None or second_direction is None or abs(_tuple_dot(first_direction, second_direction)) > 0.01: return { "prismatic_profile_status": "not-detected", "prismatic_profile_note": "两组对边不垂直,不按规则矩形特征处理。", } for group in direction_groups: lengths = [float(item) for item in group["lengths"]] average = sum(lengths) / len(lengths) if max(abs(item - average) for item in lengths) > max(average * 1e-4, 1e-7): return { "prismatic_profile_status": "not-detected", "prismatic_profile_note": "矩形候选的相对边长度不一致。", } group["average_length"] = average direction_groups.sort(key=lambda group: float(group["average_length"]), reverse=True) length = float(direction_groups[0]["average_length"]) width = float(direction_groups[1]["average_length"]) area = _float_or_none(info.get("area")) area_ratio = area / max(length * width, 1e-12) if area is not None else 0.0 if area is None or abs(area_ratio - 1.0) > 0.01: return { "prismatic_profile_status": "not-detected", "prismatic_profile_note": "轮廓面积与长乘宽不一致,可能存在内孔或非矩形裁剪。", "prismatic_profile_area_ratio": area_ratio, } adjacent_side_ids = sorted(set(self._adjacent_face_ids_for_edges(edge_ids, face_id)) - {face_id}) try: opposite_face_id = int(shell_info["shell_opposite_face_id"]) except (KeyError, TypeError, ValueError): opposite_face_id = None connected_side_ids: list[int] = [] if opposite_face_id is not None: for side_id in adjacent_side_ids: side_neighbors = self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(side_id), side_id) if opposite_face_id in side_neighbors: connected_side_ids.append(side_id) reference_face_ids = [opposite_face_id] if opposite_face_id is not None else [] topology_reference = False signed_extrusion = _float_or_none(shell_info.get("shell_signed_thickness")) if len(connected_side_ids) < 2: source_plane = BRepAdaptor_Surface(self.faces[face_id]).Plane() source_normal = source_plane.Axis().Direction() solid_id = self.face_solid_ids[face_id] tolerance = max(_shape_diagonal(self.faces[face_id]) * 1e-6, 1e-6) groups: list[dict[str, object]] = [] for side_id in adjacent_side_ids: neighbors = self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(side_id), side_id) for candidate_id in neighbors: if candidate_id == face_id or candidate_id in region_ids: continue if solid_id >= 0 and self.face_solid_ids[candidate_id] != solid_id: continue try: candidate_surface = BRepAdaptor_Surface(self.faces[candidate_id]) if candidate_surface.GetType() != GeomAbs_Plane: continue candidate_plane = candidate_surface.Plane() normal_dot = _direction_dot(source_normal, candidate_plane.Axis().Direction()) if abs(normal_dot) < 0.995: continue signed_distance = _axis_parameter( source_plane.Location(), source_normal, candidate_plane.Location(), ) except Exception: continue if abs(signed_distance) <= tolerance: continue matched_group = None for group in groups: if abs(float(group["signed_distance"]) - signed_distance) <= tolerance * 20.0: matched_group = group break if matched_group is None: matched_group = { "signed_distance": signed_distance, "face_ids": set(), "side_ids": set(), "normal_dot": normal_dot, } groups.append(matched_group) matched_group["face_ids"].add(candidate_id) matched_group["side_ids"].add(side_id) eligible_groups = [group for group in groups if len(group["side_ids"]) >= 2] if eligible_groups: eligible_groups.sort(key=lambda group: (-len(group["side_ids"]), abs(float(group["signed_distance"])))) best_group = eligible_groups[0] reference_face_ids = sorted(int(item) for item in best_group["face_ids"]) connected_side_ids = sorted(int(item) for item in best_group["side_ids"]) opposite_face_id = reference_face_ids[0] signed_extrusion = float(best_group["signed_distance"]) topology_reference = True support_ratio = len(connected_side_ids) / max(len(adjacent_side_ids), 1) shell_info.update( { "shell_region_status": "candidate", "shell_region_kind": "prismatic-topology-reference", "shell_source_face_ids": tuple(region_ids), "shell_opposite_face_id": opposite_face_id, "shell_thickness_estimate": abs(signed_extrusion), "shell_signed_thickness": signed_extrusion, "shell_overlap_ratio_estimate": support_ratio, "shell_opposite_normal_dot": best_group["normal_dot"], "shell_confidence": "high" if support_ratio >= 0.99 else "medium", "shell_note": "通过矩形轮廓的相邻侧壁找到高度/深度基准。", } ) extrusion_candidate = opposite_face_id is not None and len(connected_side_ids) >= 2 profile_confidence = "high" if len(adjacent_side_ids) == 4 and area_ratio >= 0.999 else "medium" feature_type = "规则矩形棱柱候选" if extrusion_candidate else "规则矩形平面候选" feature_semantics = "generic-prismatic" if extrusion_candidate and reference_face_ids: reference_area = 0.0 for reference_id in reference_face_ids: props = GProp_GProps() brepgprop.SurfaceProperties(self.faces[reference_id], props) reference_area += float(props.Mass()) oriented_normal = _tuple_normalized(_tuple_or_none(info.get("oriented_normal"))) source_normal_tuple = _tuple_normalized( _dir_tuple(BRepAdaptor_Surface(self.faces[face_id]).Plane().Axis().Direction()) ) if reference_area > area * 1.2 and oriented_normal is not None and source_normal_tuple is not None: outward_offset = float(signed_extrusion or 0.0) * _tuple_dot(source_normal_tuple, oriented_normal) if outward_offset > 0: feature_type = "矩形口袋候选" feature_semantics = "subtractive-pocket" else: feature_type = "矩形凸台候选" feature_semantics = "additive-boss" result: dict[str, object] = { "prismatic_profile_status": "candidate", "prismatic_profile_kind": "rectangular-planar-profile", "prismatic_profile_confidence": profile_confidence, "confidence": profile_confidence, "prismatic_length": length, "prismatic_width": width, "prismatic_length_direction": direction_groups[0]["direction"], "prismatic_width_direction": direction_groups[1]["direction"], "prismatic_profile_area_ratio": area_ratio, "prismatic_side_face_ids": tuple(adjacent_side_ids), "prismatic_connected_side_face_ids": tuple(connected_side_ids), "prismatic_reference_face_ids": tuple(reference_face_ids), "prismatic_feature_semantics": feature_semantics, "prismatic_profile_note": "四条直线边形成两组等长平行对边,面积与长乘宽一致。", "feature_type": feature_type, "local_face_width": length, "local_face_height": width, "local_face_width_direction": direction_groups[0]["direction"], "local_face_height_direction": direction_groups[1]["direction"], } if extrusion_candidate: extrusion = _float_or_none(shell_info.get("shell_thickness_estimate")) extrusion_confidence = "high" if len(connected_side_ids) == 4 else "medium" result.update( { "prismatic_extrusion_status": "candidate", "prismatic_extrusion_estimate": extrusion if extrusion is not None else "", "prismatic_reference_face_id": opposite_face_id, "prismatic_extrusion_confidence": extrusion_confidence, "confidence": extrusion_confidence, "prismatic_reference_source": "side-wall-topology" if topology_reference else "overlapping-plane", "prismatic_highlight_face_ids": tuple(sorted({face_id, *reference_face_ids, *connected_side_ids})), "prismatic_extrusion_note": "相对平面通过至少两个侧壁与当前矩形面相连。", } ) 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"]) local_width = _float_or_none(info.get("local_face_width")) local_height = _float_or_none(info.get("local_face_height")) local_spans = [value for value in (local_width, local_height) if value is not None and value > tolerance] if local_spans and thickness > min(local_spans) * 1.5: return { "shell_region_status": "not-detected", "shell_opposite_face_id": best["shell_opposite_face_id"], "shell_thickness_estimate": thickness, "shell_overlap_ratio_estimate": overlap_ratio, "shell_region_note": "相对平面距离明显大于当前面的局部短边,不按薄壁厚度处理。", } 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"] angular_spans: list[float] = [] for side_id in side_face_ids: try: side_surface = BRepAdaptor_Surface(self.faces[side_id]) angular_spans.append(abs(side_surface.LastUParameter() - side_surface.FirstUParameter())) except Exception: continue combined_angular_span = min(sum(angular_spans), math.tau) if angular_spans else float(info.get("angular_span", 0.0)) domain_info["angular_span"] = combined_angular_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"] guess = str(info.get("feature_guess", "cylindrical face")) has_two_axial_caps = bool(end_faces["start_end_face_ids"] and end_faces["end_end_face_ids"]) material_toward = str(info.get("material_toward_axis", "") or "") material_away = str(info.get("material_away_axis", "") or "") if ( guess == "round/fillet candidate" and has_two_axial_caps and material_toward == "inside" and "outside" in material_away ): info = dict(info) info["feature_guess"] = "boss/outer-round candidate" info["confidence"] = "medium" info["note"] = "partial cylinder has material inside its axis and explicit planar caps at both ends" domain_info["feature_guess"] = info["feature_guess"] 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 = combined_angular_span 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_angular_span": combined_angular_span, "angular_span": combined_angular_span, "is_full_cylinder": combined_angular_span >= math.tau * 0.92, "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_first_level_topology(self, face_id: int) -> dict[str, object]: """Return the explicit first-level B-Rep neighborhood for a Face. The current project defines first-level Face topology as the selected Face region plus Faces that share a boundary Edge with that region. Vertex-only contacts are reported through boundary vertex counts, but they are not used as propagation edges at this stage. """ if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") cached = self._face_first_level_topology_cache.get(face_id) if cached is not None: return dict(cached) source_solid_id = self.face_solid_ids[face_id] if face_id < len(self.face_solid_ids) else -1 source_part_id = self.face_part_ids[face_id] if face_id < len(self.face_part_ids) else -1 try: same_domain_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id] except Exception: same_domain_face_ids = [face_id] same_domain_face_ids = tuple(sorted({int(item) for item in same_domain_face_ids if 0 <= int(item) < len(self.faces)})) if not same_domain_face_ids: same_domain_face_ids = (face_id,) same_domain_set = set(same_domain_face_ids) selected_boundary_edge_ids = tuple(self._face_boundary_edge_ids(face_id)) region_boundary_edge_ids = tuple(self._region_boundary_edge_ids(same_domain_face_ids)) shared_edges_by_face: dict[int, list[int]] = {} for edge_id in region_boundary_edge_ids: for candidate_id in self._edge_adjacent_face_ids(edge_id): if candidate_id in same_domain_set: continue if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id: continue shared_edges_by_face.setdefault(candidate_id, []).append(edge_id) adjacent_face_ids = tuple(sorted(shared_edges_by_face)) first_level_face_ids = tuple(sorted({*same_domain_face_ids, *adjacent_face_ids})) diagonal = _shape_diagonal(self.shape) tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) vertex_points_by_key: dict[tuple[int, int, int], tuple[float, float, float]] = {} for item in same_domain_face_ids: explorer = TopExp_Explorer(self.faces[item], TopAbs_VERTEX) while explorer.More(): vertex = topods.Vertex(explorer.Current()) point = _point_tuple(BRep_Tool.Pnt(vertex)) vertex_points_by_key[self._local_point_key(point, tolerance)] = point explorer.Next() boundary_vertex_points = tuple(vertex_points_by_key[key] for key in sorted(vertex_points_by_key)) adjacent_surface_types: list[tuple[int, str]] = [] for adjacent_id in adjacent_face_ids: adjacent_surface_types.append((adjacent_id, self.face_surface_kind(adjacent_id))) shared_edge_refs = tuple( { "face_id": adjacent_id, "edge_ids": tuple(sorted(set(edge_ids))), "edge_count": len(set(edge_ids)), "surface": self.face_surface_kind(adjacent_id), } for adjacent_id, edge_ids in sorted(shared_edges_by_face.items()) ) topology = { "topology_relation_model": "STEP/B-Rep shared-edge first-level", "topology_relation_depth": 1, "topology_relation_scope": "selected same-domain region + direct shared-edge adjacent Faces", "topology_relation_boundary": "shared-edge", "topology_ignored_relation_depths": ("second-level", "third-level", "deeper"), "topology_ignored_relation_note": ( "当前阶段只传播一级关系;相邻 Face 再连接出去的二级、三级拓扑只作为后续目标,不自动递归编辑。" ), "source_face_id": face_id, "source_part_id": source_part_id, "source_solid_id": source_solid_id, "same_domain_face_ids": same_domain_face_ids, "same_domain_face_count": len(same_domain_face_ids), "same_domain_region_kind": "same-domain-region" if len(same_domain_face_ids) > 1 else "single-face", "selected_boundary_edge_ids": selected_boundary_edge_ids, "selected_boundary_edge_count": len(selected_boundary_edge_ids), "first_level_boundary_edge_ids": region_boundary_edge_ids, "first_level_boundary_edge_count": len(region_boundary_edge_ids), "first_level_boundary_vertex_points": boundary_vertex_points, "first_level_boundary_vertex_count": len(boundary_vertex_points), "first_level_adjacent_face_ids": adjacent_face_ids, "first_level_adjacent_face_count": len(adjacent_face_ids), "first_level_adjacent_surface_types": tuple(adjacent_surface_types), "first_level_shared_edges_by_face": shared_edge_refs, "first_level_face_ids": first_level_face_ids, "first_level_face_count": len(first_level_face_ids), "first_level_topology_note": ( f"已识别当前 Face 区域 {len(same_domain_face_ids)} 个 Face、" f"边界 Edge {len(region_boundary_edge_ids)} 条、" f"边界 Vertex {len(boundary_vertex_points)} 个、" f"共享边一级相邻 Face {len(adjacent_face_ids)} 个;" "当前编辑计划只处理这些一级关系。" ), } for item in same_domain_face_ids: self._face_first_level_topology_cache[item] = dict(topology) return dict(topology) def cylindrical_feature_first_level_topology(self, face_id: int) -> dict[str, object]: """Return the first-level B-Rep neighborhood for a cylindrical feature. For holes and slots, first-level topology means the selected cylindrical side region and Faces that directly share one of its boundary Edges. Paired slot ends reached through another planar wall are deliberately not promoted to first-level topology at this stage. """ if face_id < 0 or face_id >= len(self.faces): raise ValueError(f"Unknown face id {face_id}") cached = self._cylindrical_first_level_topology_cache.get(face_id) if cached is not None: return dict(cached) info = self.face_info(face_id) if info.get("surface") != "cylinder": raise ValueError(f"Face {face_id} is not a cylindrical feature face") source_solid_id = self.face_solid_ids[face_id] if face_id < len(self.face_solid_ids) else -1 source_part_id = self.face_part_ids[face_id] if face_id < len(self.face_part_ids) else -1 feature = self.feature_info(face_id) def valid_face_ids(values: object) -> tuple[int, ...]: result: list[int] = [] for item in _int_values(values): if item < 0 or item >= len(self.faces): continue if source_part_id >= 0 and self.face_part_ids[item] != source_part_id: continue if source_solid_id >= 0 and self.face_solid_ids[item] != source_solid_id: continue result.append(item) return tuple(sorted(set(result))) side_face_ids = valid_face_ids(feature.get("feature_side_face_ids")) if not side_face_ids: side_face_ids = valid_face_ids(feature.get("feature_face_ids")) if not side_face_ids: try: side_face_ids = valid_face_ids(self.connected_same_domain_face_ids(face_id)) except Exception: side_face_ids = () if not side_face_ids: side_face_ids = (face_id,) side_face_set = set(side_face_ids) selected_boundary_edge_ids = tuple(self._face_boundary_edge_ids(face_id)) region_boundary_edge_ids = tuple(self._region_boundary_edge_ids(side_face_ids)) shared_edges_by_face: dict[int, list[int]] = {} for edge_id in region_boundary_edge_ids: for candidate_id in self._edge_adjacent_face_ids(edge_id): if candidate_id in side_face_set: continue if source_part_id >= 0 and self.face_part_ids[candidate_id] != source_part_id: continue if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id: continue shared_edges_by_face.setdefault(candidate_id, []).append(edge_id) adjacent_face_ids = tuple(sorted(shared_edges_by_face)) end_face_ids = valid_face_ids(feature.get("feature_end_face_ids")) bottom_face_ids = valid_face_ids(feature.get("feature_bottom_face_ids")) opening_face_ids = valid_face_ids(feature.get("feature_opening_face_ids")) slot_boundary_face_ids = valid_face_ids(feature.get("feature_slot_boundary_face_ids")) first_level_face_ids = tuple(sorted({*side_face_ids, *adjacent_face_ids})) diagonal = _shape_diagonal(self.shape) tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3) vertex_points_by_key: dict[tuple[int, int, int], tuple[float, float, float]] = {} for edge_id in region_boundary_edge_ids: if edge_id < 0 or edge_id >= len(self.edges): continue explorer = TopExp_Explorer(self.edges[edge_id], TopAbs_VERTEX) while explorer.More(): vertex = topods.Vertex(explorer.Current()) point = _point_tuple(BRep_Tool.Pnt(vertex)) vertex_points_by_key[self._local_point_key(point, tolerance)] = point explorer.Next() boundary_vertex_points = tuple(vertex_points_by_key[key] for key in sorted(vertex_points_by_key)) adjacent_surface_types = tuple((item, self.face_surface_kind(item)) for item in adjacent_face_ids) shared_edge_refs = tuple( { "face_id": adjacent_id, "edge_ids": tuple(sorted(set(edge_ids))), "edge_count": len(set(edge_ids)), "surface": self.face_surface_kind(adjacent_id), } for adjacent_id, edge_ids in sorted(shared_edges_by_face.items()) ) angular_span = feature.get("slot_angular_span", feature.get("angular_span", info.get("angular_span"))) topology = { "topology_relation_model": "STEP/B-Rep cylindrical-feature shared-edge first-level", "topology_relation_depth": 1, "topology_relation_scope": "selected cylindrical same-domain side region + direct shared-edge adjacent Faces", "topology_relation_boundary": "shared-edge", "topology_ignored_relation_depths": ("second-level", "third-level", "deeper"), "topology_ignored_relation_note": ( "Only direct shared-edge neighbors of the cylindrical side region are treated as first-level topology. " "Faces reached through those neighbors are recorded later as second-level or deeper relationships." ), "source_face_id": face_id, "source_part_id": source_part_id, "source_solid_id": source_solid_id, "feature_type": feature.get("feature_type"), "feature_guess": feature.get("feature_guess", info.get("feature_guess")), "slot_kind": feature.get("slot_kind", ""), "cylinder_end_type": feature.get("cylinder_end_type", info.get("cylinder_end_type")), "is_full_cylinder": bool(feature.get("is_full_cylinder", info.get("is_full_cylinder", False))), "angular_span": angular_span, "cylindrical_feature_side_face_ids": side_face_ids, "cylindrical_feature_side_face_count": len(side_face_ids), "cylindrical_feature_selected_boundary_edge_ids": selected_boundary_edge_ids, "cylindrical_feature_selected_boundary_edge_count": len(selected_boundary_edge_ids), "cylindrical_feature_boundary_edge_ids": region_boundary_edge_ids, "cylindrical_feature_boundary_edge_count": len(region_boundary_edge_ids), "cylindrical_feature_boundary_vertex_points": boundary_vertex_points, "cylindrical_feature_boundary_vertex_count": len(boundary_vertex_points), "cylindrical_feature_adjacent_face_ids": adjacent_face_ids, "cylindrical_feature_adjacent_face_count": len(adjacent_face_ids), "cylindrical_feature_adjacent_surface_types": adjacent_surface_types, "cylindrical_feature_shared_edges_by_face": shared_edge_refs, "cylindrical_feature_first_level_face_ids": first_level_face_ids, "cylindrical_feature_first_level_face_count": len(first_level_face_ids), "cylindrical_feature_end_face_ids": end_face_ids, "cylindrical_feature_end_face_count": len(end_face_ids), "cylindrical_feature_bottom_face_ids": bottom_face_ids, "cylindrical_feature_bottom_face_count": len(bottom_face_ids), "cylindrical_feature_opening_face_ids": opening_face_ids, "cylindrical_feature_opening_face_count": len(opening_face_ids), "cylindrical_feature_slot_boundary_face_ids": slot_boundary_face_ids, "cylindrical_feature_slot_boundary_face_count": len(slot_boundary_face_ids), "first_level_topology_note": ( f"Cylindrical side Faces={len(side_face_ids)}, boundary Edges={len(region_boundary_edge_ids)}, " f"boundary Vertices={len(boundary_vertex_points)}, direct adjacent Faces={len(adjacent_face_ids)}. " "Second-level and deeper propagation is not automatic in this stage." ), } for item in side_face_ids: self._cylindrical_first_level_topology_cache[item] = dict(topology) return dict(topology) def _face_boundary_wire_info(self, face: TopoDS_Shape) -> dict[str, object]: try: boundary_wires = len(_explore(face, TopAbs_WIRE)) except Exception: boundary_wires = 0 inner_boundary_wires = max(boundary_wires - 1, 0) return { "boundary_wires": boundary_wires, "inner_boundary_wires": inner_boundary_wires, "has_inner_boundaries": inner_boundary_wires > 0, } def _local_face_deform_readiness(self, face_id: int) -> dict[str, object]: if face_id < 0 or face_id >= len(self.faces): return { "local_face_deform_ready": False, "local_face_deform_blocker": "Face ID 不存在,不能做局部 Face 变形。", } solid_id = self.face_solid_ids[face_id] if face_id < len(self.face_solid_ids) else -1 cache_key = solid_id if solid_id >= 0 else -(face_id + 1) cached = self._local_face_deform_readiness_cache.get(cache_key) if cached is not None: return dict(cached) def remember(info: dict[str, object]) -> dict[str, object]: self._local_face_deform_readiness_cache[cache_key] = dict(info) return dict(info) if solid_id < 0 or solid_id >= len(self.solids): return remember( { "local_face_deform_ready": False, "local_face_deform_face_count": 1, "local_face_deform_blocker": "找不到当前 Face 所属 Solid,不能做“只改当前面”的局部重建。", } ) solid_face_ids = [index for index, item in enumerate(self.face_solid_ids) if item == solid_id] if not solid_face_ids: solid_face_ids = [face_id] face_count = len(solid_face_ids) if face_count > 128: return remember( { "local_face_deform_ready": False, "local_face_deform_face_count": face_count, "local_face_deform_blocker": "所属 Solid 的 Face 数量超过 128,当前版本不开放“只改当前面”的局部重建。", } ) source_shape = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else self.faces[face_id] tolerance = max(_shape_diagonal(source_shape) * 1e-7, 1e-6) for item in solid_face_ids: face = self.faces[item] try: surface = BRepAdaptor_Surface(face) except Exception: return remember( { "local_face_deform_ready": False, "local_face_deform_face_count": face_count, "local_face_deform_blocker": "所属 Solid 里有 Face 不能稳定读取曲面类型,不能做局部 Face 变形。", } ) if surface.GetType() != GeomAbs_Plane: return remember( { "local_face_deform_ready": False, "local_face_deform_face_count": face_count, "local_face_deform_blocker": "所属 Solid 含有曲面,当前版本只对简单全平面 Solid 开放“只改当前面”。", } ) wire_info = self._face_boundary_wire_info(face) if bool(wire_info.get("has_inner_boundaries")): return remember( { "local_face_deform_ready": False, "local_face_deform_face_count": face_count, "local_face_deform_blocker": "所属 Solid 里有带内孔/内边界的 Face,请优先使用孔、槽或推拉等专门修改方式。", } ) try: if len(self._local_deform_face_vertex_points(face, tolerance)) < 3: return remember( { "local_face_deform_ready": False, "local_face_deform_face_count": face_count, "local_face_deform_blocker": "所属 Solid 里有 Face 顶点环不能稳定读取,不能做局部 Face 变形。", } ) except Exception: return remember( { "local_face_deform_ready": False, "local_face_deform_face_count": face_count, "local_face_deform_blocker": "所属 Solid 里有 Face 顶点环读取失败,不能做局部 Face 变形。", } ) return remember( { "local_face_deform_ready": True, "local_face_deform_face_count": face_count, "local_face_deform_blocker": "", } ) 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 assign_logical_face_region_exclusive(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 logical_id = int(logical_id) replacement_id = max([logical_id, len(self.faces), *[int(item) for item in self.face_logical_ids]], default=logical_id) + 1 for face_id, current_logical_id in enumerate(list(self.face_logical_ids)): if int(current_logical_id) != logical_id or face_id in valid_face_ids: continue self.face_logical_ids[face_id] = replacement_id replacement_id += 1 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) self.assign_logical_face_region(logical_id, valid_face_ids) 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: 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: raise ValueError("No planar faces were found for push/pull.") if len(valid_face_ids) > 1: boundary_edge_ids = self._region_boundary_edge_ids(valid_face_ids) if boundary_edge_ids: try: wire = BRepBuilderAPI_MakeWire() for edge_id in boundary_edge_ids: wire.Add(topods.Edge(self.edges[edge_id])) if not hasattr(wire, "IsDone") or wire.IsDone(): face_builder = BRepBuilderAPI_MakeFace(wire.Wire()) if not hasattr(face_builder, "IsDone") or face_builder.IsDone(): profile = face_builder.Face() if not profile.IsNull(): _ensure_valid_shape(profile) return profile except Exception: pass profile_faces = [self.faces[face_id] for face_id in valid_face_ids] 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