Files
pythonocc-step-editor/step_editor/model.py
T

1707 lines
79 KiB
Python
Raw Normal View History

from __future__ import annotations
import math
from pathlib import Path
from typing import Callable, Iterable
from OCC.Core.BRep import BRep_Tool
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse
from OCC.Core.BRepBndLib import brepbndlib
from OCC.Core.BOPAlgo import BOPAlgo_GlueFull
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeVertex, BRepBuilderAPI_Transform
from OCC.Core.BRepExtrema import BRepExtrema_DistShapeShape
from OCC.Core.BRepCheck import BRepCheck_Analyzer
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
from OCC.Core.BRepGProp import brepgprop
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism
from OCC.Core.Bnd import Bnd_Box
from OCC.Core.GeomAbs import (
GeomAbs_BSplineCurve,
GeomAbs_BSplineSurface,
GeomAbs_BezierCurve,
GeomAbs_BezierSurface,
GeomAbs_Circle,
GeomAbs_Cone,
GeomAbs_Cylinder,
GeomAbs_Ellipse,
GeomAbs_Hyperbola,
GeomAbs_Line,
GeomAbs_OffsetSurface,
GeomAbs_OtherCurve,
GeomAbs_OtherSurface,
GeomAbs_Parabola,
GeomAbs_Plane,
GeomAbs_Sphere,
GeomAbs_SurfaceOfExtrusion,
GeomAbs_SurfaceOfRevolution,
GeomAbs_Torus,
)
from OCC.Core.GProp import GProp_GProps
from OCC.Core.IFSelect import IFSelect_RetDone
from OCC.Core.Interface import Interface_Static
from OCC.Core.STEPCAFControl import STEPCAFControl_Reader
from OCC.Core.STEPControl import STEPControl_AsIs, STEPControl_Reader, STEPControl_Writer
from OCC.Core.ShapeFix import ShapeFix_Shape
from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
from OCC.Core.TDF import TDF_Label, TDF_LabelSequence
from OCC.Core.TDocStd import TDocStd_Document
from OCC.Core.TopAbs import (
TopAbs_EDGE,
TopAbs_EXTERNAL,
TopAbs_FACE,
TopAbs_FORWARD,
TopAbs_IN,
TopAbs_INTERNAL,
TopAbs_OUT,
TopAbs_REVERSED,
TopAbs_SOLID,
)
from OCC.Core.TopExp import TopExp_Explorer, topexp
from OCC.Core.TopLoc import TopLoc_Location
from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_IndexedMapOfShape
from OCC.Core.XCAFDoc import XCAFDoc_DocumentTool
from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES
from .export import ExportMixin
from .features import FeatureMixin
from .operations import OperationMixin
from .polydata import PolydataMixin
from .transforms import TransformMixin
from .geometry_utils import * # noqa: F403
from .model_types import PartNode, TopologyStats
from .step_io import (
_load_plain_step,
_load_with_xcaf,
_parse_product_names,
_prepare_shape_for_step_export,
_write_step,
)
class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, PolydataMixin):
def __init__(self, filename: Path, parts: list[PartNode], shape: TopoDS_Shape):
self.filename = filename
self.parts = parts
self.shape = shape
self.faces: list[TopoDS_Shape] = []
self.face_logical_ids: list[int] = []
self.face_part_ids: list[int] = []
self.face_solid_ids: list[int] = []
self.edges: list[TopoDS_Shape] = []
self.edge_part_ids: list[int] = []
self.edge_solid_ids: list[int] = []
self.solids: list[tuple[int, TopoDS_Shape]] = []
self._quick_face_info_cache: dict[int, dict[str, object]] = {}
self._face_info_cache: dict[int, dict[str, object]] = {}
2026-07-29 15:43:28 +08:00
self._feature_info_cache: dict[int, dict[str, object]] = {}
self._edge_info_cache: dict[int, dict[str, object]] = {}
self._face_edge_ids_cache: dict[int, list[int]] = {}
self._edge_face_ids_cache: dict[int, list[int]] = {}
self._same_domain_face_ids_cache: dict[int, list[int]] = {}
self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None
self._same_domain_internal_edge_ids_cache: set[int] | None = None
self._same_domain_duplicate_edge_ids_cache: set[int] | None = None
self._face_polydata_cache: dict[tuple[object, ...], object] = {}
self._edge_polydata_cache: dict[tuple[object, ...], object] = {}
self._polydata_cache_limit = 96
self._mesh_deflection: float | None = None
self.refresh_topology()
@classmethod
def load(cls, filename: str | Path) -> "StepModel":
path = Path(filename)
if not path.exists():
raise FileNotFoundError(path)
product_names = _parse_product_names(path)
parts, whole_shape = _load_with_xcaf(path, product_names)
if not parts or whole_shape.IsNull():
whole_shape = _load_plain_step(path)
fallback_name = product_names[0] if product_names else path.stem
parts = [PartNode(1, fallback_name, "part", whole_shape, path=fallback_name)]
return cls(path, parts, whole_shape)
def display_parts(self) -> list[PartNode]:
leaf_parts = [p for p in self.parts if p.kind == "part" and not p.shape.IsNull()]
if leaf_parts:
return leaf_parts
return [p for p in self.parts if not p.shape.IsNull()]
def stats(self) -> TopologyStats:
topo = TopologyExplorer(self.shape, ignore_orientation=True)
return TopologyStats(
parts=len(self.display_parts()),
solids=len(list(topo.solids())),
faces=len(list(topo.faces())),
edges=len(list(topo.edges())),
vertices=len(list(topo.vertices())),
)
def part_topology_stats(self, part_id: int) -> TopologyStats:
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
topo = TopologyExplorer(part.shape, ignore_orientation=True)
return TopologyStats(
parts=1,
solids=len(list(topo.solids())),
faces=len(list(topo.faces())),
edges=len(list(topo.edges())),
vertices=len(list(topo.vertices())),
)
def geometry_stats(self) -> dict[str, object]:
surface_props = GProp_GProps()
brepgprop.SurfaceProperties(self.shape, surface_props)
info: dict[str, object] = {
"surface_area": surface_props.Mass(),
"surface_center": _point_tuple(surface_props.CentreOfMass()),
}
info.update(_shape_bounds_info(self.shape))
info.update(_shape_volume_info(self.shape))
return info
def refresh_topology(self) -> None:
self.shape = _compound_from_shapes([p.shape for p in self.display_parts()])
self.faces.clear()
self.face_logical_ids.clear()
self.face_part_ids.clear()
self.face_solid_ids.clear()
self.edges.clear()
self.edge_part_ids.clear()
self.edge_solid_ids.clear()
self.solids.clear()
self._quick_face_info_cache.clear()
self._face_info_cache.clear()
2026-07-29 15:43:28 +08:00
self._feature_info_cache.clear()
self._edge_info_cache.clear()
self._face_edge_ids_cache.clear()
self._edge_face_ids_cache.clear()
self._same_domain_face_ids_cache.clear()
self._edge_duplicate_key_ids_cache = None
self._same_domain_internal_edge_ids_cache = None
self._same_domain_duplicate_edge_ids_cache = None
self._face_polydata_cache.clear()
self._edge_polydata_cache.clear()
self._mesh_deflection = None
solid_id = 0
for part in self.display_parts():
part_solids = _explore(part.shape, TopAbs_SOLID)
part_solid_edge_maps: list[tuple[int, TopTools_IndexedDataMapOfShapeListOfShape]] = []
part_solid_entries: list[tuple[int, TopoDS_Shape]] = []
if part_solids:
for solid in part_solids:
current_solid_id = solid_id
self.solids.append((part.id, solid))
part_solid_entries.append((current_solid_id, solid))
edge_map = TopTools_IndexedDataMapOfShapeListOfShape()
topexp.MapShapesAndAncestors(solid, TopAbs_EDGE, TopAbs_SOLID, edge_map)
part_solid_edge_maps.append((current_solid_id, edge_map))
solid_id += 1
part_edge_map = TopTools_IndexedMapOfShape()
topexp.MapShapes(part.shape, TopAbs_EDGE, part_edge_map)
part_edge_ids_by_index: dict[int, int] = {}
for local_edge_index in range(1, part_edge_map.Size() + 1):
edge = part_edge_map.FindKey(local_edge_index)
edge_id = len(self.edges)
part_edge_ids_by_index[local_edge_index] = edge_id
self.edges.append(edge)
self.edge_part_ids.append(part.id)
self.edge_solid_ids.append(_mapped_edge_solid_id(edge, part_solid_edge_maps))
self._edge_face_ids_cache[edge_id] = []
if part_solids:
for current_solid_id, solid in part_solid_entries:
for face in _explore(solid, TopAbs_FACE):
face_id = len(self.faces)
self.faces.append(face)
self.face_logical_ids.append(face_id)
self.face_part_ids.append(part.id)
self.face_solid_ids.append(current_solid_id)
self._cache_face_edge_links(face_id, face, part_edge_map, part_edge_ids_by_index)
else:
for face in _explore(part.shape, TopAbs_FACE):
face_id = len(self.faces)
self.faces.append(face)
self.face_logical_ids.append(face_id)
self.face_part_ids.append(part.id)
self.face_solid_ids.append(-1)
self._cache_face_edge_links(face_id, face, part_edge_map, part_edge_ids_by_index)
def _cache_face_edge_links(
self,
face_id: int,
face: TopoDS_Shape,
part_edge_map: TopTools_IndexedMapOfShape,
part_edge_ids_by_index: dict[int, int],
) -> None:
face_edge_ids: list[int] = []
face_edge_map = TopTools_IndexedMapOfShape()
topexp.MapShapes(face, TopAbs_EDGE, face_edge_map)
for local_face_edge_index in range(1, face_edge_map.Size() + 1):
local_part_edge_index = part_edge_map.FindIndex(face_edge_map.FindKey(local_face_edge_index))
edge_id = part_edge_ids_by_index.get(local_part_edge_index)
if edge_id is None:
continue
face_edge_ids.append(edge_id)
self._edge_face_ids_cache.setdefault(edge_id, []).append(face_id)
self._face_edge_ids_cache[face_id] = face_edge_ids
def part_by_id(self, part_id: int) -> PartNode | None:
return next((p for p in self.parts if p.id == part_id), None)
def snapshot(self) -> dict[object, object]:
data: dict[object, object] = {part.id: part.shape for part in self.parts}
data[SNAPSHOT_FACE_LOGICAL_IDS_KEY] = tuple(self.face_logical_ids)
return data
def restore_snapshot(self, snapshot: dict[object, object]) -> None:
for part in self.parts:
if part.id in snapshot:
part.shape = snapshot[part.id]
self.refresh_topology()
logical_ids = snapshot.get(SNAPSHOT_FACE_LOGICAL_IDS_KEY)
if isinstance(logical_ids, (list, tuple)) and len(logical_ids) == len(self.faces):
self.face_logical_ids = [int(item) for item in logical_ids]
self._quick_face_info_cache.clear()
self._face_info_cache.clear()
2026-07-29 15:43:28 +08:00
self._feature_info_cache.clear()
self._same_domain_face_ids_cache.clear()
def quick_face_info(self, face_id: int) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
full_info = self._face_info_cache.get(face_id)
if full_info is not None:
return dict(full_info)
cached = self._quick_face_info_cache.get(face_id)
if cached is not None:
return dict(cached)
face = self.faces[face_id]
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
surf = BRepAdaptor_Surface(face)
surface_type = surf.GetType()
surface_label = SURFACE_TYPES.get(surface_type, f"type {surface_type}")
info: dict[str, object] = {
"kind": "face",
"face_id": face_id,
"topological_face_id": face_id,
"logical_face_id": self.face_logical_id(face_id),
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"orientation": _orientation_name(face.Orientation()),
"surface": surface_label,
"area": props.Mass(),
"area_center": _point_tuple(props.CentreOfMass()),
"u_range": (surf.FirstUParameter(), surf.LastUParameter()),
"v_range": (surf.FirstVParameter(), surf.LastVParameter()),
"boundary_edges": len(self._face_boundary_edge_ids(face_id)),
"selection_info_mode": "quick",
"selection_info_note": "快速选择信息;同域面、端盖、底面等深层识别会在执行编辑计划或手动扫描时再计算。",
}
info.update(_shape_bounds_info(face))
if surface_type == GeomAbs_Plane:
plane = surf.Plane()
direction = plane.Axis().Direction()
push_pull_direction = self._plane_push_pull_direction(face_id, surf)
info["plane_origin"] = _point_tuple(plane.Location())
info["normal"] = _dir_tuple(direction)
info["oriented_normal"] = _oriented_dir_tuple(direction, face)
info["push_pull_outward_direction"] = push_pull_direction["outward_direction"]
info["push_pull_inward_direction"] = push_pull_direction["inward_direction"]
info["push_pull_plus_side"] = push_pull_direction["plus_side_state"]
info["push_pull_minus_side"] = push_pull_direction["minus_side_state"]
info["push_pull_confidence"] = push_pull_direction["confidence"]
info["push_pull_note"] = push_pull_direction["note"]
info["push_pull_status"] = "candidate"
info["feature_type"] = "可推拉平面候选"
info["feature_source_face_id"] = face_id
info["feature_highlight_face_ids"] = (face_id,)
info["feature_edit_actions"] = "推拉平面"
info.update(
self._local_face_plane_size_info(
face_id,
face=face,
surf=surf,
center=_tuple_or_none(info.get("area_center")),
)
)
elif surface_type == GeomAbs_Cylinder:
cyl = surf.Cylinder()
axis = cyl.Axis()
radius = cyl.Radius()
u_span = abs(surf.LastUParameter() - surf.FirstUParameter())
swept_area = max(radius * max(u_span, 1e-9), 1e-9)
classification = self._classify_cylindrical_face(face_id, surf, detailed=False)
info["radius"] = radius
info["diameter"] = radius * 2.0
info["axis_point"] = _point_tuple(axis.Location())
info["axis"] = _dir_tuple(axis.Direction())
info["angular_span"] = u_span
info["is_full_cylinder"] = u_span >= math.tau * 0.98
info["height_estimate"] = props.Mass() / swept_area
info["feature_guess"] = classification["feature_guess"]
info["confidence"] = classification["confidence"]
info["material_toward_axis"] = classification["toward_axis"]
info["material_away_axis"] = classification["away_axis"]
info["material_vote_summary"] = classification["vote_summary"]
info["material_sample_count"] = classification["sample_count"]
info["note"] = classification["note"]
info["feature_source_face_id"] = face_id
info["feature_highlight_face_ids"] = (face_id,)
if u_span < math.tau * 0.92:
info["slot_kind"] = "partial-cylindrical-groove"
info["slot_angular_span"] = u_span
info["slot_open_angle"] = max(math.tau - u_span, 0.0)
info["slot_chord_width_estimate"] = 2.0 * radius * math.sin(min(u_span, math.tau) * 0.5)
info["slot_sagitta_depth_estimate"] = radius * (1.0 - math.cos(min(u_span, math.tau) * 0.5))
info["slot_arc_length_estimate"] = radius * u_span
elif surface_type == GeomAbs_Cone:
cone = surf.Cone()
info["axis_point"] = _point_tuple(cone.Location())
info["axis"] = _dir_tuple(cone.Axis().Direction())
info["reference_radius"] = cone.RefRadius()
info["semi_angle"] = cone.SemiAngle()
info["feature_reference_radius"] = cone.RefRadius()
info["feature_reference_diameter"] = cone.RefRadius() * 2.0
elif surface_type == GeomAbs_Sphere:
sphere = surf.Sphere()
info["center"] = _point_tuple(sphere.Location())
info["radius"] = sphere.Radius()
info["diameter"] = sphere.Radius() * 2.0
info["feature_sphere_radius"] = sphere.Radius()
info["feature_sphere_diameter"] = sphere.Radius() * 2.0
elif surface_type == GeomAbs_Torus:
torus = surf.Torus()
info["center"] = _point_tuple(torus.Location())
info["axis"] = _dir_tuple(torus.Axis().Direction())
info["major_radius"] = torus.MajorRadius()
info["minor_radius"] = torus.MinorRadius()
info["feature_torus_major_radius"] = torus.MajorRadius()
info["feature_torus_minor_radius"] = torus.MinorRadius()
self._quick_face_info_cache[face_id] = dict(info)
return dict(info)
def face_info(self, face_id: int) -> dict[str, object]:
if face_id in self._face_info_cache:
return dict(self._face_info_cache[face_id])
face = self.faces[face_id]
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
surf = BRepAdaptor_Surface(face)
surface_type = surf.GetType()
boundary_edges = len(list(TopologyExplorer(face, ignore_orientation=True).edges()))
info: dict[str, object] = {
"kind": "face",
"face_id": face_id,
"topological_face_id": face_id,
"logical_face_id": self.face_logical_id(face_id),
"face_region_logical_id": self.face_region_logical_id(face_id),
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"orientation": _orientation_name(face.Orientation()),
"surface": SURFACE_TYPES.get(surface_type, f"type {surface_type}"),
"area": props.Mass(),
"area_center": _point_tuple(props.CentreOfMass()),
"u_range": (surf.FirstUParameter(), surf.LastUParameter()),
"v_range": (surf.FirstVParameter(), surf.LastVParameter()),
"boundary_edges": boundary_edges,
}
info.update(_shape_bounds_info(face))
if surface_type == GeomAbs_Plane:
plane = surf.Plane()
direction = plane.Axis().Direction()
push_pull_direction = self._plane_push_pull_direction(face_id, surf)
info["plane_origin"] = _point_tuple(plane.Location())
info["normal"] = _dir_tuple(direction)
info["oriented_normal"] = _oriented_dir_tuple(direction, face)
info["push_pull_outward_direction"] = push_pull_direction["outward_direction"]
info["push_pull_inward_direction"] = push_pull_direction["inward_direction"]
info["push_pull_plus_side"] = push_pull_direction["plus_side_state"]
info["push_pull_minus_side"] = push_pull_direction["minus_side_state"]
info["push_pull_confidence"] = push_pull_direction["confidence"]
info["push_pull_note"] = push_pull_direction["note"]
info.update(
self._local_face_plane_size_info(
face_id,
face=face,
surf=surf,
center=_tuple_or_none(info.get("area_center")),
)
)
elif surface_type == GeomAbs_Cylinder:
cyl = surf.Cylinder()
axis = cyl.Axis()
radius = cyl.Radius()
u_span = abs(surf.LastUParameter() - surf.FirstUParameter())
swept_area = max(radius * max(u_span, 1e-9), 1e-9)
classification = self._classify_cylindrical_face(face_id, surf, detailed=True)
info["radius"] = cyl.Radius()
info["diameter"] = cyl.Radius() * 2.0
info["axis_point"] = _point_tuple(axis.Location())
info["axis"] = _dir_tuple(axis.Direction())
info["angular_span"] = u_span
info["is_full_cylinder"] = u_span >= math.tau * 0.98
info["height_estimate"] = props.Mass() / swept_area
info["feature_guess"] = classification["feature_guess"]
info["confidence"] = classification["confidence"]
info["material_toward_axis"] = classification["toward_axis"]
info["material_away_axis"] = classification["away_axis"]
info["material_vote_summary"] = classification["vote_summary"]
info["material_sample_count"] = classification["sample_count"]
info["note"] = classification["note"]
info.update(self._cylinder_end_opening_info(face_id, surf))
info.update(_cylinder_resize_readiness(info))
info.update(_cylinder_boss_resize_readiness(info))
info.update(_cylinder_depth_readiness(info))
info.update(_cylinder_suppress_readiness(info))
elif surface_type == GeomAbs_Cone:
cone = surf.Cone()
info["axis_point"] = _point_tuple(cone.Location())
info["axis"] = _dir_tuple(cone.Axis().Direction())
info["reference_radius"] = cone.RefRadius()
info["semi_angle"] = cone.SemiAngle()
elif surface_type == GeomAbs_Sphere:
sphere = surf.Sphere()
info["center"] = _point_tuple(sphere.Location())
info["radius"] = sphere.Radius()
info["diameter"] = sphere.Radius() * 2.0
elif surface_type == GeomAbs_Torus:
torus = surf.Torus()
info["center"] = _point_tuple(torus.Location())
info["axis"] = _dir_tuple(torus.Axis().Direction())
info["major_radius"] = torus.MajorRadius()
info["minor_radius"] = torus.MinorRadius()
self._face_info_cache[face_id] = dict(info)
return dict(info)
2026-07-29 15:43:28 +08:00
def face_surface_kind(self, face_id: int) -> str:
if face_id < 0 or face_id >= len(self.faces):
return ""
cached = self._face_info_cache.get(face_id)
if cached is not None and cached.get("surface") is not None:
return str(cached.get("surface"))
try:
surf = BRepAdaptor_Surface(self.faces[face_id])
surface_type = surf.GetType()
except Exception:
return ""
return SURFACE_TYPES.get(surface_type, f"type {surface_type}")
def face_cylinder_diameter(self, face_id: int) -> float | None:
if face_id < 0 or face_id >= len(self.faces):
return None
cached = self._face_info_cache.get(face_id)
if cached is not None and cached.get("diameter") is not None and cached.get("surface") == "cylinder":
try:
return float(cached["diameter"])
except (TypeError, ValueError):
pass
try:
surf = BRepAdaptor_Surface(self.faces[face_id])
if surf.GetType() != GeomAbs_Cylinder:
return None
return float(surf.Cylinder().Radius()) * 2.0
except Exception:
return None
def cached_feature_info(self, face_id: int) -> dict[str, object] | None:
cached = self._feature_info_cache.get(face_id)
return dict(cached) if cached is not None else None
def feature_info(self, face_id: int) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
2026-07-29 15:43:28 +08:00
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":
2026-07-29 15:43:28 +08:00
result = self._cylindrical_feature_info(face_id, info)
elif surface == "plane":
result = self._planar_feature_info(face_id, info)
elif surface == "cone":
result = self._conical_feature_info(face_id, info)
elif surface == "sphere":
result = self._spherical_feature_info(face_id, info)
elif surface == "torus":
result = self._toroidal_feature_info(face_id, info)
else:
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
result = dict(info)
result.update(
{
"kind": "feature",
"feature_type": "暂不支持的局部曲面候选",
"feature_source_face_id": face_id,
"feature_face_ids": (face_id,),
"feature_highlight_face_ids": (face_id,),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_edit_actions": "当前只读;复杂曲面局部面积修改暂未开放",
"feature_mode": (
"Feature 模式会把选中的Face解释为局部几何特征候选;"
"复杂曲面局部面积修改需要更明确的边界/约束重建,当前不会把面积伪装成直接可改参数。"
),
}
)
self._feature_info_cache[face_id] = dict(result)
return dict(result)
def _toroidal_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
major_radius = float(info.get("major_radius", 0.0) or 0.0)
minor_radius = float(info.get("minor_radius", 0.0) or 0.0)
result = dict(info)
result.update(
{
"kind": "feature",
"feature_type": "环面候选",
"feature_source_face_id": face_id,
"feature_face_ids": (face_id,),
"feature_highlight_face_ids": (face_id,),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_edit_actions": "修改环面主半径/小半径",
"feature_mode": (
"这是从 STEP/B-Rep 环面直接识别出的几何候选;当前修改会围绕环面中心缩放所属零件/Solid,"
"主半径和小半径会等比例变化,不是 CAD 历史里的管径或圆角参数。"
),
"feature_torus_major_radius": major_radius,
"feature_torus_minor_radius": minor_radius,
}
)
return result
def _spherical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
2026-07-29 15:43:28 +08:00
radius = float(info.get("radius", 0.0) or 0.0)
result = dict(info)
result.update(
{
"kind": "feature",
2026-07-29 15:43:28 +08:00
"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),
2026-07-29 15:43:28 +08:00
"feature_edit_actions": "修改球面半径/直径",
"feature_mode": (
"这是从 STEP/B-Rep 球面直接识别出的几何候选;修改会围绕球心缩放所属零件/Solid,"
"不是 CAD 历史里的球面或圆角参数。"
),
"feature_sphere_radius": radius,
"feature_sphere_diameter": radius * 2.0 if radius > 0 else "",
}
)
return result
def _conical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
reference_radius = float(info.get("reference_radius", 0.0) or 0.0)
result = dict(info)
result.update(
{
"kind": "feature",
"feature_type": "圆锥面候选",
"feature_source_face_id": face_id,
"feature_face_ids": (face_id,),
"feature_highlight_face_ids": (face_id,),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_reference_radius": reference_radius,
"feature_reference_diameter": reference_radius * 2.0 if reference_radius > 0 else "",
"feature_edit_actions": "修改圆锥参考半径/直径",
"feature_mode": (
"这是从 STEP/B-Rep 圆锥面直接识别出的几何候选;修改会围绕圆锥轴做径向缩放,"
"不是 CAD 历史里的锥孔或倒角参数。"
),
}
)
return result
def _planar_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
coplanar_face_ids = self._connected_coplanar_planar_face_ids(face_id)
boundary_edge_ids = self._region_boundary_edge_ids(coplanar_face_ids)
shell_info = self._planar_shell_region_info(face_id, coplanar_face_ids, info)
if len(coplanar_face_ids) > 1:
scope_note = f"已检测到 {len(coplanar_face_ids)} 个共面且相接/重叠的 face,推拉时会作为同一片平面区域处理。"
else:
scope_note = "当前 face 没有检测到可一起推拉的共面相接/重叠邻居。"
edit_actions = "推拉平面"
if shell_info.get("shell_region_status") == "candidate":
edit_actions += ";调整薄壁/壳体厚度"
result = dict(info)
result.update(
{
"kind": "feature",
"feature_type": "可推拉平面候选",
"feature_source_face_id": face_id,
"feature_face_ids": tuple(coplanar_face_ids),
"feature_highlight_face_ids": tuple(coplanar_face_ids),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_adjacent_face_ids": tuple(
sorted(set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - set(coplanar_face_ids))
),
"push_pull_scope_face_ids": tuple(coplanar_face_ids),
"push_pull_scope_face_count": len(coplanar_face_ids),
"push_pull_scope_note": scope_note,
"feature_edit_actions": edit_actions,
"feature_mode": "这是从 B-Rep 几何推断出的平面编辑候选,不是 CAD 历史特征。",
**shell_info,
}
)
return result
def _planar_shell_region_info(
self,
face_id: int,
coplanar_face_ids: Iterable[int],
info: dict[str, object],
) -> dict[str, object]:
try:
source_surf = BRepAdaptor_Surface(self.faces[face_id])
except Exception:
return {
"shell_region_status": "not-detected",
"shell_region_note": "无法读取当前平面,不能估算薄壁/壳体区域。",
}
if source_surf.GetType() != GeomAbs_Plane:
return {}
plane = source_surf.Plane()
normal = plane.Axis().Direction()
u_dir, v_dir = _plane_basis_dirs(normal)
valid_region_ids = sorted({int(item) for item in coplanar_face_ids if 0 <= int(item) < len(self.faces)})
if not valid_region_ids:
valid_region_ids = [face_id]
try:
region_shape = _compound_from_shapes(self.faces[item] for item in valid_region_ids)
source_interval = _shape_plane_interval(region_shape, plane.Location(), u_dir, v_dir)
except Exception:
source_interval = _shape_plane_interval(self.faces[face_id], plane.Location(), u_dir, v_dir)
if source_interval is None:
return {
"shell_region_status": "not-detected",
"shell_region_note": "当前平面区域缺少稳定投影范围,不能估算薄壁/壳体厚度。",
}
def interval_length(interval: tuple[float, float]) -> float:
return max(float(max(interval) - min(interval)), 0.0)
def overlap_length(left: tuple[float, float], right: tuple[float, float]) -> float:
left_min, left_max = min(left), max(left)
right_min, right_max = min(right), max(right)
return max(min(left_max, right_max) - max(left_min, right_min), 0.0)
source_area = max(interval_length(source_interval[0]) * interval_length(source_interval[1]), 1e-12)
diagonal = max(_shape_diagonal(self.shape), 1.0)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
source_solid_id = self.face_solid_ids[face_id]
region_id_set = set(valid_region_ids)
best: dict[str, object] | None = None
best_score: tuple[float, float] | None = None
for candidate_id, face in enumerate(self.faces):
if candidate_id in region_id_set:
continue
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
try:
candidate_surf = BRepAdaptor_Surface(face)
except Exception:
continue
if candidate_surf.GetType() != GeomAbs_Plane:
continue
candidate_plane = candidate_surf.Plane()
normal_dot = _direction_dot(normal, candidate_plane.Axis().Direction())
if abs(normal_dot) < 0.985:
continue
thickness = abs(_axis_parameter(plane.Location(), normal, candidate_plane.Location()))
if thickness <= tolerance:
continue
interval = _shape_plane_interval(face, plane.Location(), u_dir, v_dir)
if interval is None:
continue
overlap_area = overlap_length(source_interval[0], interval[0]) * overlap_length(source_interval[1], interval[1])
overlap_ratio = overlap_area / source_area
if overlap_ratio <= 0.02:
continue
score = (thickness, -overlap_ratio)
if best_score is None or score < best_score:
best_score = score
best = {
"shell_opposite_face_id": candidate_id,
"shell_thickness_estimate": thickness,
"shell_signed_thickness": _axis_parameter(plane.Location(), normal, candidate_plane.Location()),
"shell_overlap_ratio_estimate": overlap_ratio,
"shell_opposite_normal_dot": normal_dot,
}
if best is None:
return {
"shell_region_status": "not-detected",
"shell_region_note": "未找到与当前平面投影重叠的相对平面;暂不能估算局部壳体/薄壁厚度。",
}
thickness = float(best["shell_thickness_estimate"])
overlap_ratio = float(best["shell_overlap_ratio_estimate"])
thin_ratio = thickness / diagonal
if overlap_ratio >= 0.55 and thin_ratio <= 0.08:
confidence = "high"
elif overlap_ratio >= 0.25 and thin_ratio <= 0.18:
confidence = "medium"
else:
confidence = "low"
kind = "thin-wall-opposite-plane-candidate" if thin_ratio <= 0.18 else "opposite-plane-region-candidate"
return {
"shell_region_kind": kind,
"shell_region_status": "candidate",
"shell_confidence": confidence,
"shell_source_face_ids": tuple(valid_region_ids),
**best,
"shell_note": (
"通过同一 solid 内投影重叠的相对平面估算薄壁/壳体厚度;"
"这是 B-Rep 几何近似,不等同于原 CAD 壳命令参数。"
),
}
def _cylindrical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
side_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id]
surf = BRepAdaptor_Surface(self.faces[face_id])
axis_range = self._cylindrical_axis_range(face_id, surf, side_face_ids)
side_face_set = set(side_face_ids)
boundary_edge_ids = self._region_boundary_edge_ids(side_face_ids)
adjacent_face_ids = sorted(set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - side_face_set)
domain_info = dict(info)
domain_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
domain_info["height_estimate"] = axis_range["span"]
end_faces = self._cylindrical_end_face_groups(face_id, adjacent_face_ids, domain_info)
end_face_ids = end_faces["end_face_ids"]
bottom_face_ids = end_faces["bottom_face_ids"]
opening_face_ids = end_faces["opening_face_ids"]
slot_info = self._cylindrical_slot_info(face_id, adjacent_face_ids, end_face_ids, domain_info)
fillet_info = self._cylindrical_existing_fillet_info(face_id, adjacent_face_ids, end_face_ids, domain_info)
guess = str(info.get("feature_guess", "cylindrical face"))
angular_span = float(info.get("angular_span", 0.0))
if guess == "hole/groove candidate":
if angular_span < math.tau * 0.92:
feature_type = "槽/半孔候选"
2026-07-29 15:43:28 +08:00
edit_actions = "调整圆柱孔径;调整槽/半孔宽度;调整槽/半孔深度;调整槽/半孔圆弧长度;调整槽/半孔圆弧角度;调整槽孔总长度"
else:
feature_type = "圆柱孔候选"
2026-07-29 15:43:28 +08:00
edit_actions = "调整圆柱孔径"
if info.get("cylinder_end_type") == "blind" and bottom_face_ids:
2026-07-29 15:43:28 +08:00
edit_actions += ";调整盲孔/盲槽深度"
else:
2026-07-29 15:43:28 +08:00
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 = "凸台/外圆候选"
2026-07-29 15:43:28 +08:00
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] = []
2026-07-29 15:43:28 +08:00
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):
2026-07-29 15:43:28 +08:00
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 = "没有在圆柱边界附近找到平面端面。"
2026-07-29 15:43:28 +08:00
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 = "已找到端面候选,但端部采样显示这些端面更像开口附近的相邻面。"
2026-07-29 15:43:28 +08:00
if cap_scan_skipped:
note += " 当前模型 Face 数较多,已跳过全模型底面扫描以避免选择时卡顿。"
return {
"end_face_ids": end_face_ids,
"start_end_face_ids": sorted(set(start_end_face_ids)),
"end_end_face_ids": sorted(set(end_end_face_ids)),
"bottom_face_ids": bottom_face_ids,
"opening_face_ids": opening_face_ids,
"bottom_confidence": bottom_confidence if bottom_face_ids else "none",
"bottom_detection": bottom_detection if bottom_face_ids else "none",
"bottom_note": note,
}
def _axis_end_match_for_planar_face(
self,
face_id: int,
axis_point: gp_Pnt,
axis_dir: gp_Dir,
v_min: float,
v_max: float,
tolerance: float,
radial_tolerance: float | None,
) -> str | None:
surf = BRepAdaptor_Surface(self.faces[face_id])
if surf.GetType() != GeomAbs_Plane:
return None
normal = surf.Plane().Axis().Direction()
if abs(_direction_dot(normal, axis_dir)) < 0.65:
return None
if radial_tolerance is not None:
center = _surface_center(self.faces[face_id])
if _point_axis_distance(axis_point, axis_dir, center) > radial_tolerance:
return None
parameters = _shape_axis_parameters(self.faces[face_id], axis_point, axis_dir)
if not parameters:
return None
start_distance = min(abs(parameter - v_min) for parameter in parameters)
end_distance = min(abs(parameter - v_max) for parameter in parameters)
if min(start_distance, end_distance) > tolerance:
return None
return "start" if start_distance <= end_distance else "end"
def _axis_cap_face_candidates(
self,
face_id: int,
axis_point: gp_Pnt,
axis_dir: gp_Dir,
v_min: float,
v_max: float,
radius: float,
span: float,
adjacent_face_ids: set[int],
) -> dict[str, list[int]]:
source_solid_id = self.face_solid_ids[face_id]
tolerance = max(span * 0.12, radius * 0.35, 0.08)
radial_tolerance = max(radius * 1.2, tolerance)
start: list[int] = []
end: list[int] = []
for candidate_id in range(len(self.faces)):
if candidate_id == face_id or candidate_id in adjacent_face_ids:
continue
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
match = self._axis_end_match_for_planar_face(
candidate_id,
axis_point,
axis_dir,
v_min,
v_max,
tolerance,
radial_tolerance=radial_tolerance,
)
if match == "start":
start.append(candidate_id)
elif match == "end":
end.append(candidate_id)
return {"start": sorted(set(start)), "end": sorted(set(end))}
def _bottom_face_axis_parameter(
self,
bottom_face_ids: Iterable[int],
axis_point: gp_Pnt,
axis_dir: gp_Dir,
expected_parameter: float,
) -> float | None:
candidates: list[float] = []
for bottom_face_id in bottom_face_ids:
if bottom_face_id < 0 or bottom_face_id >= len(self.faces):
continue
parameters = _shape_axis_parameters(self.faces[bottom_face_id], axis_point, axis_dir)
if not parameters:
continue
candidates.append(sum(parameters) / len(parameters))
if not candidates:
return None
return min(candidates, key=lambda parameter: abs(parameter - expected_parameter))
def _face_boundary_edge_ids(self, face_id: int) -> list[int]:
if face_id in self._face_edge_ids_cache:
return list(self._face_edge_ids_cache[face_id])
if face_id < 0 or face_id >= len(self.faces):
return []
face_edges = list(TopologyExplorer(self.faces[face_id], ignore_orientation=True).edges())
edge_ids: list[int] = []
for edge_id, edge in enumerate(self.edges):
if any(_same_shape(edge, face_edge) for face_edge in face_edges):
edge_ids.append(edge_id)
self._face_edge_ids_cache[face_id] = list(edge_ids)
return edge_ids
def face_boundary_edge_ids(self, face_id: int) -> list[int]:
if face_id < 0 or face_id >= len(self.faces):
return []
return self._face_boundary_edge_ids(face_id)
def face_logical_id(self, face_id: int) -> int:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
if face_id < len(self.face_logical_ids):
return int(self.face_logical_ids[face_id])
return face_id
def face_region_logical_id(self, face_id: int) -> int:
face_ids = self.connected_same_domain_face_ids(face_id) or [face_id]
return min(self.face_logical_id(item) for item in face_ids if 0 <= item < len(self.faces))
def face_ids_for_logical_id(self, logical_id: int) -> list[int]:
logical_id = int(logical_id)
return [face_id for face_id in range(len(self.faces)) if self.face_logical_id(face_id) == logical_id]
def resolve_face_selection_id(self, face_or_logical_id: int) -> int | None:
logical_matches = self.face_ids_for_logical_id(int(face_or_logical_id))
if logical_matches:
return logical_matches[0]
if 0 <= int(face_or_logical_id) < len(self.faces):
return int(face_or_logical_id)
return None
def assign_logical_face_region(self, logical_id: int, face_ids: Iterable[int]) -> None:
valid_face_ids = sorted({int(face_id) for face_id in face_ids if 0 <= int(face_id) < len(self.faces)})
if not valid_face_ids:
return
for face_id in valid_face_ids:
self.face_logical_ids[face_id] = int(logical_id)
self._quick_face_info_cache.pop(face_id, None)
self._face_info_cache.pop(face_id, None)
2026-07-29 15:43:28 +08:00
self._feature_info_cache.pop(face_id, None)
def nearest_edge_id_to_point(
self,
edge_ids: Iterable[int],
point: tuple[float, float, float] | None,
) -> int | None:
valid_edge_ids = [int(edge_id) for edge_id in edge_ids if 0 <= int(edge_id) < len(self.edges)]
if not valid_edge_ids:
return None
if point is None:
return valid_edge_ids[0]
px, py, pz = (float(point[0]), float(point[1]), float(point[2]))
best_edge_id: int | None = None
best_distance = math.inf
for edge_id in valid_edge_ids:
try:
samples = discretize_edge(self.edges[edge_id], 0.35)
except Exception:
samples = []
if len(samples) < 2:
try:
curve = BRepAdaptor_Curve(self.edges[edge_id])
samples = [
_point_tuple(curve.Value(curve.FirstParameter())),
_point_tuple(curve.Value(curve.LastParameter())),
]
except Exception:
samples = []
if not samples:
continue
sample_points = [(float(coords[0]), float(coords[1]), float(coords[2])) for coords in samples]
if len(sample_points) == 1:
distance = _point_distance_sq((px, py, pz), sample_points[0])
else:
distance = min(
_point_segment_distance_sq((px, py, pz), start, end)
for start, end in zip(sample_points, sample_points[1:])
)
if distance < best_distance:
best_distance = distance
best_edge_id = edge_id
return best_edge_id if best_edge_id is not None else valid_edge_ids[0]
def nearest_face_id_to_point(
self,
face_ids: Iterable[int],
point: tuple[float, float, float] | None,
) -> int | None:
valid_face_ids = [int(face_id) for face_id in face_ids if 0 <= int(face_id) < len(self.faces)]
if not valid_face_ids:
return None
if point is None:
return valid_face_ids[0]
point_vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(float(point[0]), float(point[1]), float(point[2]))).Vertex()
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
return best_face_id if best_face_id is not None else exact_face_ids[0]
def _face_bounds_distance_sq(self, face_id: int, point: tuple[float, float, float]) -> float:
px, py, pz = (float(point[0]), float(point[1]), float(point[2]))
try:
xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(self.faces[face_id])
dx = max(xmin - px, 0.0, px - xmax)
dy = max(ymin - py, 0.0, py - ymax)
dz = max(zmin - pz, 0.0, pz - zmax)
return dx * dx + dy * dy + dz * dz
except Exception:
try:
center = _surface_center(self.faces[face_id])
return _point_distance_sq((px, py, pz), _point_tuple(center))
except Exception:
return math.inf
def _adjacent_face_ids_for_edges(self, edge_ids: list[int], face_id: int) -> list[int]:
if not edge_ids:
return []
source_solid_id = self.face_solid_ids[face_id]
adjacent: set[int] = set()
for edge_id in edge_ids:
if edge_id < 0 or edge_id >= len(self.edges):
continue
for candidate_id in self._edge_adjacent_face_ids(edge_id):
if candidate_id == face_id:
continue
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
adjacent.add(candidate_id)
return sorted(adjacent)
def _connected_coplanar_planar_face_ids(self, face_id: int) -> list[int]:
if face_id < 0 or face_id >= len(self.faces):
return []
source_surf = BRepAdaptor_Surface(self.faces[face_id])
if source_surf.GetType() != GeomAbs_Plane:
return [face_id]
source_solid_id = self.face_solid_ids[face_id]
tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3)
interval_tolerance = max(tolerance * 20.0, _shape_diagonal(self.shape) * 1e-6, 1e-4)
plane = source_surf.Plane()
axis_point = plane.Location()
u_dir, v_dir = _plane_basis_dirs(plane.Axis().Direction())
candidates: dict[int, tuple[tuple[float, float], tuple[float, float]]] = {}
for candidate_id, face in enumerate(self.faces):
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
candidate_surf = BRepAdaptor_Surface(face)
if not _surfaces_are_coplanar(source_surf, candidate_surf, tolerance):
continue
interval = _shape_plane_interval(face, axis_point, u_dir, v_dir)
if interval is not None:
candidates[candidate_id] = interval
if face_id in candidates:
visited = {face_id}
queue = [face_id]
while queue:
current_id = queue.pop(0)
current_interval = candidates[current_id]
for candidate_id, candidate_interval in candidates.items():
if candidate_id in visited:
continue
if _plane_intervals_touch_or_overlap(
current_interval,
candidate_interval,
interval_tolerance,
):
visited.add(candidate_id)
queue.append(candidate_id)
return sorted(visited)
visited = {face_id}
queue = [face_id]
while queue:
current_id = queue.pop(0)
for adjacent_id in self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(current_id), current_id):
if adjacent_id in visited:
continue
if source_solid_id >= 0 and self.face_solid_ids[adjacent_id] != source_solid_id:
continue
candidate_surf = BRepAdaptor_Surface(self.faces[adjacent_id])
if _surfaces_are_coplanar(source_surf, candidate_surf, tolerance):
visited.add(adjacent_id)
queue.append(adjacent_id)
return sorted(visited)
def connected_same_domain_face_ids(self, face_id: int) -> list[int]:
if face_id < 0 or face_id >= len(self.faces):
return []
if face_id in self._same_domain_face_ids_cache:
return list(self._same_domain_face_ids_cache[face_id])
source_surf = BRepAdaptor_Surface(self.faces[face_id])
surface_type = source_surf.GetType()
if surface_type == GeomAbs_Plane:
face_ids = self._connected_coplanar_planar_face_ids(face_id)
elif surface_type == GeomAbs_Cylinder:
face_ids = self._connected_cocylindrical_face_ids(face_id)
else:
face_ids = [face_id]
face_ids = sorted(set(face_ids or [face_id]))
for item in face_ids:
self._same_domain_face_ids_cache[item] = list(face_ids)
return list(face_ids)
def _connected_cocylindrical_face_ids(self, face_id: int) -> list[int]:
if face_id < 0 or face_id >= len(self.faces):
return []
source_solid_id = self.face_solid_ids[face_id]
source_surf = BRepAdaptor_Surface(self.faces[face_id])
if source_surf.GetType() != GeomAbs_Cylinder:
return [face_id]
cylinder = source_surf.Cylinder()
axis = cylinder.Axis()
axis_point = axis.Location()
axis_dir = axis.Direction()
radius = max(float(cylinder.Radius()), 0.0)
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
interval_tolerance = max(tolerance * 50.0, diagonal * 1e-5, radius * 1e-4, 1e-3)
source_interval = _shape_axis_interval(self.faces[face_id], axis_point, axis_dir)
candidates: dict[int, tuple[float, float]] = {}
for candidate_id, face in enumerate(self.faces):
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
candidate_surf = BRepAdaptor_Surface(face)
if not _surfaces_are_cocylindrical(source_surf, candidate_surf, tolerance):
continue
interval = _shape_axis_interval(face, axis_point, axis_dir)
if interval is not None:
candidates[candidate_id] = interval
if source_interval is not None and face_id in candidates:
visited = {face_id}
queue = [face_id]
while queue:
current_id = queue.pop(0)
current_interval = candidates[current_id]
for candidate_id, candidate_interval in candidates.items():
if candidate_id in visited:
continue
if _intervals_touch_or_overlap(
current_interval,
candidate_interval,
interval_tolerance,
):
visited.add(candidate_id)
queue.append(candidate_id)
return sorted(visited)
visited = {face_id}
queue = [face_id]
while queue:
current_id = queue.pop(0)
for adjacent_id in self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(current_id), current_id):
if adjacent_id in visited:
continue
if source_solid_id >= 0 and self.face_solid_ids[adjacent_id] != source_solid_id:
continue
candidate_surf = BRepAdaptor_Surface(self.faces[adjacent_id])
if _surfaces_are_cocylindrical(source_surf, candidate_surf, tolerance):
visited.add(adjacent_id)
queue.append(adjacent_id)
return sorted(visited)
def _cylindrical_axis_range(
self,
face_id: int,
surf: BRepAdaptor_Surface | None = None,
face_ids: Iterable[int] | None = None,
) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
surf = surf or BRepAdaptor_Surface(self.faces[face_id])
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Selected face is not cylindrical.")
cyl = surf.Cylinder()
axis = cyl.Axis()
axis_point = axis.Location()
axis_dir = axis.Direction()
source_v_min = min(float(surf.FirstVParameter()), float(surf.LastVParameter()))
source_v_max = max(float(surf.FirstVParameter()), float(surf.LastVParameter()))
if face_ids is None:
domain_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id]
else:
domain_face_ids = sorted({int(item) for item in face_ids if 0 <= int(item) < len(self.faces)})
if face_id not in domain_face_ids:
domain_face_ids.append(face_id)
domain_face_ids.sort()
intervals: list[tuple[float, float]] = []
tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3)
for item in domain_face_ids:
item_surf = BRepAdaptor_Surface(self.faces[item])
if item_surf.GetType() != GeomAbs_Cylinder:
continue
if not _surfaces_are_cocylindrical(item_surf, surf, tolerance):
continue
interval = _shape_axis_interval(self.faces[item], axis_point, axis_dir)
if interval is not None:
intervals.append(interval)
if intervals and len(domain_face_ids) > 1:
v_min = min(interval[0] for interval in intervals)
v_max = max(interval[1] for interval in intervals)
range_source = "same-domain-cylinder-faces"
else:
v_min = source_v_min
v_max = source_v_max
range_source = "selected-face-v-range"
return {
"axis_point": axis_point,
"axis_direction": axis_dir,
"v_min": v_min,
"v_max": v_max,
"span": max(v_max - v_min, 0.0),
"source_v_min": source_v_min,
"source_v_max": source_v_max,
"same_domain_face_ids": tuple(domain_face_ids),
"same_domain_face_count": len(domain_face_ids),
"range_source": range_source,
}
def _region_boundary_edge_ids(self, face_ids: Iterable[int]) -> list[int]:
counts: dict[int, int] = {}
for face_id in face_ids:
for edge_id in self._face_boundary_edge_ids(face_id):
counts[edge_id] = counts.get(edge_id, 0) + 1
return sorted(edge_id for edge_id, count in counts.items() if count == 1)
def _push_pull_profile_shape(self, face_ids: Iterable[int]) -> TopoDS_Shape:
profile_faces = [self.faces[face_id] for face_id in face_ids if 0 <= face_id < len(self.faces)]
if not profile_faces:
raise ValueError("No planar faces were found for push/pull.")
return _unify_same_domain_shape(_compound_from_shapes(profile_faces))
def _face_region_mapping_specs(self, face_ids: Iterable[int]) -> list[dict[str, object]]:
specs: list[dict[str, object]] = []
seen_logical_ids: set[int] = set()
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
for face_id in sorted({int(item) for item in face_ids if 0 <= int(item) < len(self.faces)}):
logical_id = self.face_region_logical_id(face_id)
if logical_id in seen_logical_ids:
continue
seen_logical_ids.add(logical_id)
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() == GeomAbs_Plane:
plane = surf.Plane()
normal = plane.Axis().Direction()
u_dir, v_dir = _plane_basis_dirs(normal)
interval = _shape_plane_interval(face, plane.Location(), u_dir, v_dir)
if interval is None:
continue
specs.append(
{
"surface": "plane",
"logical_id": logical_id,
"part_id": self.face_part_ids[face_id],
"point": _point_tuple(plane.Location()),
"normal": _dir_tuple(normal),
"u_dir": _dir_tuple(u_dir),
"v_dir": _dir_tuple(v_dir),
"interval": interval,
"tolerance": tolerance,
}
)
elif surf.GetType() == GeomAbs_Cylinder:
cylinder = surf.Cylinder()
axis = cylinder.Axis()
interval = _shape_axis_interval(face, axis.Location(), axis.Direction())
if interval is None:
continue
specs.append(
{
"surface": "cylinder",
"logical_id": logical_id,
"part_id": self.face_part_ids[face_id],
"axis_point": _point_tuple(axis.Location()),
"axis_direction": _dir_tuple(axis.Direction()),
"radius": float(cylinder.Radius()),
"interval": interval,
"tolerance": tolerance,
}
)
return specs
def _apply_face_region_mapping_specs(self, specs: Iterable[dict[str, object]]) -> None:
for spec in specs:
logical_id = int(spec.get("logical_id", -1))
if logical_id < 0:
continue
seed_face_ids = self._matching_face_ids_for_region_spec(spec)
if not seed_face_ids:
continue
region_ids: set[int] = set()
for seed_face_id in seed_face_ids:
region_ids.update(self.connected_same_domain_face_ids(seed_face_id) or [seed_face_id])
self.assign_logical_face_region(logical_id, region_ids)
def _matching_face_ids_for_region_spec(self, spec: dict[str, object]) -> list[int]:
surface = str(spec.get("surface", ""))
part_id = int(spec.get("part_id", -1))
tolerance_value = spec.get("tolerance")
if tolerance_value is None:
tolerance_value = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3)
tolerance = float(tolerance_value)
matches: list[int] = []
for face_id, face in enumerate(self.faces):
if part_id >= 0 and self.face_part_ids[face_id] != part_id:
continue
surf = BRepAdaptor_Surface(face)
if surface == "plane":
if not _surface_matches_plane_spec(surf, spec, tolerance):
continue
plane = surf.Plane()
u_dir = gp_Dir(*spec["u_dir"])
v_dir = gp_Dir(*spec["v_dir"])
interval = _shape_plane_interval(face, gp_Pnt(*spec["point"]), u_dir, v_dir)
if interval is not None and _plane_intervals_touch_or_overlap(interval, spec["interval"], max(tolerance * 20.0, 1e-4)):
matches.append(face_id)
elif surface == "cylinder":
if not _surface_matches_cylinder_spec(surf, spec, tolerance):
continue
interval = _shape_axis_interval(face, gp_Pnt(*spec["axis_point"]), gp_Dir(*spec["axis_direction"]))
interval_tolerance = max(tolerance * 50.0, _shape_diagonal(self.shape) * 1e-5, float(spec.get("radius", 0.0)) * 1e-4, 1e-3)
if interval is not None and _intervals_touch_or_overlap(interval, spec["interval"], interval_tolerance):
matches.append(face_id)
return matches
def edge_info(self, edge_id: int) -> dict[str, object]:
if edge_id in self._edge_info_cache:
return dict(self._edge_info_cache[edge_id])
edge = self.edges[edge_id]
props = GProp_GProps()
brepgprop.LinearProperties(edge, props)
curve = BRepAdaptor_Curve(edge)
curve_type = curve.GetType()
info: dict[str, object] = {
"kind": "edge",
"edge_id": edge_id,
"part_id": self.edge_part_ids[edge_id],
"solid_id": self._edge_solid_id(edge_id),
"orientation": _orientation_name(edge.Orientation()),
"curve": CURVE_TYPES.get(curve_type, f"type {curve_type}"),
"length": props.Mass(),
"length_center": _point_tuple(props.CentreOfMass()),
"first_parameter": curve.FirstParameter(),
"last_parameter": curve.LastParameter(),
"start_point": _point_tuple(curve.Value(curve.FirstParameter())),
"end_point": _point_tuple(curve.Value(curve.LastParameter())),
}
info.update(_shape_bounds_info(edge))
if curve_type == GeomAbs_Line:
line = curve.Line()
info["line_origin"] = _point_tuple(line.Location())
info["direction"] = _dir_tuple(line.Direction())
if curve_type == GeomAbs_Circle:
circle = curve.Circle()
info["center"] = _point_tuple(circle.Location())
info["axis"] = _dir_tuple(circle.Axis().Direction())
info["radius"] = circle.Radius()
info["diameter"] = circle.Radius() * 2.0
if curve_type == GeomAbs_Ellipse:
ellipse = curve.Ellipse()
info["center"] = _point_tuple(ellipse.Location())
info["axis"] = _dir_tuple(ellipse.Axis().Direction())
info["major_axis"] = _dir_tuple(ellipse.XAxis().Direction())
info["minor_axis"] = _dir_tuple(ellipse.YAxis().Direction())
info["major_radius"] = ellipse.MajorRadius()
info["minor_radius"] = ellipse.MinorRadius()
adjacent_face_ids = self._edge_adjacent_face_ids(edge_id)
info["adjacent_face_ids"] = tuple(adjacent_face_ids)
info["adjacent_face_count"] = len(adjacent_face_ids)
self._edge_info_cache[edge_id] = dict(info)
return dict(info)
def _edge_solid_id(self, edge_id: int) -> int:
if edge_id < 0 or edge_id >= len(self.edges):
return -1
return self.edge_solid_ids[edge_id] if edge_id < len(self.edge_solid_ids) else -1
def edge_ids_for_solid(self, solid_id: int) -> list[int]:
if solid_id < 0 or solid_id >= len(self.solids):
raise ValueError(f"Unknown solid id {solid_id}")
return [edge_id for edge_id in range(len(self.edges)) if self._edge_solid_id(edge_id) == solid_id]
def _edge_adjacent_face_ids(self, edge_id: int) -> list[int]:
if edge_id < 0 or edge_id >= len(self.edges):
return []
if edge_id in self._edge_face_ids_cache:
return list(self._edge_face_ids_cache[edge_id])
edge = self.edges[edge_id]
part_id = self.edge_part_ids[edge_id]
solid_id = self._edge_solid_id(edge_id)
adjacent: list[int] = []
for face_id, face in enumerate(self.faces):
if self.face_part_ids[face_id] != part_id:
continue
if solid_id >= 0 and self.face_solid_ids[face_id] != solid_id:
continue
if any(_same_shape(candidate, edge) for candidate in TopologyExplorer(face, ignore_orientation=True).edges()):
adjacent.append(face_id)
return adjacent
def solid_info(self, solid_id: int) -> dict[str, object]:
if solid_id < 0 or solid_id >= len(self.solids):
raise ValueError(f"Unknown solid id {solid_id}")
part_id, solid = self.solids[solid_id]
topo = TopologyExplorer(solid, ignore_orientation=True)
surface_props = GProp_GProps()
brepgprop.SurfaceProperties(solid, surface_props)
info: dict[str, object] = {
"kind": "solid",
"solid_id": solid_id,
"part_id": part_id,
"faces": len(list(topo.faces())),
"edges": len(list(topo.edges())),
"vertices": len(list(topo.vertices())),
"surface_area": surface_props.Mass(),
"surface_center": _point_tuple(surface_props.CentreOfMass()),
}
info.update(_shape_bounds_info(solid))
info.update(_shape_volume_info(solid))
return info
def part_info(self, part_id: int) -> dict[str, object]:
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
topo = TopologyExplorer(part.shape, ignore_orientation=True)
info: dict[str, object] = {
"kind": part.kind,
"part_id": part.id,
"name": part.name,
"path": part.path,
"parent_id": part.parent_id if part.parent_id is not None else "",
"depth": part.depth,
"solids": len(list(topo.solids())),
"faces": len(list(topo.faces())),
"edges": len(list(topo.edges())),
"vertices": len(list(topo.vertices())),
}
info.update(_shape_bounds_info(part.shape))
info.update(_shape_volume_info(part.shape))
return info