2953 lines
144 KiB
Python
2953 lines
144 KiB
Python
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._face_first_level_fact_cache: dict[tuple[int, str], 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._face_first_level_fact_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._face_first_level_fact_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._face_first_level_fact_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"] = "快速选择阶段不判断材料内外方向;执行推拉时会重新计算。"
|
||
cap_direction = self._cylindrical_cap_push_pull_direction(face_id, surf)
|
||
if cap_direction is not None:
|
||
info.update(cap_direction)
|
||
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"] = "推拉平面"
|
||
if bool(info.get("has_inner_boundaries")):
|
||
info["local_face_deform_ready"] = False
|
||
info["local_face_deform_face_count"] = 1
|
||
info["local_face_deform_blocker"] = "当前 Face 有内孔/内边界;请优先使用推拉当前面、孔或槽的专门修改入口。"
|
||
else:
|
||
info["local_face_deform_ready"] = True
|
||
info["local_face_deform_face_count"] = 1
|
||
info["local_face_deform_blocker"] = ""
|
||
info["local_face_deform_status"] = "deferred"
|
||
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()
|
||
|
||
info.update(self._recognition_summary_fields(info))
|
||
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()
|
||
info.update(self._recognition_summary_fields(info))
|
||
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 _recognition_summary_fields(self, info: dict[str, object]) -> dict[str, object]:
|
||
surface = str(info.get("surface") or "")
|
||
candidate = (
|
||
str(info.get("feature_type") or "").strip()
|
||
or str(info.get("feature_guess") or "").strip()
|
||
or (f"{surface} Face" if surface else "Face")
|
||
)
|
||
confidence = (
|
||
str(info.get("confidence") or "").strip()
|
||
or str(info.get("push_pull_confidence") or "").strip()
|
||
or str(info.get("shell_confidence") or "").strip()
|
||
or "unchecked"
|
||
)
|
||
if confidence == "unchecked":
|
||
feature_guess = str(info.get("feature_guess") or "")
|
||
has_axis = info.get("axis") not in {None, ""} or info.get("axis_point") not in {None, ""}
|
||
if surface == "plane" and info.get("boundary_edges") not in {None, ""}:
|
||
confidence = "high"
|
||
elif surface == "cylinder" and _float_or_none(info.get("radius")) is not None and has_axis:
|
||
confidence = "high" if feature_guess else "medium"
|
||
elif surface == "cone" and _float_or_none(info.get("reference_radius")) is not None and _float_or_none(info.get("semi_angle")) is not None:
|
||
confidence = "medium"
|
||
elif surface == "sphere" and _float_or_none(info.get("radius")) is not None:
|
||
confidence = "high"
|
||
elif surface == "torus" and _float_or_none(info.get("major_radius")) is not None and _float_or_none(info.get("minor_radius")) is not None:
|
||
confidence = "high"
|
||
|
||
risk_rank = {"low": 0, "medium": 1, "high": 2, "blocked": 3}
|
||
capability_specs = (
|
||
("resize_status", "resize_risk", "resize_blockers", "孔/槽/圆柱直径"),
|
||
("push_pull_status", "push_pull_risk", "push_pull_blockers", "平面推拉"),
|
||
("shell_status", "shell_risk", "shell_blockers", "薄壁厚度"),
|
||
("cylinder_resize_status", "cylinder_resize_risk", "cylinder_resize_blockers", "圆柱直径/半径"),
|
||
("boss_resize_status", "boss_resize_risk", "boss_resize_blockers", "圆柱凸台直径/高度/轴心"),
|
||
("depth_status", "depth_risk", "depth_blockers", "盲孔/盲槽深度"),
|
||
("suppress_status", "suppress_risk", "suppress_blockers", "封堵孔/槽"),
|
||
("fillet_status", "fillet_risk", "fillet_blockers", "已有圆角半径"),
|
||
("chamfer_status", "chamfer_risk", "chamfer_blockers", "倒角"),
|
||
)
|
||
available_capabilities = {
|
||
status_key
|
||
for status_key, _risk_key, _blocker_key, _label in capability_specs
|
||
if str(info.get(status_key) or "").strip() in {"ready", "caution", "candidate"}
|
||
}
|
||
feature_type_text = str(info.get("feature_type") or "")
|
||
feature_actions_text = str(info.get("feature_edit_actions") or "")
|
||
has_planar_push_pull_candidate = (
|
||
surface == "plane"
|
||
and (
|
||
"推拉" in feature_type_text
|
||
or "推拉" in feature_actions_text
|
||
or str(info.get("push_pull_status") or "").strip() == "candidate"
|
||
)
|
||
)
|
||
has_local_face_deform = bool(info.get("local_face_deform_ready"))
|
||
has_slot_candidate = str(info.get("slot_status") or "").strip() == "candidate"
|
||
has_shell_candidate = str(info.get("shell_region_status") or "").strip() == "candidate"
|
||
has_analytic_surface_candidate = surface in {"cone", "sphere", "torus"} and bool(feature_type_text)
|
||
has_available_capability = bool(
|
||
available_capabilities
|
||
or has_planar_push_pull_candidate
|
||
or has_local_face_deform
|
||
or has_slot_candidate
|
||
or has_shell_candidate
|
||
or has_analytic_surface_candidate
|
||
)
|
||
risk = "low"
|
||
for key in (
|
||
"risk",
|
||
"first_level_topology_risk",
|
||
):
|
||
value = str(info.get(key) or "").strip()
|
||
if risk_rank.get(value, -1) > risk_rank.get(risk, -1):
|
||
risk = value
|
||
for status_key, risk_key, _blocker_key, _label in capability_specs:
|
||
value = str(info.get(risk_key) or "").strip()
|
||
if value == "blocked" and has_available_capability:
|
||
continue
|
||
if risk_rank.get(value, -1) > risk_rank.get(risk, -1):
|
||
risk = value
|
||
if confidence in {"low", "unchecked", "none"} and risk == "low":
|
||
risk = "medium"
|
||
|
||
evidence: list[str] = []
|
||
evidence_keys: list[str] = []
|
||
|
||
def add(key: str, text: str) -> None:
|
||
if text and key not in evidence_keys:
|
||
evidence_keys.append(key)
|
||
evidence.append(text)
|
||
|
||
if surface:
|
||
add("surface", f"曲面={surface}")
|
||
if surface == "cylinder" and info.get("radius") not in {None, ""}:
|
||
add("cylinder_geometry", f"圆柱半径={info.get('radius')}")
|
||
if surface == "cone":
|
||
if info.get("reference_radius") not in {None, ""}:
|
||
add("cone_geometry", f"圆锥参考半径={info.get('reference_radius')}")
|
||
if info.get("semi_angle") not in {None, ""}:
|
||
add("cone_angle", f"圆锥半角={info.get('semi_angle')}")
|
||
if surface == "sphere" and info.get("radius") not in {None, ""}:
|
||
add("sphere_geometry", f"球半径={info.get('radius')}")
|
||
if surface == "torus":
|
||
if info.get("major_radius") not in {None, ""}:
|
||
add("torus_major_radius", f"环面主半径={info.get('major_radius')}")
|
||
if info.get("minor_radius") not in {None, ""}:
|
||
add("torus_minor_radius", f"环面小半径={info.get('minor_radius')}")
|
||
if info.get("boundary_edges") not in {None, ""}:
|
||
add("boundary_edges", f"边界Edge={info.get('boundary_edges')}")
|
||
if info.get("same_domain_face_count") not in {None, ""}:
|
||
add("same_domain", f"同域Face={info.get('same_domain_face_count')}")
|
||
if info.get("first_level_adjacent_face_count") not in {None, ""}:
|
||
add("first_level_topology", f"一级相邻Face={info.get('first_level_adjacent_face_count')}")
|
||
elif info.get("feature_adjacent_face_ids") not in {None, ""}:
|
||
try:
|
||
adjacent_count = len(tuple(info.get("feature_adjacent_face_ids") or ()))
|
||
except TypeError:
|
||
adjacent_count = 0
|
||
add("first_level_topology", f"一级相邻Face={adjacent_count}")
|
||
if info.get("first_level_fact_summary") not in {None, ""}:
|
||
add("first_level_fact_graph", f"一级事实={info.get('first_level_fact_summary')}")
|
||
if info.get("material_vote_summary") not in {None, ""}:
|
||
add("material_votes", f"材料采样={info.get('material_vote_summary')}")
|
||
if info.get("feature_end_face_ids") not in {None, ""}:
|
||
try:
|
||
end_count = len(tuple(info.get("feature_end_face_ids") or ()))
|
||
except TypeError:
|
||
end_count = 0
|
||
add("end_faces", f"端面Face={end_count}")
|
||
if info.get("feature_bottom_face_ids") not in {None, ""}:
|
||
try:
|
||
bottom_count = len(tuple(info.get("feature_bottom_face_ids") or ()))
|
||
except TypeError:
|
||
bottom_count = 0
|
||
add("bottom_faces", f"疑似底面Face={bottom_count}")
|
||
if info.get("slot_status") == "candidate":
|
||
add("slot_geometry", "部分圆柱槽/半孔几何")
|
||
if info.get("shell_region_status") == "candidate":
|
||
add("shell_opposite_face", "找到相对平面/薄壁候选")
|
||
|
||
ready_actions: list[str] = []
|
||
limited_actions: list[str] = []
|
||
blockers: list[str] = []
|
||
limitations: list[str] = []
|
||
|
||
def add_unique(items: list[str], text: str) -> None:
|
||
if text and text not in items:
|
||
items.append(text)
|
||
|
||
def add_action(items: list[str], text: str) -> None:
|
||
if text and text not in items:
|
||
items.append(text)
|
||
|
||
for status_key, _risk_key, blocker_key, label in capability_specs:
|
||
status = str(info.get(status_key) or "").strip()
|
||
blocker_text = str(info.get(blocker_key) or "").strip()
|
||
if status in {"ready", "caution", "candidate"}:
|
||
add_action(ready_actions, label)
|
||
elif status == "blocked" and blocker_text:
|
||
add_action(limited_actions, label)
|
||
|
||
if has_planar_push_pull_candidate:
|
||
add_action(ready_actions, "平面推拉")
|
||
if has_local_face_deform:
|
||
add_action(ready_actions, "当前面面积/面宽/面高/中心/偏移")
|
||
elif str(info.get("local_face_deform_blocker") or "").strip():
|
||
add_action(limited_actions, "当前面局部尺寸/中心/偏移")
|
||
if has_shell_candidate:
|
||
add_action(ready_actions, "薄壁厚度")
|
||
if has_slot_candidate:
|
||
add_action(ready_actions, "槽/半孔宽度/深度/弧长")
|
||
if surface == "cone" and feature_type_text:
|
||
add_action(ready_actions, "圆锥参考半径/直径/半角")
|
||
elif surface == "sphere" and feature_type_text:
|
||
add_action(ready_actions, "球面半径/直径")
|
||
elif surface == "torus" and feature_type_text:
|
||
add_action(ready_actions, "环面主/小半径或直径")
|
||
|
||
for key in ("local_face_deform_blocker", "first_level_topology_blockers"):
|
||
text = str(info.get(key) or "").strip()
|
||
if not text:
|
||
continue
|
||
if has_available_capability:
|
||
add_unique(limitations, text)
|
||
else:
|
||
add_unique(blockers, text)
|
||
for status_key, _risk_key, blocker_key, _label in capability_specs:
|
||
text = str(info.get(blocker_key) or "").strip()
|
||
if not text:
|
||
continue
|
||
status = str(info.get(status_key) or "").strip()
|
||
if status == "blocked" and has_available_capability:
|
||
add_unique(limitations, text)
|
||
else:
|
||
add_unique(blockers, text)
|
||
|
||
note = (
|
||
str(info.get("feature_mode") or "").strip()
|
||
or str(info.get("note") or "").strip()
|
||
or str(info.get("push_pull_note") or "").strip()
|
||
)
|
||
if note:
|
||
add("note", note)
|
||
|
||
confidence_points = {"high": 72, "medium": 56, "low": 34, "unchecked": 22, "none": 0}
|
||
risk_penalty = {"low": 0, "medium": 14, "high": 30, "blocked": 72}
|
||
score = confidence_points.get(confidence, 22)
|
||
score += min(len(evidence_keys) * 5, 24)
|
||
score -= risk_penalty.get(risk, 14)
|
||
if blockers:
|
||
score -= 35
|
||
elif limitations:
|
||
score -= min(len(limitations) * 4, 12)
|
||
score = max(0, min(100, int(round(score))))
|
||
|
||
if risk == "blocked" or blockers:
|
||
decision = "已阻止"
|
||
elif score >= 76 and risk == "low":
|
||
decision = "高可信候选"
|
||
elif score >= 56:
|
||
decision = "可尝试候选"
|
||
elif score >= 36:
|
||
decision = "需人工确认"
|
||
else:
|
||
decision = "不建议自动修改"
|
||
|
||
summary_parts = [candidate, f"置信度={confidence}", f"风险={risk}", f"评分={score}", f"结论={decision}"]
|
||
if evidence:
|
||
summary_evidence = list(evidence[:5])
|
||
if "first_level_fact_graph" in evidence_keys:
|
||
fact_text = evidence[evidence_keys.index("first_level_fact_graph")]
|
||
if fact_text not in summary_evidence:
|
||
if len(summary_evidence) >= 5:
|
||
summary_evidence[-1] = fact_text
|
||
else:
|
||
summary_evidence.append(fact_text)
|
||
summary_parts.append("证据:" + ";".join(summary_evidence))
|
||
if ready_actions:
|
||
summary_parts.append("可改:" + ";".join(ready_actions[:4]))
|
||
if blockers:
|
||
summary_parts.append("限制:" + ";".join(blockers[:2]))
|
||
elif limitations:
|
||
summary_parts.append("受限能力:" + ";".join(limitations[:2]))
|
||
if limited_actions:
|
||
summary_parts.append("受限修改:" + ";".join(limited_actions[:4]))
|
||
|
||
return {
|
||
"recognition_candidate": candidate,
|
||
"recognition_confidence": confidence,
|
||
"recognition_risk": risk,
|
||
"recognition_score": score,
|
||
"recognition_decision": decision,
|
||
"recognition_evidence": ";".join(evidence),
|
||
"recognition_evidence_keys": tuple(evidence_keys),
|
||
"recognition_ready_actions": ";".join(ready_actions),
|
||
"recognition_limited_actions": ";".join(limited_actions),
|
||
"recognition_blockers": ";".join(blockers),
|
||
"recognition_limitations": ";".join(limitations),
|
||
"recognition_summary": ";".join(summary_parts),
|
||
}
|
||
|
||
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解释为局部几何特征候选;"
|
||
"复杂曲面局部面积修改需要更明确的边界/约束重建,当前不会把面积伪装成直接可改参数。"
|
||
),
|
||
}
|
||
)
|
||
result.update(self._recognition_summary_fields(result))
|
||
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_source_face_id": face_id,
|
||
"feature_face_ids": (face_id,),
|
||
"feature_highlight_face_ids": (face_id,),
|
||
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
|
||
"feature_edit_actions": "修改环面主半径/小半径",
|
||
"feature_mode": (
|
||
"这是从 STEP/B-Rep 环面直接识别出的几何候选;当前修改会围绕环面中心缩放所属零件/Solid,"
|
||
"主半径和小半径会等比例变化,不是 CAD 历史里的管径或圆角参数。"
|
||
),
|
||
"feature_torus_major_radius": major_radius,
|
||
"feature_torus_minor_radius": minor_radius,
|
||
}
|
||
)
|
||
return result
|
||
|
||
def _spherical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
|
||
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
|
||
radius = float(info.get("radius", 0.0) or 0.0)
|
||
result = dict(info)
|
||
result.update(
|
||
{
|
||
"kind": "feature",
|
||
"feature_type": "球面候选",
|
||
"feature_source_face_id": face_id,
|
||
"feature_face_ids": (face_id,),
|
||
"feature_highlight_face_ids": (face_id,),
|
||
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
|
||
"feature_edit_actions": "修改球面半径/直径",
|
||
"feature_mode": (
|
||
"这是从 STEP/B-Rep 球面直接识别出的几何候选;修改会围绕球心缩放所属零件/Solid,"
|
||
"不是 CAD 历史里的球面或圆角参数。"
|
||
),
|
||
"feature_sphere_radius": radius,
|
||
"feature_sphere_diameter": radius * 2.0 if radius > 0 else "",
|
||
}
|
||
)
|
||
return result
|
||
|
||
def _conical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
|
||
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
|
||
reference_radius = float(info.get("reference_radius", 0.0) or 0.0)
|
||
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_first_level_facts(self, face_id: int, scope: str = "auto") -> dict[str, object]:
|
||
"""Return a unified first-level fact graph for recognition and UI.
|
||
|
||
This is intentionally a fact layer, not a feature-history guess. It
|
||
normalizes the selected region, boundary Edges/Vertices, direct
|
||
shared-edge neighbors, and ignored deeper relation depths so Face,
|
||
cylinder, hole, slot, and later feature recognizers can share the same
|
||
evidence contract.
|
||
"""
|
||
if face_id < 0 or face_id >= len(self.faces):
|
||
raise ValueError(f"Unknown face id {face_id}")
|
||
requested_scope = str(scope or "auto")
|
||
surface = self.face_surface_kind(face_id)
|
||
resolved_scope = requested_scope
|
||
if requested_scope == "auto":
|
||
resolved_scope = "cylindrical-feature" if surface == "cylinder" else "face"
|
||
if resolved_scope not in {"face", "cylindrical-feature"}:
|
||
raise ValueError(f"Unknown first-level fact scope: {scope}")
|
||
cache_key = (int(face_id), resolved_scope)
|
||
cached = self._face_first_level_fact_cache.get(cache_key)
|
||
if cached is not None:
|
||
return dict(cached)
|
||
|
||
if resolved_scope == "cylindrical-feature":
|
||
topology = self.cylindrical_feature_first_level_topology(face_id)
|
||
subject_face_ids = tuple(_int_values(topology.get("cylindrical_feature_side_face_ids"))) or (face_id,)
|
||
boundary_edge_ids = tuple(_int_values(topology.get("cylindrical_feature_boundary_edge_ids")))
|
||
boundary_vertex_points = tuple(topology.get("cylindrical_feature_boundary_vertex_points") or ())
|
||
adjacent_face_ids = tuple(_int_values(topology.get("cylindrical_feature_adjacent_face_ids")))
|
||
included_face_ids = tuple(_int_values(topology.get("cylindrical_feature_first_level_face_ids"))) or tuple(
|
||
sorted({*subject_face_ids, *adjacent_face_ids})
|
||
)
|
||
role_groups = (
|
||
{
|
||
"role": "cylindrical-side",
|
||
"face_ids": subject_face_ids,
|
||
"count": len(subject_face_ids),
|
||
},
|
||
{
|
||
"role": "direct-adjacent",
|
||
"face_ids": adjacent_face_ids,
|
||
"count": len(adjacent_face_ids),
|
||
},
|
||
{
|
||
"role": "end/opening",
|
||
"face_ids": tuple(_int_values(topology.get("cylindrical_feature_end_face_ids"))),
|
||
"count": int(topology.get("cylindrical_feature_end_face_count", 0) or 0),
|
||
},
|
||
{
|
||
"role": "blind-bottom",
|
||
"face_ids": tuple(_int_values(topology.get("cylindrical_feature_bottom_face_ids"))),
|
||
"count": int(topology.get("cylindrical_feature_bottom_face_count", 0) or 0),
|
||
},
|
||
{
|
||
"role": "slot-boundary",
|
||
"face_ids": tuple(_int_values(topology.get("cylindrical_feature_slot_boundary_face_ids"))),
|
||
"count": int(topology.get("cylindrical_feature_slot_boundary_face_count", 0) or 0),
|
||
},
|
||
)
|
||
subject_role = "cylindrical side region"
|
||
source_model = "cylindrical-feature"
|
||
else:
|
||
topology = self.face_first_level_topology(face_id)
|
||
subject_face_ids = tuple(_int_values(topology.get("same_domain_face_ids"))) or (face_id,)
|
||
boundary_edge_ids = tuple(_int_values(topology.get("first_level_boundary_edge_ids")))
|
||
boundary_vertex_points = tuple(topology.get("first_level_boundary_vertex_points") or ())
|
||
adjacent_face_ids = tuple(_int_values(topology.get("first_level_adjacent_face_ids")))
|
||
included_face_ids = tuple(_int_values(topology.get("first_level_face_ids"))) or tuple(
|
||
sorted({*subject_face_ids, *adjacent_face_ids})
|
||
)
|
||
role_groups = (
|
||
{
|
||
"role": "selected-same-domain-region",
|
||
"face_ids": subject_face_ids,
|
||
"count": len(subject_face_ids),
|
||
},
|
||
{
|
||
"role": "direct-adjacent",
|
||
"face_ids": adjacent_face_ids,
|
||
"count": len(adjacent_face_ids),
|
||
},
|
||
)
|
||
subject_role = "selected same-domain Face region"
|
||
source_model = "face"
|
||
|
||
adjacent_surface_types = tuple(topology.get("first_level_adjacent_surface_types") or ()) or tuple(
|
||
topology.get("cylindrical_feature_adjacent_surface_types") or ()
|
||
)
|
||
shared_edges = tuple(topology.get("first_level_shared_edges_by_face") or ()) or tuple(
|
||
topology.get("cylindrical_feature_shared_edges_by_face") or ()
|
||
)
|
||
ignored_depths = tuple(topology.get("topology_ignored_relation_depths") or ("second-level", "third-level"))
|
||
summary = (
|
||
f"{subject_role}: Face {len(subject_face_ids)}, boundary Edge {len(boundary_edge_ids)}, "
|
||
f"boundary Vertex {len(boundary_vertex_points)}, direct adjacent Face {len(adjacent_face_ids)}; "
|
||
"deeper relations are recorded as future propagation targets, not edited automatically."
|
||
)
|
||
facts = {
|
||
"first_level_fact_model": "STEP/B-Rep first-level fact graph",
|
||
"first_level_fact_source_model": source_model,
|
||
"first_level_fact_status": "ready",
|
||
"first_level_fact_relation_depth": 1,
|
||
"first_level_fact_relation_boundary": "shared-edge",
|
||
"first_level_fact_scope": resolved_scope,
|
||
"first_level_fact_subject_role": subject_role,
|
||
"first_level_fact_subject_face_ids": tuple(sorted(set(subject_face_ids))),
|
||
"first_level_fact_subject_face_count": len(set(subject_face_ids)),
|
||
"first_level_fact_boundary_edge_ids": tuple(sorted(set(boundary_edge_ids))),
|
||
"first_level_fact_boundary_edge_count": len(set(boundary_edge_ids)),
|
||
"first_level_fact_boundary_vertex_points": boundary_vertex_points,
|
||
"first_level_fact_boundary_vertex_count": len(boundary_vertex_points),
|
||
"first_level_fact_adjacent_face_ids": tuple(sorted(set(adjacent_face_ids))),
|
||
"first_level_fact_adjacent_face_count": len(set(adjacent_face_ids)),
|
||
"first_level_fact_adjacent_surface_types": adjacent_surface_types,
|
||
"first_level_fact_shared_edges_by_face": shared_edges,
|
||
"first_level_fact_included_face_ids": tuple(sorted(set(included_face_ids))),
|
||
"first_level_fact_included_face_count": len(set(included_face_ids)),
|
||
"first_level_fact_role_groups": role_groups,
|
||
"first_level_fact_ignored_relation_depths": ignored_depths,
|
||
"first_level_fact_ignored_relation_note": topology.get("topology_ignored_relation_note", ""),
|
||
"first_level_fact_summary": summary,
|
||
}
|
||
for item in facts["first_level_fact_subject_face_ids"]:
|
||
self._face_first_level_fact_cache[(int(item), resolved_scope)] = dict(facts)
|
||
self._face_first_level_fact_cache[cache_key] = dict(facts)
|
||
return dict(facts)
|
||
|
||
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)
|
||
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)
|
||
shared_edge_result = sorted(visited)
|
||
if len(self.faces) > 600 or len(shared_edge_result) > 1:
|
||
return shared_edge_result
|
||
|
||
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)
|
||
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)
|
||
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)
|
||
shared_edge_result = sorted(visited)
|
||
if len(self.faces) > 600 or len(shared_edge_result) > 1:
|
||
return shared_edge_result
|
||
|
||
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)
|
||
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
|