26216064e0
将原来的 main.py/step_model.py 拆分为 step_editor 包,补充测量、同域高亮、模型修复、历史导出、槽宽/凸台/边长等 MVP 编辑能力,并更新 README 和忽略规则。
1380 lines
63 KiB
Python
1380 lines
63 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_Transform
|
|
from OCC.Core.BRepCheck import BRepCheck_Analyzer
|
|
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
|
|
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
|
|
from OCC.Core.BRepGProp import brepgprop
|
|
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
|
|
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism
|
|
from OCC.Core.Bnd import Bnd_Box
|
|
from OCC.Core.GeomAbs import (
|
|
GeomAbs_BSplineCurve,
|
|
GeomAbs_BSplineSurface,
|
|
GeomAbs_BezierCurve,
|
|
GeomAbs_BezierSurface,
|
|
GeomAbs_Circle,
|
|
GeomAbs_Cone,
|
|
GeomAbs_Cylinder,
|
|
GeomAbs_Ellipse,
|
|
GeomAbs_Hyperbola,
|
|
GeomAbs_Line,
|
|
GeomAbs_OffsetSurface,
|
|
GeomAbs_OtherCurve,
|
|
GeomAbs_OtherSurface,
|
|
GeomAbs_Parabola,
|
|
GeomAbs_Plane,
|
|
GeomAbs_Sphere,
|
|
GeomAbs_SurfaceOfExtrusion,
|
|
GeomAbs_SurfaceOfRevolution,
|
|
GeomAbs_Torus,
|
|
)
|
|
from OCC.Core.GProp import GProp_GProps
|
|
from OCC.Core.IFSelect import IFSelect_RetDone
|
|
from OCC.Core.Interface import Interface_Static
|
|
from OCC.Core.STEPCAFControl import STEPCAFControl_Reader
|
|
from OCC.Core.STEPControl import STEPControl_AsIs, STEPControl_Reader, STEPControl_Writer
|
|
from OCC.Core.ShapeFix import ShapeFix_Shape
|
|
from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
|
|
from OCC.Core.TDF import TDF_Label, TDF_LabelSequence
|
|
from OCC.Core.TDocStd import TDocStd_Document
|
|
from OCC.Core.TopAbs import (
|
|
TopAbs_EDGE,
|
|
TopAbs_EXTERNAL,
|
|
TopAbs_FACE,
|
|
TopAbs_FORWARD,
|
|
TopAbs_IN,
|
|
TopAbs_INTERNAL,
|
|
TopAbs_OUT,
|
|
TopAbs_REVERSED,
|
|
TopAbs_SOLID,
|
|
)
|
|
from OCC.Core.TopExp import TopExp_Explorer, topexp
|
|
from OCC.Core.TopLoc import TopLoc_Location
|
|
from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods
|
|
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_IndexedMapOfShape
|
|
from OCC.Core.XCAFDoc import XCAFDoc_DocumentTool
|
|
from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
|
from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
|
|
|
|
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._face_info_cache: dict[int, dict[str, object]] = {}
|
|
self._edge_info_cache: dict[int, dict[str, object]] = {}
|
|
self._face_edge_ids_cache: dict[int, list[int]] = {}
|
|
self._edge_face_ids_cache: dict[int, list[int]] = {}
|
|
self._same_domain_face_ids_cache: dict[int, list[int]] = {}
|
|
self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None
|
|
self._same_domain_internal_edge_ids_cache: set[int] | None = None
|
|
self._same_domain_duplicate_edge_ids_cache: set[int] | None = None
|
|
self._face_polydata_cache: dict[tuple[object, ...], object] = {}
|
|
self._edge_polydata_cache: dict[tuple[object, ...], object] = {}
|
|
self._polydata_cache_limit = 96
|
|
self._mesh_deflection: float | None = None
|
|
self.refresh_topology()
|
|
|
|
@classmethod
|
|
def load(cls, filename: str | Path) -> "StepModel":
|
|
path = Path(filename)
|
|
if not path.exists():
|
|
raise FileNotFoundError(path)
|
|
|
|
product_names = _parse_product_names(path)
|
|
parts, whole_shape = _load_with_xcaf(path, product_names)
|
|
if not parts or whole_shape.IsNull():
|
|
whole_shape = _load_plain_step(path)
|
|
fallback_name = product_names[0] if product_names else path.stem
|
|
parts = [PartNode(1, fallback_name, "part", whole_shape, path=fallback_name)]
|
|
return cls(path, parts, whole_shape)
|
|
|
|
def display_parts(self) -> list[PartNode]:
|
|
leaf_parts = [p for p in self.parts if p.kind == "part" and not p.shape.IsNull()]
|
|
if leaf_parts:
|
|
return leaf_parts
|
|
return [p for p in self.parts if not p.shape.IsNull()]
|
|
|
|
def stats(self) -> TopologyStats:
|
|
topo = TopologyExplorer(self.shape, ignore_orientation=True)
|
|
return TopologyStats(
|
|
parts=len(self.display_parts()),
|
|
solids=len(list(topo.solids())),
|
|
faces=len(list(topo.faces())),
|
|
edges=len(list(topo.edges())),
|
|
vertices=len(list(topo.vertices())),
|
|
)
|
|
|
|
def part_topology_stats(self, part_id: int) -> TopologyStats:
|
|
part = self.part_by_id(part_id)
|
|
if part is None:
|
|
raise ValueError(f"Unknown part id {part_id}")
|
|
topo = TopologyExplorer(part.shape, ignore_orientation=True)
|
|
return TopologyStats(
|
|
parts=1,
|
|
solids=len(list(topo.solids())),
|
|
faces=len(list(topo.faces())),
|
|
edges=len(list(topo.edges())),
|
|
vertices=len(list(topo.vertices())),
|
|
)
|
|
|
|
def geometry_stats(self) -> dict[str, object]:
|
|
surface_props = GProp_GProps()
|
|
brepgprop.SurfaceProperties(self.shape, surface_props)
|
|
info: dict[str, object] = {
|
|
"surface_area": surface_props.Mass(),
|
|
"surface_center": _point_tuple(surface_props.CentreOfMass()),
|
|
}
|
|
info.update(_shape_bounds_info(self.shape))
|
|
info.update(_shape_volume_info(self.shape))
|
|
return info
|
|
|
|
def refresh_topology(self) -> None:
|
|
self.shape = _compound_from_shapes([p.shape for p in self.display_parts()])
|
|
self.faces.clear()
|
|
self.face_logical_ids.clear()
|
|
self.face_part_ids.clear()
|
|
self.face_solid_ids.clear()
|
|
self.edges.clear()
|
|
self.edge_part_ids.clear()
|
|
self.edge_solid_ids.clear()
|
|
self.solids.clear()
|
|
self._face_info_cache.clear()
|
|
self._edge_info_cache.clear()
|
|
self._face_edge_ids_cache.clear()
|
|
self._edge_face_ids_cache.clear()
|
|
self._same_domain_face_ids_cache.clear()
|
|
self._edge_duplicate_key_ids_cache = None
|
|
self._same_domain_internal_edge_ids_cache = None
|
|
self._same_domain_duplicate_edge_ids_cache = None
|
|
self._face_polydata_cache.clear()
|
|
self._edge_polydata_cache.clear()
|
|
self._mesh_deflection = None
|
|
|
|
solid_id = 0
|
|
for part in self.display_parts():
|
|
part_solids = _explore(part.shape, TopAbs_SOLID)
|
|
part_solid_edge_maps: list[tuple[int, TopTools_IndexedDataMapOfShapeListOfShape]] = []
|
|
part_solid_entries: list[tuple[int, TopoDS_Shape]] = []
|
|
if part_solids:
|
|
for solid in part_solids:
|
|
current_solid_id = solid_id
|
|
self.solids.append((part.id, solid))
|
|
part_solid_entries.append((current_solid_id, solid))
|
|
edge_map = TopTools_IndexedDataMapOfShapeListOfShape()
|
|
topexp.MapShapesAndAncestors(solid, TopAbs_EDGE, TopAbs_SOLID, edge_map)
|
|
part_solid_edge_maps.append((current_solid_id, edge_map))
|
|
solid_id += 1
|
|
|
|
part_edge_map = TopTools_IndexedMapOfShape()
|
|
topexp.MapShapes(part.shape, TopAbs_EDGE, part_edge_map)
|
|
part_edge_ids_by_index: dict[int, int] = {}
|
|
for local_edge_index in range(1, part_edge_map.Size() + 1):
|
|
edge = part_edge_map.FindKey(local_edge_index)
|
|
edge_id = len(self.edges)
|
|
part_edge_ids_by_index[local_edge_index] = edge_id
|
|
self.edges.append(edge)
|
|
self.edge_part_ids.append(part.id)
|
|
self.edge_solid_ids.append(_mapped_edge_solid_id(edge, part_solid_edge_maps))
|
|
self._edge_face_ids_cache[edge_id] = []
|
|
|
|
if part_solids:
|
|
for current_solid_id, solid in part_solid_entries:
|
|
for face in _explore(solid, TopAbs_FACE):
|
|
face_id = len(self.faces)
|
|
self.faces.append(face)
|
|
self.face_logical_ids.append(face_id)
|
|
self.face_part_ids.append(part.id)
|
|
self.face_solid_ids.append(current_solid_id)
|
|
self._cache_face_edge_links(face_id, face, part_edge_map, part_edge_ids_by_index)
|
|
else:
|
|
for face in _explore(part.shape, TopAbs_FACE):
|
|
face_id = len(self.faces)
|
|
self.faces.append(face)
|
|
self.face_logical_ids.append(face_id)
|
|
self.face_part_ids.append(part.id)
|
|
self.face_solid_ids.append(-1)
|
|
self._cache_face_edge_links(face_id, face, part_edge_map, part_edge_ids_by_index)
|
|
|
|
def _cache_face_edge_links(
|
|
self,
|
|
face_id: int,
|
|
face: TopoDS_Shape,
|
|
part_edge_map: TopTools_IndexedMapOfShape,
|
|
part_edge_ids_by_index: dict[int, int],
|
|
) -> None:
|
|
face_edge_ids: list[int] = []
|
|
face_edge_map = TopTools_IndexedMapOfShape()
|
|
topexp.MapShapes(face, TopAbs_EDGE, face_edge_map)
|
|
for local_face_edge_index in range(1, face_edge_map.Size() + 1):
|
|
local_part_edge_index = part_edge_map.FindIndex(face_edge_map.FindKey(local_face_edge_index))
|
|
edge_id = part_edge_ids_by_index.get(local_part_edge_index)
|
|
if edge_id is None:
|
|
continue
|
|
face_edge_ids.append(edge_id)
|
|
self._edge_face_ids_cache.setdefault(edge_id, []).append(face_id)
|
|
self._face_edge_ids_cache[face_id] = face_edge_ids
|
|
|
|
def part_by_id(self, part_id: int) -> PartNode | None:
|
|
return next((p for p in self.parts if p.id == part_id), None)
|
|
|
|
def snapshot(self) -> dict[object, object]:
|
|
data: dict[object, object] = {part.id: part.shape for part in self.parts}
|
|
data[SNAPSHOT_FACE_LOGICAL_IDS_KEY] = tuple(self.face_logical_ids)
|
|
return data
|
|
|
|
def restore_snapshot(self, snapshot: dict[object, object]) -> None:
|
|
for part in self.parts:
|
|
if part.id in snapshot:
|
|
part.shape = snapshot[part.id]
|
|
self.refresh_topology()
|
|
logical_ids = snapshot.get(SNAPSHOT_FACE_LOGICAL_IDS_KEY)
|
|
if isinstance(logical_ids, (list, tuple)) and len(logical_ids) == len(self.faces):
|
|
self.face_logical_ids = [int(item) for item in logical_ids]
|
|
self._face_info_cache.clear()
|
|
self._same_domain_face_ids_cache.clear()
|
|
|
|
def face_info(self, face_id: int) -> dict[str, object]:
|
|
if face_id in self._face_info_cache:
|
|
return dict(self._face_info_cache[face_id])
|
|
face = self.faces[face_id]
|
|
props = GProp_GProps()
|
|
brepgprop.SurfaceProperties(face, props)
|
|
|
|
surf = BRepAdaptor_Surface(face)
|
|
surface_type = surf.GetType()
|
|
boundary_edges = len(list(TopologyExplorer(face, ignore_orientation=True).edges()))
|
|
info: dict[str, object] = {
|
|
"kind": "face",
|
|
"face_id": face_id,
|
|
"topological_face_id": face_id,
|
|
"logical_face_id": self.face_logical_id(face_id),
|
|
"face_region_logical_id": self.face_region_logical_id(face_id),
|
|
"part_id": self.face_part_ids[face_id],
|
|
"solid_id": self.face_solid_ids[face_id],
|
|
"orientation": _orientation_name(face.Orientation()),
|
|
"surface": SURFACE_TYPES.get(surface_type, f"type {surface_type}"),
|
|
"area": props.Mass(),
|
|
"area_center": _point_tuple(props.CentreOfMass()),
|
|
"u_range": (surf.FirstUParameter(), surf.LastUParameter()),
|
|
"v_range": (surf.FirstVParameter(), surf.LastVParameter()),
|
|
"boundary_edges": boundary_edges,
|
|
}
|
|
info.update(_shape_bounds_info(face))
|
|
if surface_type == GeomAbs_Plane:
|
|
plane = surf.Plane()
|
|
direction = plane.Axis().Direction()
|
|
push_pull_direction = self._plane_push_pull_direction(face_id, surf)
|
|
info["plane_origin"] = _point_tuple(plane.Location())
|
|
info["normal"] = _dir_tuple(direction)
|
|
info["oriented_normal"] = _oriented_dir_tuple(direction, face)
|
|
info["push_pull_outward_direction"] = push_pull_direction["outward_direction"]
|
|
info["push_pull_inward_direction"] = push_pull_direction["inward_direction"]
|
|
info["push_pull_plus_side"] = push_pull_direction["plus_side_state"]
|
|
info["push_pull_minus_side"] = push_pull_direction["minus_side_state"]
|
|
info["push_pull_confidence"] = push_pull_direction["confidence"]
|
|
info["push_pull_note"] = push_pull_direction["note"]
|
|
elif surface_type == GeomAbs_Cylinder:
|
|
cyl = surf.Cylinder()
|
|
axis = cyl.Axis()
|
|
radius = cyl.Radius()
|
|
u_span = abs(surf.LastUParameter() - surf.FirstUParameter())
|
|
swept_area = max(radius * max(u_span, 1e-9), 1e-9)
|
|
classification = self._classify_cylindrical_face(face_id, surf, detailed=True)
|
|
info["radius"] = cyl.Radius()
|
|
info["diameter"] = cyl.Radius() * 2.0
|
|
info["axis_point"] = _point_tuple(axis.Location())
|
|
info["axis"] = _dir_tuple(axis.Direction())
|
|
info["angular_span"] = u_span
|
|
info["is_full_cylinder"] = u_span >= math.tau * 0.98
|
|
info["height_estimate"] = props.Mass() / swept_area
|
|
info["feature_guess"] = classification["feature_guess"]
|
|
info["confidence"] = classification["confidence"]
|
|
info["material_toward_axis"] = classification["toward_axis"]
|
|
info["material_away_axis"] = classification["away_axis"]
|
|
info["material_vote_summary"] = classification["vote_summary"]
|
|
info["material_sample_count"] = classification["sample_count"]
|
|
info["note"] = classification["note"]
|
|
info.update(self._cylinder_end_opening_info(face_id, surf))
|
|
info.update(_cylinder_resize_readiness(info))
|
|
info.update(_cylinder_boss_resize_readiness(info))
|
|
info.update(_cylinder_depth_readiness(info))
|
|
info.update(_cylinder_suppress_readiness(info))
|
|
elif surface_type == GeomAbs_Cone:
|
|
cone = surf.Cone()
|
|
info["axis_point"] = _point_tuple(cone.Location())
|
|
info["axis"] = _dir_tuple(cone.Axis().Direction())
|
|
info["reference_radius"] = cone.RefRadius()
|
|
info["semi_angle"] = cone.SemiAngle()
|
|
elif surface_type == GeomAbs_Sphere:
|
|
sphere = surf.Sphere()
|
|
info["center"] = _point_tuple(sphere.Location())
|
|
info["radius"] = sphere.Radius()
|
|
info["diameter"] = sphere.Radius() * 2.0
|
|
elif surface_type == GeomAbs_Torus:
|
|
torus = surf.Torus()
|
|
info["center"] = _point_tuple(torus.Location())
|
|
info["axis"] = _dir_tuple(torus.Axis().Direction())
|
|
info["major_radius"] = torus.MajorRadius()
|
|
info["minor_radius"] = torus.MinorRadius()
|
|
self._face_info_cache[face_id] = dict(info)
|
|
return dict(info)
|
|
|
|
def feature_info(self, face_id: int) -> dict[str, object]:
|
|
if face_id < 0 or face_id >= len(self.faces):
|
|
raise ValueError(f"Unknown face id {face_id}")
|
|
info = self.face_info(face_id)
|
|
surface = str(info.get("surface", ""))
|
|
if surface == "cylinder":
|
|
return self._cylindrical_feature_info(face_id, info)
|
|
if surface == "plane":
|
|
return self._planar_feature_info(face_id, info)
|
|
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
|
|
result = dict(info)
|
|
result.update(
|
|
{
|
|
"kind": "feature",
|
|
"feature_type": "暂不支持的局部曲面候选",
|
|
"feature_source_face_id": face_id,
|
|
"feature_face_ids": (face_id,),
|
|
"feature_highlight_face_ids": (face_id,),
|
|
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
|
|
"feature_edit_actions": "当前第一版只能查看该局部曲面,暂不支持直接编辑。",
|
|
"feature_mode": "Feature 模式会把选中的 face 解释为局部几何特征候选。",
|
|
}
|
|
)
|
|
return result
|
|
|
|
def _planar_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
|
|
coplanar_face_ids = self._connected_coplanar_planar_face_ids(face_id)
|
|
boundary_edge_ids = self._region_boundary_edge_ids(coplanar_face_ids)
|
|
shell_info = self._planar_shell_region_info(face_id, coplanar_face_ids, info)
|
|
if len(coplanar_face_ids) > 1:
|
|
scope_note = f"已检测到 {len(coplanar_face_ids)} 个共面且相接/重叠的 face,推拉时会作为同一片平面区域处理。"
|
|
else:
|
|
scope_note = "当前 face 没有检测到可一起推拉的共面相接/重叠邻居。"
|
|
edit_actions = "推拉平面"
|
|
if shell_info.get("shell_region_status") == "candidate":
|
|
edit_actions += ";查看薄壁/壳体厚度估算"
|
|
result = dict(info)
|
|
result.update(
|
|
{
|
|
"kind": "feature",
|
|
"feature_type": "可推拉平面候选",
|
|
"feature_source_face_id": face_id,
|
|
"feature_face_ids": tuple(coplanar_face_ids),
|
|
"feature_highlight_face_ids": tuple(coplanar_face_ids),
|
|
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
|
|
"feature_adjacent_face_ids": tuple(
|
|
sorted(set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - set(coplanar_face_ids))
|
|
),
|
|
"push_pull_scope_face_ids": tuple(coplanar_face_ids),
|
|
"push_pull_scope_face_count": len(coplanar_face_ids),
|
|
"push_pull_scope_note": scope_note,
|
|
"feature_edit_actions": edit_actions,
|
|
"feature_mode": "这是从 B-Rep 几何推断出的平面编辑候选,不是 CAD 历史特征。",
|
|
**shell_info,
|
|
}
|
|
)
|
|
return result
|
|
|
|
def _planar_shell_region_info(
|
|
self,
|
|
face_id: int,
|
|
coplanar_face_ids: Iterable[int],
|
|
info: dict[str, object],
|
|
) -> dict[str, object]:
|
|
try:
|
|
source_surf = BRepAdaptor_Surface(self.faces[face_id])
|
|
except Exception:
|
|
return {
|
|
"shell_region_status": "not-detected",
|
|
"shell_region_note": "无法读取当前平面,不能估算薄壁/壳体区域。",
|
|
}
|
|
if source_surf.GetType() != GeomAbs_Plane:
|
|
return {}
|
|
|
|
plane = source_surf.Plane()
|
|
normal = plane.Axis().Direction()
|
|
u_dir, v_dir = _plane_basis_dirs(normal)
|
|
valid_region_ids = sorted({int(item) for item in coplanar_face_ids if 0 <= int(item) < len(self.faces)})
|
|
if not valid_region_ids:
|
|
valid_region_ids = [face_id]
|
|
try:
|
|
region_shape = _compound_from_shapes(self.faces[item] for item in valid_region_ids)
|
|
source_interval = _shape_plane_interval(region_shape, plane.Location(), u_dir, v_dir)
|
|
except Exception:
|
|
source_interval = _shape_plane_interval(self.faces[face_id], plane.Location(), u_dir, v_dir)
|
|
if source_interval is None:
|
|
return {
|
|
"shell_region_status": "not-detected",
|
|
"shell_region_note": "当前平面区域缺少稳定投影范围,不能估算薄壁/壳体厚度。",
|
|
}
|
|
|
|
def interval_length(interval: tuple[float, float]) -> float:
|
|
return max(float(max(interval) - min(interval)), 0.0)
|
|
|
|
def overlap_length(left: tuple[float, float], right: tuple[float, float]) -> float:
|
|
left_min, left_max = min(left), max(left)
|
|
right_min, right_max = min(right), max(right)
|
|
return max(min(left_max, right_max) - max(left_min, right_min), 0.0)
|
|
|
|
source_area = max(interval_length(source_interval[0]) * interval_length(source_interval[1]), 1e-12)
|
|
diagonal = max(_shape_diagonal(self.shape), 1.0)
|
|
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
|
|
source_solid_id = self.face_solid_ids[face_id]
|
|
region_id_set = set(valid_region_ids)
|
|
best: dict[str, object] | None = None
|
|
best_score: tuple[float, float] | None = None
|
|
|
|
for candidate_id, face in enumerate(self.faces):
|
|
if candidate_id in region_id_set:
|
|
continue
|
|
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
|
|
continue
|
|
try:
|
|
candidate_surf = BRepAdaptor_Surface(face)
|
|
except Exception:
|
|
continue
|
|
if candidate_surf.GetType() != GeomAbs_Plane:
|
|
continue
|
|
candidate_plane = candidate_surf.Plane()
|
|
normal_dot = _direction_dot(normal, candidate_plane.Axis().Direction())
|
|
if abs(normal_dot) < 0.985:
|
|
continue
|
|
thickness = abs(_axis_parameter(plane.Location(), normal, candidate_plane.Location()))
|
|
if thickness <= tolerance:
|
|
continue
|
|
interval = _shape_plane_interval(face, plane.Location(), u_dir, v_dir)
|
|
if interval is None:
|
|
continue
|
|
overlap_area = overlap_length(source_interval[0], interval[0]) * overlap_length(source_interval[1], interval[1])
|
|
overlap_ratio = overlap_area / source_area
|
|
if overlap_ratio <= 0.02:
|
|
continue
|
|
score = (thickness, -overlap_ratio)
|
|
if best_score is None or score < best_score:
|
|
best_score = score
|
|
best = {
|
|
"shell_opposite_face_id": candidate_id,
|
|
"shell_thickness_estimate": thickness,
|
|
"shell_overlap_ratio_estimate": overlap_ratio,
|
|
"shell_opposite_normal_dot": normal_dot,
|
|
}
|
|
|
|
if best is None:
|
|
return {
|
|
"shell_region_status": "not-detected",
|
|
"shell_region_note": "未找到与当前平面投影重叠的相对平面;暂不能估算局部壳体/薄壁厚度。",
|
|
}
|
|
|
|
thickness = float(best["shell_thickness_estimate"])
|
|
overlap_ratio = float(best["shell_overlap_ratio_estimate"])
|
|
thin_ratio = thickness / diagonal
|
|
if overlap_ratio >= 0.55 and thin_ratio <= 0.08:
|
|
confidence = "high"
|
|
elif overlap_ratio >= 0.25 and thin_ratio <= 0.18:
|
|
confidence = "medium"
|
|
else:
|
|
confidence = "low"
|
|
kind = "thin-wall-opposite-plane-candidate" if thin_ratio <= 0.18 else "opposite-plane-region-candidate"
|
|
return {
|
|
"shell_region_kind": kind,
|
|
"shell_region_status": "candidate",
|
|
"shell_confidence": confidence,
|
|
"shell_source_face_ids": tuple(valid_region_ids),
|
|
**best,
|
|
"shell_note": (
|
|
"通过同一 solid 内投影重叠的相对平面估算薄壁/壳体厚度;"
|
|
"这是 B-Rep 几何近似,不等同于原 CAD 壳命令参数。"
|
|
),
|
|
}
|
|
|
|
def _cylindrical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
|
|
side_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id]
|
|
surf = BRepAdaptor_Surface(self.faces[face_id])
|
|
axis_range = self._cylindrical_axis_range(face_id, surf, side_face_ids)
|
|
side_face_set = set(side_face_ids)
|
|
boundary_edge_ids = self._region_boundary_edge_ids(side_face_ids)
|
|
adjacent_face_ids = sorted(set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - side_face_set)
|
|
domain_info = dict(info)
|
|
domain_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
|
|
domain_info["height_estimate"] = axis_range["span"]
|
|
end_faces = self._cylindrical_end_face_groups(face_id, adjacent_face_ids, domain_info)
|
|
end_face_ids = end_faces["end_face_ids"]
|
|
bottom_face_ids = end_faces["bottom_face_ids"]
|
|
opening_face_ids = end_faces["opening_face_ids"]
|
|
slot_info = self._cylindrical_slot_info(face_id, adjacent_face_ids, end_face_ids, domain_info)
|
|
fillet_info = self._cylindrical_existing_fillet_info(face_id, adjacent_face_ids, end_face_ids, domain_info)
|
|
|
|
guess = str(info.get("feature_guess", "cylindrical face"))
|
|
angular_span = float(info.get("angular_span", 0.0))
|
|
if guess == "hole/groove candidate":
|
|
if angular_span < math.tau * 0.92:
|
|
feature_type = "槽/半孔候选"
|
|
else:
|
|
feature_type = "圆柱孔候选"
|
|
if info.get("cylinder_end_type") == "blind" and bottom_face_ids:
|
|
edit_actions = "调整圆柱孔径;调整盲孔深度"
|
|
else:
|
|
edit_actions = "调整圆柱孔径;孔深调整需要明确盲孔底面"
|
|
if angular_span >= math.tau * 0.92:
|
|
edit_actions += ";封堵圆柱孔"
|
|
elif guess == "round/fillet candidate":
|
|
feature_type = "圆角/倒圆候选"
|
|
edit_actions = "可尝试修改已有圆角半径;第一版会先移除圆角面,再在恢复出的锐边上重新倒圆。"
|
|
elif guess == "boss/outer-round candidate":
|
|
feature_type = "凸台/外圆候选"
|
|
edit_actions = "调整圆柱凸台直径。"
|
|
else:
|
|
feature_type = "未明确圆柱特征"
|
|
edit_actions = "可尝试调整圆柱孔径,但风险较高。"
|
|
|
|
highlight_face_ids = tuple(
|
|
sorted(
|
|
{
|
|
*side_face_ids,
|
|
*end_face_ids,
|
|
*slot_info.get("feature_slot_boundary_face_ids", ()),
|
|
*fillet_info.get("feature_existing_fillet_support_face_ids", ()),
|
|
}
|
|
)
|
|
)
|
|
result = dict(info)
|
|
result.update(
|
|
{
|
|
"kind": "feature",
|
|
"feature_type": feature_type,
|
|
"feature_source_face_id": face_id,
|
|
"feature_face_ids": tuple(side_face_ids),
|
|
"feature_side_face_ids": tuple(side_face_ids),
|
|
"feature_end_face_ids": tuple(end_face_ids),
|
|
"feature_bottom_face_ids": tuple(bottom_face_ids),
|
|
"feature_opening_face_ids": tuple(opening_face_ids),
|
|
"feature_highlight_face_ids": highlight_face_ids,
|
|
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
|
|
"feature_adjacent_face_ids": tuple(adjacent_face_ids),
|
|
"same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]),
|
|
"same_domain_height_estimate": axis_range["span"],
|
|
"same_domain_range_source": axis_range["range_source"],
|
|
"same_domain_face_ids": tuple(side_face_ids),
|
|
"same_domain_face_count": len(side_face_ids),
|
|
"same_domain_note": (
|
|
"已把同一实体内同轴、同半径且轴向连续/重叠的圆柱 face 当作同一几何区域。"
|
|
if len(side_face_ids) > 1
|
|
else "当前圆柱 face 没有检测到可合并选择的同域圆柱面。"
|
|
),
|
|
"feature_start_end_face_ids": tuple(end_faces["start_end_face_ids"]),
|
|
"feature_end_end_face_ids": tuple(end_faces["end_end_face_ids"]),
|
|
"feature_bottom_confidence": end_faces["bottom_confidence"],
|
|
"feature_bottom_detection": end_faces["bottom_detection"],
|
|
"feature_bottom_note": end_faces["bottom_note"],
|
|
"feature_edit_actions": edit_actions,
|
|
"feature_mode": "这是从 B-Rep 圆柱面、相邻面和材料采样推断出的局部特征候选。",
|
|
**slot_info,
|
|
**fillet_info,
|
|
}
|
|
)
|
|
return result
|
|
|
|
def _cylindrical_slot_info(
|
|
self,
|
|
face_id: int,
|
|
adjacent_face_ids: list[int],
|
|
end_face_ids: Iterable[int],
|
|
info: dict[str, object],
|
|
) -> dict[str, object]:
|
|
guess = str(info.get("feature_guess", "cylindrical face"))
|
|
angular_span = float(info.get("angular_span", 0.0))
|
|
if guess != "hole/groove candidate" or angular_span >= math.tau * 0.92:
|
|
return {}
|
|
|
|
radius = max(float(info.get("radius", 0.0)), 0.0)
|
|
span = min(max(angular_span, 0.0), math.tau)
|
|
boundary_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids))
|
|
chord_width = 2.0 * radius * math.sin(span / 2.0) if radius > 0 else 0.0
|
|
sagitta_depth = radius * (1.0 - math.cos(min(span, math.pi) / 2.0)) if radius > 0 else 0.0
|
|
return {
|
|
"slot_kind": "partial-cylindrical-groove",
|
|
"slot_status": "candidate",
|
|
"slot_angular_span": angular_span,
|
|
"slot_open_angle": max(math.tau - span, 0.0),
|
|
"slot_chord_width_estimate": chord_width,
|
|
"slot_arc_length_estimate": radius * span,
|
|
"slot_sagitta_depth_estimate": sagitta_depth,
|
|
"feature_slot_face_ids": (face_id,),
|
|
"feature_slot_boundary_face_ids": tuple(boundary_face_ids),
|
|
"slot_note": (
|
|
"这是由局部圆柱面推断出的槽/半孔候选;宽度和深度是几何估算,"
|
|
"不是 CAD 历史里的参数。"
|
|
),
|
|
}
|
|
|
|
def _cylindrical_existing_fillet_info(
|
|
self,
|
|
face_id: int,
|
|
adjacent_face_ids: list[int],
|
|
end_face_ids: Iterable[int],
|
|
info: dict[str, object],
|
|
) -> dict[str, object]:
|
|
if str(info.get("feature_guess", "cylindrical face")) != "round/fillet candidate":
|
|
return {}
|
|
|
|
radius = max(float(info.get("radius", 0.0)), 0.0)
|
|
angular_span = min(max(float(info.get("angular_span", 0.0)), 0.0), math.tau)
|
|
support_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids))
|
|
return {
|
|
"existing_fillet_kind": "cylindrical-round-face",
|
|
"existing_fillet_status": "candidate",
|
|
"existing_fillet_radius_estimate": radius,
|
|
"existing_fillet_angular_span": angular_span,
|
|
"existing_fillet_arc_length_estimate": radius * angular_span,
|
|
"feature_existing_fillet_face_ids": (face_id,),
|
|
"feature_existing_fillet_support_face_ids": tuple(support_face_ids),
|
|
"existing_fillet_note": (
|
|
"这是由局部小半径圆柱面推断出的已有圆角/倒圆候选;"
|
|
"第一版可尝试使用 defeature + 重新倒圆修改半径;"
|
|
"复杂 blend 或支撑面不明确时可能失败并回滚。"
|
|
),
|
|
}
|
|
|
|
def _cylindrical_end_face_groups(
|
|
self,
|
|
face_id: int,
|
|
adjacent_face_ids: list[int],
|
|
info: dict[str, object],
|
|
) -> dict[str, object]:
|
|
axis_point_values = info.get("axis_point")
|
|
axis_values = info.get("axis")
|
|
v_range = info.get("v_range")
|
|
if not isinstance(axis_point_values, tuple) or not isinstance(axis_values, tuple) or not isinstance(v_range, tuple):
|
|
return {
|
|
"end_face_ids": [],
|
|
"start_end_face_ids": [],
|
|
"end_end_face_ids": [],
|
|
"bottom_face_ids": [],
|
|
"opening_face_ids": [],
|
|
"bottom_note": "缺少圆柱轴线或参数范围,无法判断端面/底面。",
|
|
}
|
|
|
|
axis_point = gp_Pnt(*axis_point_values)
|
|
axis_dir = gp_Dir(float(axis_values[0]), float(axis_values[1]), float(axis_values[2]))
|
|
v_min = min(float(v_range[0]), float(v_range[1]))
|
|
v_max = max(float(v_range[0]), float(v_range[1]))
|
|
span = max(v_max - v_min, 1e-9)
|
|
radius = float(info.get("radius", 0.0))
|
|
tolerance = max(span * 0.08, radius * 0.2, 0.05)
|
|
|
|
start_end_face_ids: list[int] = []
|
|
end_end_face_ids: list[int] = []
|
|
for adjacent_id in adjacent_face_ids:
|
|
match = self._axis_end_match_for_planar_face(
|
|
adjacent_id,
|
|
axis_point,
|
|
axis_dir,
|
|
v_min,
|
|
v_max,
|
|
tolerance,
|
|
radial_tolerance=None,
|
|
)
|
|
if match == "start":
|
|
start_end_face_ids.append(adjacent_id)
|
|
elif match == "end":
|
|
end_end_face_ids.append(adjacent_id)
|
|
|
|
if info.get("cylinder_end_type") == "blind" and (not start_end_face_ids or not end_end_face_ids):
|
|
scanned = self._axis_cap_face_candidates(
|
|
face_id,
|
|
axis_point,
|
|
axis_dir,
|
|
v_min,
|
|
v_max,
|
|
radius,
|
|
span,
|
|
set(adjacent_face_ids),
|
|
)
|
|
if not start_end_face_ids:
|
|
start_end_face_ids.extend(scanned["start"])
|
|
if not end_end_face_ids:
|
|
end_end_face_ids.extend(scanned["end"])
|
|
|
|
bottom_face_ids: list[int] = []
|
|
opening_face_ids: list[int] = []
|
|
if info.get("start_end_open") is True:
|
|
opening_face_ids.extend(start_end_face_ids)
|
|
elif info.get("start_end_state") == "inside":
|
|
bottom_face_ids.extend(start_end_face_ids)
|
|
if info.get("end_end_open") is True:
|
|
opening_face_ids.extend(end_end_face_ids)
|
|
elif info.get("end_end_state") == "inside":
|
|
bottom_face_ids.extend(end_end_face_ids)
|
|
|
|
end_face_ids = sorted({*start_end_face_ids, *end_end_face_ids})
|
|
bottom_face_ids = sorted(set(bottom_face_ids))
|
|
opening_face_ids = sorted(set(opening_face_ids))
|
|
bottom_detection = "axis-cap-scan" if any(
|
|
face_id not in adjacent_face_ids for face_id in bottom_face_ids
|
|
) else "adjacent-end-face"
|
|
bottom_confidence = "medium" if bottom_detection == "axis-cap-scan" else "high"
|
|
if not end_face_ids:
|
|
note = "没有在圆柱边界附近找到平面端面。"
|
|
elif bottom_face_ids:
|
|
if bottom_detection == "axis-cap-scan":
|
|
note = "已通过轴线端部采样和轴线附近圆盘面扫描标记疑似底面;这是几何推断,不是 CAD 历史孔深。"
|
|
else:
|
|
note = "已根据圆柱轴线端部 inside/outside 采样标记疑似底面;这是几何推断,不是 CAD 历史孔深。"
|
|
else:
|
|
note = "已找到端面候选,但端部采样显示这些端面更像开口附近的相邻面。"
|
|
return {
|
|
"end_face_ids": end_face_ids,
|
|
"start_end_face_ids": sorted(set(start_end_face_ids)),
|
|
"end_end_face_ids": sorted(set(end_end_face_ids)),
|
|
"bottom_face_ids": bottom_face_ids,
|
|
"opening_face_ids": opening_face_ids,
|
|
"bottom_confidence": bottom_confidence if bottom_face_ids else "none",
|
|
"bottom_detection": bottom_detection if bottom_face_ids else "none",
|
|
"bottom_note": note,
|
|
}
|
|
|
|
def _axis_end_match_for_planar_face(
|
|
self,
|
|
face_id: int,
|
|
axis_point: gp_Pnt,
|
|
axis_dir: gp_Dir,
|
|
v_min: float,
|
|
v_max: float,
|
|
tolerance: float,
|
|
radial_tolerance: float | None,
|
|
) -> str | None:
|
|
surf = BRepAdaptor_Surface(self.faces[face_id])
|
|
if surf.GetType() != GeomAbs_Plane:
|
|
return None
|
|
normal = surf.Plane().Axis().Direction()
|
|
if abs(_direction_dot(normal, axis_dir)) < 0.65:
|
|
return None
|
|
if radial_tolerance is not None:
|
|
center = _surface_center(self.faces[face_id])
|
|
if _point_axis_distance(axis_point, axis_dir, center) > radial_tolerance:
|
|
return None
|
|
parameters = _shape_axis_parameters(self.faces[face_id], axis_point, axis_dir)
|
|
if not parameters:
|
|
return None
|
|
start_distance = min(abs(parameter - v_min) for parameter in parameters)
|
|
end_distance = min(abs(parameter - v_max) for parameter in parameters)
|
|
if min(start_distance, end_distance) > tolerance:
|
|
return None
|
|
return "start" if start_distance <= end_distance else "end"
|
|
|
|
def _axis_cap_face_candidates(
|
|
self,
|
|
face_id: int,
|
|
axis_point: gp_Pnt,
|
|
axis_dir: gp_Dir,
|
|
v_min: float,
|
|
v_max: float,
|
|
radius: float,
|
|
span: float,
|
|
adjacent_face_ids: set[int],
|
|
) -> dict[str, list[int]]:
|
|
source_solid_id = self.face_solid_ids[face_id]
|
|
tolerance = max(span * 0.12, radius * 0.35, 0.08)
|
|
radial_tolerance = max(radius * 1.2, tolerance)
|
|
start: list[int] = []
|
|
end: list[int] = []
|
|
for candidate_id in range(len(self.faces)):
|
|
if candidate_id == face_id or candidate_id in adjacent_face_ids:
|
|
continue
|
|
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
|
|
continue
|
|
match = self._axis_end_match_for_planar_face(
|
|
candidate_id,
|
|
axis_point,
|
|
axis_dir,
|
|
v_min,
|
|
v_max,
|
|
tolerance,
|
|
radial_tolerance=radial_tolerance,
|
|
)
|
|
if match == "start":
|
|
start.append(candidate_id)
|
|
elif match == "end":
|
|
end.append(candidate_id)
|
|
return {"start": sorted(set(start)), "end": sorted(set(end))}
|
|
|
|
def _bottom_face_axis_parameter(
|
|
self,
|
|
bottom_face_ids: Iterable[int],
|
|
axis_point: gp_Pnt,
|
|
axis_dir: gp_Dir,
|
|
expected_parameter: float,
|
|
) -> float | None:
|
|
candidates: list[float] = []
|
|
for bottom_face_id in bottom_face_ids:
|
|
if bottom_face_id < 0 or bottom_face_id >= len(self.faces):
|
|
continue
|
|
parameters = _shape_axis_parameters(self.faces[bottom_face_id], axis_point, axis_dir)
|
|
if not parameters:
|
|
continue
|
|
candidates.append(sum(parameters) / len(parameters))
|
|
if not candidates:
|
|
return None
|
|
return min(candidates, key=lambda parameter: abs(parameter - expected_parameter))
|
|
|
|
def _face_boundary_edge_ids(self, face_id: int) -> list[int]:
|
|
if face_id in self._face_edge_ids_cache:
|
|
return list(self._face_edge_ids_cache[face_id])
|
|
if face_id < 0 or face_id >= len(self.faces):
|
|
return []
|
|
face_edges = list(TopologyExplorer(self.faces[face_id], ignore_orientation=True).edges())
|
|
edge_ids: list[int] = []
|
|
for edge_id, edge in enumerate(self.edges):
|
|
if any(_same_shape(edge, face_edge) for face_edge in face_edges):
|
|
edge_ids.append(edge_id)
|
|
self._face_edge_ids_cache[face_id] = list(edge_ids)
|
|
return edge_ids
|
|
|
|
def face_boundary_edge_ids(self, face_id: int) -> list[int]:
|
|
if face_id < 0 or face_id >= len(self.faces):
|
|
return []
|
|
return self._face_boundary_edge_ids(face_id)
|
|
|
|
def 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._face_info_cache.pop(face_id, None)
|
|
|
|
def nearest_edge_id_to_point(
|
|
self,
|
|
edge_ids: Iterable[int],
|
|
point: tuple[float, float, float] | None,
|
|
) -> int | None:
|
|
valid_edge_ids = [int(edge_id) for edge_id in edge_ids if 0 <= int(edge_id) < len(self.edges)]
|
|
if not valid_edge_ids:
|
|
return None
|
|
if point is None:
|
|
return valid_edge_ids[0]
|
|
|
|
px, py, pz = (float(point[0]), float(point[1]), float(point[2]))
|
|
best_edge_id: int | None = None
|
|
best_distance = math.inf
|
|
for edge_id in valid_edge_ids:
|
|
try:
|
|
samples = discretize_edge(self.edges[edge_id], 0.35)
|
|
except Exception:
|
|
samples = []
|
|
if len(samples) < 2:
|
|
try:
|
|
curve = BRepAdaptor_Curve(self.edges[edge_id])
|
|
samples = [
|
|
_point_tuple(curve.Value(curve.FirstParameter())),
|
|
_point_tuple(curve.Value(curve.LastParameter())),
|
|
]
|
|
except Exception:
|
|
samples = []
|
|
if not samples:
|
|
continue
|
|
sample_points = [(float(coords[0]), float(coords[1]), float(coords[2])) for coords in samples]
|
|
if len(sample_points) == 1:
|
|
distance = _point_distance_sq((px, py, pz), sample_points[0])
|
|
else:
|
|
distance = min(
|
|
_point_segment_distance_sq((px, py, pz), start, end)
|
|
for start, end in zip(sample_points, sample_points[1:])
|
|
)
|
|
if distance < best_distance:
|
|
best_distance = distance
|
|
best_edge_id = edge_id
|
|
return best_edge_id if best_edge_id is not None else valid_edge_ids[0]
|
|
|
|
def _adjacent_face_ids_for_edges(self, edge_ids: list[int], face_id: int) -> list[int]:
|
|
if not edge_ids:
|
|
return []
|
|
source_solid_id = self.face_solid_ids[face_id]
|
|
adjacent: set[int] = set()
|
|
for edge_id in edge_ids:
|
|
if edge_id < 0 or edge_id >= len(self.edges):
|
|
continue
|
|
for candidate_id in self._edge_adjacent_face_ids(edge_id):
|
|
if candidate_id == face_id:
|
|
continue
|
|
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
|
|
continue
|
|
adjacent.add(candidate_id)
|
|
return sorted(adjacent)
|
|
|
|
def _connected_coplanar_planar_face_ids(self, face_id: int) -> list[int]:
|
|
if face_id < 0 or face_id >= len(self.faces):
|
|
return []
|
|
source_surf = BRepAdaptor_Surface(self.faces[face_id])
|
|
if source_surf.GetType() != GeomAbs_Plane:
|
|
return [face_id]
|
|
|
|
source_solid_id = self.face_solid_ids[face_id]
|
|
tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3)
|
|
interval_tolerance = max(tolerance * 20.0, _shape_diagonal(self.shape) * 1e-6, 1e-4)
|
|
plane = source_surf.Plane()
|
|
axis_point = plane.Location()
|
|
u_dir, v_dir = _plane_basis_dirs(plane.Axis().Direction())
|
|
candidates: dict[int, tuple[tuple[float, float], tuple[float, float]]] = {}
|
|
for candidate_id, face in enumerate(self.faces):
|
|
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
|
|
continue
|
|
candidate_surf = BRepAdaptor_Surface(face)
|
|
if not _surfaces_are_coplanar(source_surf, candidate_surf, tolerance):
|
|
continue
|
|
interval = _shape_plane_interval(face, axis_point, u_dir, v_dir)
|
|
if interval is not None:
|
|
candidates[candidate_id] = interval
|
|
|
|
if face_id in candidates:
|
|
visited = {face_id}
|
|
queue = [face_id]
|
|
while queue:
|
|
current_id = queue.pop(0)
|
|
current_interval = candidates[current_id]
|
|
for candidate_id, candidate_interval in candidates.items():
|
|
if candidate_id in visited:
|
|
continue
|
|
if _plane_intervals_touch_or_overlap(
|
|
current_interval,
|
|
candidate_interval,
|
|
interval_tolerance,
|
|
):
|
|
visited.add(candidate_id)
|
|
queue.append(candidate_id)
|
|
return sorted(visited)
|
|
|
|
visited = {face_id}
|
|
queue = [face_id]
|
|
while queue:
|
|
current_id = queue.pop(0)
|
|
for adjacent_id in self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(current_id), current_id):
|
|
if adjacent_id in visited:
|
|
continue
|
|
if source_solid_id >= 0 and self.face_solid_ids[adjacent_id] != source_solid_id:
|
|
continue
|
|
candidate_surf = BRepAdaptor_Surface(self.faces[adjacent_id])
|
|
if _surfaces_are_coplanar(source_surf, candidate_surf, tolerance):
|
|
visited.add(adjacent_id)
|
|
queue.append(adjacent_id)
|
|
return sorted(visited)
|
|
|
|
def connected_same_domain_face_ids(self, face_id: int) -> list[int]:
|
|
if face_id < 0 or face_id >= len(self.faces):
|
|
return []
|
|
if face_id in self._same_domain_face_ids_cache:
|
|
return list(self._same_domain_face_ids_cache[face_id])
|
|
source_surf = BRepAdaptor_Surface(self.faces[face_id])
|
|
surface_type = source_surf.GetType()
|
|
if surface_type == GeomAbs_Plane:
|
|
face_ids = self._connected_coplanar_planar_face_ids(face_id)
|
|
elif surface_type == GeomAbs_Cylinder:
|
|
face_ids = self._connected_cocylindrical_face_ids(face_id)
|
|
else:
|
|
face_ids = [face_id]
|
|
face_ids = sorted(set(face_ids or [face_id]))
|
|
for item in face_ids:
|
|
self._same_domain_face_ids_cache[item] = list(face_ids)
|
|
return list(face_ids)
|
|
|
|
def _connected_cocylindrical_face_ids(self, face_id: int) -> list[int]:
|
|
if face_id < 0 or face_id >= len(self.faces):
|
|
return []
|
|
source_solid_id = self.face_solid_ids[face_id]
|
|
source_surf = BRepAdaptor_Surface(self.faces[face_id])
|
|
if source_surf.GetType() != GeomAbs_Cylinder:
|
|
return [face_id]
|
|
cylinder = source_surf.Cylinder()
|
|
axis = cylinder.Axis()
|
|
axis_point = axis.Location()
|
|
axis_dir = axis.Direction()
|
|
radius = max(float(cylinder.Radius()), 0.0)
|
|
diagonal = _shape_diagonal(self.shape)
|
|
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
|
|
interval_tolerance = max(tolerance * 50.0, diagonal * 1e-5, radius * 1e-4, 1e-3)
|
|
source_interval = _shape_axis_interval(self.faces[face_id], axis_point, axis_dir)
|
|
|
|
candidates: dict[int, tuple[float, float]] = {}
|
|
for candidate_id, face in enumerate(self.faces):
|
|
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
|
|
continue
|
|
candidate_surf = BRepAdaptor_Surface(face)
|
|
if not _surfaces_are_cocylindrical(source_surf, candidate_surf, tolerance):
|
|
continue
|
|
interval = _shape_axis_interval(face, axis_point, axis_dir)
|
|
if interval is not None:
|
|
candidates[candidate_id] = interval
|
|
|
|
if source_interval is not None and face_id in candidates:
|
|
visited = {face_id}
|
|
queue = [face_id]
|
|
while queue:
|
|
current_id = queue.pop(0)
|
|
current_interval = candidates[current_id]
|
|
for candidate_id, candidate_interval in candidates.items():
|
|
if candidate_id in visited:
|
|
continue
|
|
if _intervals_touch_or_overlap(
|
|
current_interval,
|
|
candidate_interval,
|
|
interval_tolerance,
|
|
):
|
|
visited.add(candidate_id)
|
|
queue.append(candidate_id)
|
|
return sorted(visited)
|
|
|
|
visited = {face_id}
|
|
queue = [face_id]
|
|
while queue:
|
|
current_id = queue.pop(0)
|
|
for adjacent_id in self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(current_id), current_id):
|
|
if adjacent_id in visited:
|
|
continue
|
|
if source_solid_id >= 0 and self.face_solid_ids[adjacent_id] != source_solid_id:
|
|
continue
|
|
candidate_surf = BRepAdaptor_Surface(self.faces[adjacent_id])
|
|
if _surfaces_are_cocylindrical(source_surf, candidate_surf, tolerance):
|
|
visited.add(adjacent_id)
|
|
queue.append(adjacent_id)
|
|
return sorted(visited)
|
|
|
|
def _cylindrical_axis_range(
|
|
self,
|
|
face_id: int,
|
|
surf: BRepAdaptor_Surface | None = None,
|
|
face_ids: Iterable[int] | None = None,
|
|
) -> dict[str, object]:
|
|
if face_id < 0 or face_id >= len(self.faces):
|
|
raise ValueError(f"Unknown face id {face_id}")
|
|
surf = surf or BRepAdaptor_Surface(self.faces[face_id])
|
|
if surf.GetType() != GeomAbs_Cylinder:
|
|
raise ValueError("Selected face is not cylindrical.")
|
|
|
|
cyl = surf.Cylinder()
|
|
axis = cyl.Axis()
|
|
axis_point = axis.Location()
|
|
axis_dir = axis.Direction()
|
|
source_v_min = min(float(surf.FirstVParameter()), float(surf.LastVParameter()))
|
|
source_v_max = max(float(surf.FirstVParameter()), float(surf.LastVParameter()))
|
|
|
|
if face_ids is None:
|
|
domain_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id]
|
|
else:
|
|
domain_face_ids = sorted({int(item) for item in face_ids if 0 <= int(item) < len(self.faces)})
|
|
if face_id not in domain_face_ids:
|
|
domain_face_ids.append(face_id)
|
|
domain_face_ids.sort()
|
|
|
|
intervals: list[tuple[float, float]] = []
|
|
tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3)
|
|
for item in domain_face_ids:
|
|
item_surf = BRepAdaptor_Surface(self.faces[item])
|
|
if item_surf.GetType() != GeomAbs_Cylinder:
|
|
continue
|
|
if not _surfaces_are_cocylindrical(item_surf, surf, tolerance):
|
|
continue
|
|
interval = _shape_axis_interval(self.faces[item], axis_point, axis_dir)
|
|
if interval is not None:
|
|
intervals.append(interval)
|
|
|
|
if intervals and len(domain_face_ids) > 1:
|
|
v_min = min(interval[0] for interval in intervals)
|
|
v_max = max(interval[1] for interval in intervals)
|
|
range_source = "same-domain-cylinder-faces"
|
|
else:
|
|
v_min = source_v_min
|
|
v_max = source_v_max
|
|
range_source = "selected-face-v-range"
|
|
|
|
return {
|
|
"axis_point": axis_point,
|
|
"axis_direction": axis_dir,
|
|
"v_min": v_min,
|
|
"v_max": v_max,
|
|
"span": max(v_max - v_min, 0.0),
|
|
"source_v_min": source_v_min,
|
|
"source_v_max": source_v_max,
|
|
"same_domain_face_ids": tuple(domain_face_ids),
|
|
"same_domain_face_count": len(domain_face_ids),
|
|
"range_source": range_source,
|
|
}
|
|
|
|
def _region_boundary_edge_ids(self, face_ids: Iterable[int]) -> list[int]:
|
|
counts: dict[int, int] = {}
|
|
for face_id in face_ids:
|
|
for edge_id in self._face_boundary_edge_ids(face_id):
|
|
counts[edge_id] = counts.get(edge_id, 0) + 1
|
|
return sorted(edge_id for edge_id, count in counts.items() if count == 1)
|
|
|
|
def _push_pull_profile_shape(self, face_ids: Iterable[int]) -> TopoDS_Shape:
|
|
profile_faces = [self.faces[face_id] for face_id in face_ids if 0 <= face_id < len(self.faces)]
|
|
if not profile_faces:
|
|
raise ValueError("No planar faces were found for push/pull.")
|
|
return _unify_same_domain_shape(_compound_from_shapes(profile_faces))
|
|
|
|
def _face_region_mapping_specs(self, face_ids: Iterable[int]) -> list[dict[str, object]]:
|
|
specs: list[dict[str, object]] = []
|
|
seen_logical_ids: set[int] = set()
|
|
diagonal = _shape_diagonal(self.shape)
|
|
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
|
|
for face_id in sorted({int(item) for item in face_ids if 0 <= int(item) < len(self.faces)}):
|
|
logical_id = self.face_region_logical_id(face_id)
|
|
if logical_id in seen_logical_ids:
|
|
continue
|
|
seen_logical_ids.add(logical_id)
|
|
face = self.faces[face_id]
|
|
surf = BRepAdaptor_Surface(face)
|
|
if surf.GetType() == GeomAbs_Plane:
|
|
plane = surf.Plane()
|
|
normal = plane.Axis().Direction()
|
|
u_dir, v_dir = _plane_basis_dirs(normal)
|
|
interval = _shape_plane_interval(face, plane.Location(), u_dir, v_dir)
|
|
if interval is None:
|
|
continue
|
|
specs.append(
|
|
{
|
|
"surface": "plane",
|
|
"logical_id": logical_id,
|
|
"part_id": self.face_part_ids[face_id],
|
|
"point": _point_tuple(plane.Location()),
|
|
"normal": _dir_tuple(normal),
|
|
"u_dir": _dir_tuple(u_dir),
|
|
"v_dir": _dir_tuple(v_dir),
|
|
"interval": interval,
|
|
"tolerance": tolerance,
|
|
}
|
|
)
|
|
elif surf.GetType() == GeomAbs_Cylinder:
|
|
cylinder = surf.Cylinder()
|
|
axis = cylinder.Axis()
|
|
interval = _shape_axis_interval(face, axis.Location(), axis.Direction())
|
|
if interval is None:
|
|
continue
|
|
specs.append(
|
|
{
|
|
"surface": "cylinder",
|
|
"logical_id": logical_id,
|
|
"part_id": self.face_part_ids[face_id],
|
|
"axis_point": _point_tuple(axis.Location()),
|
|
"axis_direction": _dir_tuple(axis.Direction()),
|
|
"radius": float(cylinder.Radius()),
|
|
"interval": interval,
|
|
"tolerance": tolerance,
|
|
}
|
|
)
|
|
return specs
|
|
|
|
def _apply_face_region_mapping_specs(self, specs: Iterable[dict[str, object]]) -> None:
|
|
for spec in specs:
|
|
logical_id = int(spec.get("logical_id", -1))
|
|
if logical_id < 0:
|
|
continue
|
|
seed_face_ids = self._matching_face_ids_for_region_spec(spec)
|
|
if not seed_face_ids:
|
|
continue
|
|
region_ids: set[int] = set()
|
|
for seed_face_id in seed_face_ids:
|
|
region_ids.update(self.connected_same_domain_face_ids(seed_face_id) or [seed_face_id])
|
|
self.assign_logical_face_region(logical_id, region_ids)
|
|
|
|
def _matching_face_ids_for_region_spec(self, spec: dict[str, object]) -> list[int]:
|
|
surface = str(spec.get("surface", ""))
|
|
part_id = int(spec.get("part_id", -1))
|
|
tolerance_value = spec.get("tolerance")
|
|
if tolerance_value is None:
|
|
tolerance_value = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3)
|
|
tolerance = float(tolerance_value)
|
|
matches: list[int] = []
|
|
for face_id, face in enumerate(self.faces):
|
|
if part_id >= 0 and self.face_part_ids[face_id] != part_id:
|
|
continue
|
|
surf = BRepAdaptor_Surface(face)
|
|
if surface == "plane":
|
|
if not _surface_matches_plane_spec(surf, spec, tolerance):
|
|
continue
|
|
plane = surf.Plane()
|
|
u_dir = gp_Dir(*spec["u_dir"])
|
|
v_dir = gp_Dir(*spec["v_dir"])
|
|
interval = _shape_plane_interval(face, gp_Pnt(*spec["point"]), u_dir, v_dir)
|
|
if interval is not None and _plane_intervals_touch_or_overlap(interval, spec["interval"], max(tolerance * 20.0, 1e-4)):
|
|
matches.append(face_id)
|
|
elif surface == "cylinder":
|
|
if not _surface_matches_cylinder_spec(surf, spec, tolerance):
|
|
continue
|
|
interval = _shape_axis_interval(face, gp_Pnt(*spec["axis_point"]), gp_Dir(*spec["axis_direction"]))
|
|
interval_tolerance = max(tolerance * 50.0, _shape_diagonal(self.shape) * 1e-5, float(spec.get("radius", 0.0)) * 1e-4, 1e-3)
|
|
if interval is not None and _intervals_touch_or_overlap(interval, spec["interval"], interval_tolerance):
|
|
matches.append(face_id)
|
|
return matches
|
|
|
|
def edge_info(self, edge_id: int) -> dict[str, object]:
|
|
if edge_id in self._edge_info_cache:
|
|
return dict(self._edge_info_cache[edge_id])
|
|
edge = self.edges[edge_id]
|
|
props = GProp_GProps()
|
|
brepgprop.LinearProperties(edge, props)
|
|
|
|
curve = BRepAdaptor_Curve(edge)
|
|
curve_type = curve.GetType()
|
|
info: dict[str, object] = {
|
|
"kind": "edge",
|
|
"edge_id": edge_id,
|
|
"part_id": self.edge_part_ids[edge_id],
|
|
"solid_id": self._edge_solid_id(edge_id),
|
|
"orientation": _orientation_name(edge.Orientation()),
|
|
"curve": CURVE_TYPES.get(curve_type, f"type {curve_type}"),
|
|
"length": props.Mass(),
|
|
"length_center": _point_tuple(props.CentreOfMass()),
|
|
"first_parameter": curve.FirstParameter(),
|
|
"last_parameter": curve.LastParameter(),
|
|
"start_point": _point_tuple(curve.Value(curve.FirstParameter())),
|
|
"end_point": _point_tuple(curve.Value(curve.LastParameter())),
|
|
}
|
|
info.update(_shape_bounds_info(edge))
|
|
if curve_type == GeomAbs_Line:
|
|
line = curve.Line()
|
|
info["line_origin"] = _point_tuple(line.Location())
|
|
info["direction"] = _dir_tuple(line.Direction())
|
|
if curve_type == GeomAbs_Circle:
|
|
circle = curve.Circle()
|
|
info["center"] = _point_tuple(circle.Location())
|
|
info["axis"] = _dir_tuple(circle.Axis().Direction())
|
|
info["radius"] = circle.Radius()
|
|
info["diameter"] = circle.Radius() * 2.0
|
|
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
|