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

3260 lines
161 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import math
from pathlib import Path
import time
from typing import Callable, Iterable
from OCC.Core.BRep import BRep_Tool
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse
from OCC.Core.BRepBndLib import brepbndlib
from OCC.Core.BOPAlgo import BOPAlgo_GlueFull
from OCC.Core.BRepBuilderAPI import (
BRepBuilderAPI_MakeFace,
BRepBuilderAPI_MakeVertex,
BRepBuilderAPI_MakeWire,
BRepBuilderAPI_Transform,
)
from OCC.Core.BRepExtrema import BRepExtrema_DistShapeShape
from OCC.Core.BRepCheck import BRepCheck_Analyzer
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
from OCC.Core.BRepGProp import brepgprop
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism
from OCC.Core.Bnd import Bnd_Box
from OCC.Core.GeomAbs import (
GeomAbs_BSplineCurve,
GeomAbs_BSplineSurface,
GeomAbs_BezierCurve,
GeomAbs_BezierSurface,
GeomAbs_Circle,
GeomAbs_Cone,
GeomAbs_Cylinder,
GeomAbs_Ellipse,
GeomAbs_Hyperbola,
GeomAbs_Line,
GeomAbs_OffsetSurface,
GeomAbs_OtherCurve,
GeomAbs_OtherSurface,
GeomAbs_Parabola,
GeomAbs_Plane,
GeomAbs_Sphere,
GeomAbs_SurfaceOfExtrusion,
GeomAbs_SurfaceOfRevolution,
GeomAbs_Torus,
)
from OCC.Core.GProp import GProp_GProps
from OCC.Core.IFSelect import IFSelect_RetDone
from OCC.Core.Interface import Interface_Static
from OCC.Core.STEPCAFControl import STEPCAFControl_Reader
from OCC.Core.STEPControl import STEPControl_AsIs, STEPControl_Reader, STEPControl_Writer
from OCC.Core.ShapeFix import ShapeFix_Shape
from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
from OCC.Core.TDF import TDF_Label, TDF_LabelSequence
from OCC.Core.TDocStd import TDocStd_Document
from OCC.Core.TopAbs import (
TopAbs_EDGE,
TopAbs_EXTERNAL,
TopAbs_FACE,
TopAbs_FORWARD,
TopAbs_IN,
TopAbs_INTERNAL,
TopAbs_OUT,
TopAbs_REVERSED,
TopAbs_SOLID,
TopAbs_VERTEX,
TopAbs_WIRE,
)
from OCC.Core.TopExp import TopExp_Explorer, topexp
from OCC.Core.TopLoc import TopLoc_Location
from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_IndexedMapOfShape
from OCC.Core.XCAFDoc import XCAFDoc_DocumentTool
from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
from .constants import CURVE_TYPES, FREEFORM_FACE_SURFACES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES
from .export import ExportMixin
from .features import FeatureMixin
from .operations import OperationMixin
from .polydata import PolydataMixin
from .transforms import TransformMixin
from .geometry_utils import * # noqa: F403
from .model_types import PartNode, TopologyStats
from .step_io import (
_load_plain_step,
_load_with_xcaf,
_parse_product_names,
_prepare_shape_for_step_export,
_write_step,
)
class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, PolydataMixin):
def __init__(self, filename: Path, parts: list[PartNode], shape: TopoDS_Shape):
self.filename = filename
self.parts = parts
self.shape = shape
self.faces: list[TopoDS_Shape] = []
self.face_logical_ids: list[int] = []
self.face_part_ids: list[int] = []
self.face_solid_ids: list[int] = []
self.edges: list[TopoDS_Shape] = []
self.edge_part_ids: list[int] = []
self.edge_solid_ids: list[int] = []
self.solids: list[tuple[int, TopoDS_Shape]] = []
self._quick_face_info_cache: dict[int, dict[str, object]] = {}
self._face_info_cache: dict[int, dict[str, object]] = {}
self._feature_info_cache: dict[int, dict[str, object]] = {}
self._edge_info_cache: dict[int, dict[str, object]] = {}
self._face_edge_ids_cache: dict[int, list[int]] = {}
self._edge_face_ids_cache: dict[int, list[int]] = {}
self._same_domain_face_ids_cache: dict[int, list[int]] = {}
self._face_first_level_topology_cache: dict[int, dict[str, object]] = {}
self._cylindrical_first_level_topology_cache: dict[int, dict[str, object]] = {}
self._face_first_level_fact_cache: dict[tuple[int, str], dict[str, object]] = {}
self._edge_vertex_points_cache: dict[int, tuple[tuple[float, float, float], ...]] = {}
self._edge_vertex_key_edge_ids_cache: dict[tuple[int, int, int, int, int], set[int]] | None = None
self._edge_first_level_topology_cache: dict[int, dict[str, object]] = {}
self._edge_first_level_fact_cache: dict[int, dict[str, object]] = {}
self._local_face_deform_readiness_cache: dict[int, dict[str, object]] = {}
self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None
self._same_domain_internal_edge_ids_cache: set[int] | None = None
self._same_domain_duplicate_edge_ids_cache: set[int] | None = None
self._face_polydata_cache: dict[tuple[object, ...], object] = {}
self._edge_polydata_cache: dict[tuple[object, ...], object] = {}
self._polydata_cache_limit = 96
self._mesh_deflection: float | None = None
self.refresh_topology()
@classmethod
def load(cls, filename: str | Path) -> "StepModel":
path = Path(filename)
if not path.exists():
raise FileNotFoundError(path)
product_names = _parse_product_names(path)
parts, whole_shape = _load_with_xcaf(path, product_names)
if not parts or whole_shape.IsNull():
whole_shape = _load_plain_step(path)
fallback_name = product_names[0] if product_names else path.stem
parts = [PartNode(1, fallback_name, "part", whole_shape, path=fallback_name)]
return cls(path, parts, whole_shape)
def display_parts(self) -> list[PartNode]:
leaf_parts = [p for p in self.parts if p.kind == "part" and not p.shape.IsNull()]
if leaf_parts:
return leaf_parts
return [p for p in self.parts if not p.shape.IsNull()]
def stats(self) -> TopologyStats:
topo = TopologyExplorer(self.shape, ignore_orientation=True)
return TopologyStats(
parts=len(self.display_parts()),
solids=len(list(topo.solids())),
faces=len(list(topo.faces())),
edges=len(list(topo.edges())),
vertices=len(list(topo.vertices())),
)
def part_topology_stats(self, part_id: int) -> TopologyStats:
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
topo = TopologyExplorer(part.shape, ignore_orientation=True)
return TopologyStats(
parts=1,
solids=len(list(topo.solids())),
faces=len(list(topo.faces())),
edges=len(list(topo.edges())),
vertices=len(list(topo.vertices())),
)
def geometry_stats(self) -> dict[str, object]:
surface_props = GProp_GProps()
brepgprop.SurfaceProperties(self.shape, surface_props)
info: dict[str, object] = {
"surface_area": surface_props.Mass(),
"surface_center": _point_tuple(surface_props.CentreOfMass()),
}
info.update(_shape_bounds_info(self.shape))
info.update(_shape_volume_info(self.shape))
return info
def refresh_topology(self) -> None:
self.shape = _compound_from_shapes([p.shape for p in self.display_parts()])
self.faces.clear()
self.face_logical_ids.clear()
self.face_part_ids.clear()
self.face_solid_ids.clear()
self.edges.clear()
self.edge_part_ids.clear()
self.edge_solid_ids.clear()
self.solids.clear()
self._quick_face_info_cache.clear()
self._face_info_cache.clear()
self._feature_info_cache.clear()
self._edge_info_cache.clear()
self._face_edge_ids_cache.clear()
self._edge_face_ids_cache.clear()
self._same_domain_face_ids_cache.clear()
self._face_first_level_topology_cache.clear()
self._cylindrical_first_level_topology_cache.clear()
self._face_first_level_fact_cache.clear()
self._edge_vertex_points_cache.clear()
self._edge_vertex_key_edge_ids_cache = None
self._edge_first_level_topology_cache.clear()
self._edge_first_level_fact_cache.clear()
self._local_face_deform_readiness_cache.clear()
self._edge_duplicate_key_ids_cache = None
self._same_domain_internal_edge_ids_cache = None
self._same_domain_duplicate_edge_ids_cache = None
self._face_polydata_cache.clear()
self._edge_polydata_cache.clear()
self._mesh_deflection = None
solid_id = 0
for part in self.display_parts():
part_solids = _explore(part.shape, TopAbs_SOLID)
part_solid_edge_maps: list[tuple[int, TopTools_IndexedDataMapOfShapeListOfShape]] = []
part_solid_entries: list[tuple[int, TopoDS_Shape]] = []
if part_solids:
for solid in part_solids:
current_solid_id = solid_id
self.solids.append((part.id, solid))
part_solid_entries.append((current_solid_id, solid))
edge_map = TopTools_IndexedDataMapOfShapeListOfShape()
topexp.MapShapesAndAncestors(solid, TopAbs_EDGE, TopAbs_SOLID, edge_map)
part_solid_edge_maps.append((current_solid_id, edge_map))
solid_id += 1
part_edge_map = TopTools_IndexedMapOfShape()
topexp.MapShapes(part.shape, TopAbs_EDGE, part_edge_map)
part_edge_ids_by_index: dict[int, int] = {}
for local_edge_index in range(1, part_edge_map.Size() + 1):
edge = part_edge_map.FindKey(local_edge_index)
edge_id = len(self.edges)
part_edge_ids_by_index[local_edge_index] = edge_id
self.edges.append(edge)
self.edge_part_ids.append(part.id)
self.edge_solid_ids.append(_mapped_edge_solid_id(edge, part_solid_edge_maps))
self._edge_face_ids_cache[edge_id] = []
if part_solids:
for current_solid_id, solid in part_solid_entries:
for face in _explore(solid, TopAbs_FACE):
face_id = len(self.faces)
self.faces.append(face)
self.face_logical_ids.append(face_id)
self.face_part_ids.append(part.id)
self.face_solid_ids.append(current_solid_id)
self._cache_face_edge_links(face_id, face, part_edge_map, part_edge_ids_by_index)
else:
for face in _explore(part.shape, TopAbs_FACE):
face_id = len(self.faces)
self.faces.append(face)
self.face_logical_ids.append(face_id)
self.face_part_ids.append(part.id)
self.face_solid_ids.append(-1)
self._cache_face_edge_links(face_id, face, part_edge_map, part_edge_ids_by_index)
def _cache_face_edge_links(
self,
face_id: int,
face: TopoDS_Shape,
part_edge_map: TopTools_IndexedMapOfShape,
part_edge_ids_by_index: dict[int, int],
) -> None:
face_edge_ids: list[int] = []
face_edge_map = TopTools_IndexedMapOfShape()
topexp.MapShapes(face, TopAbs_EDGE, face_edge_map)
for local_face_edge_index in range(1, face_edge_map.Size() + 1):
local_part_edge_index = part_edge_map.FindIndex(face_edge_map.FindKey(local_face_edge_index))
edge_id = part_edge_ids_by_index.get(local_part_edge_index)
if edge_id is None:
continue
face_edge_ids.append(edge_id)
self._edge_face_ids_cache.setdefault(edge_id, []).append(face_id)
self._face_edge_ids_cache[face_id] = face_edge_ids
def part_by_id(self, part_id: int) -> PartNode | None:
return next((p for p in self.parts if p.id == part_id), None)
def snapshot(self) -> dict[object, object]:
data: dict[object, object] = {part.id: part.shape for part in self.parts}
data[SNAPSHOT_FACE_LOGICAL_IDS_KEY] = tuple(self.face_logical_ids)
return data
def restore_snapshot(self, snapshot: dict[object, object]) -> None:
for part in self.parts:
if part.id in snapshot:
part.shape = snapshot[part.id]
self.refresh_topology()
logical_ids = snapshot.get(SNAPSHOT_FACE_LOGICAL_IDS_KEY)
if isinstance(logical_ids, (list, tuple)) and len(logical_ids) == len(self.faces):
self.face_logical_ids = [int(item) for item in logical_ids]
self._quick_face_info_cache.clear()
self._face_info_cache.clear()
self._feature_info_cache.clear()
self._same_domain_face_ids_cache.clear()
self._face_first_level_topology_cache.clear()
self._cylindrical_first_level_topology_cache.clear()
self._face_first_level_fact_cache.clear()
self._local_face_deform_readiness_cache.clear()
def _restore_face_logical_ids_if_count_matches(self, logical_ids: Iterable[int]) -> bool:
previous = tuple(int(item) for item in logical_ids)
if len(previous) != len(self.faces):
return False
self.face_logical_ids = list(previous)
self._quick_face_info_cache.clear()
self._face_info_cache.clear()
self._feature_info_cache.clear()
self._same_domain_face_ids_cache.clear()
self._face_first_level_topology_cache.clear()
self._cylindrical_first_level_topology_cache.clear()
self._face_first_level_fact_cache.clear()
self._local_face_deform_readiness_cache.clear()
return True
def quick_face_info(self, face_id: int) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
full_info = self._face_info_cache.get(face_id)
if full_info is not None:
return dict(full_info)
cached = self._quick_face_info_cache.get(face_id)
if cached is not None:
return dict(cached)
face = self.faces[face_id]
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
surf = BRepAdaptor_Surface(face)
surface_type = surf.GetType()
surface_label = SURFACE_TYPES.get(surface_type, f"type {surface_type}")
info: dict[str, object] = {
"kind": "face",
"face_id": face_id,
"topological_face_id": face_id,
"logical_face_id": self.face_logical_id(face_id),
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"orientation": _orientation_name(face.Orientation()),
"surface": surface_label,
"area": props.Mass(),
"area_center": _point_tuple(props.CentreOfMass()),
"u_range": (surf.FirstUParameter(), surf.LastUParameter()),
"v_range": (surf.FirstVParameter(), surf.LastVParameter()),
"boundary_edges": len(self._face_boundary_edge_ids(face_id)),
"selection_info_mode": "quick",
"selection_info_note": "快速选择信息;同域面、端盖、底面等深层识别会在执行编辑计划或手动扫描时再计算。",
}
info.update(_shape_bounds_info(face))
info.update(self._face_boundary_wire_info(face))
if surface_type == GeomAbs_Plane:
plane = surf.Plane()
direction = plane.Axis().Direction()
info["plane_origin"] = _point_tuple(plane.Location())
info["normal"] = _dir_tuple(direction)
info["oriented_normal"] = _oriented_dir_tuple(direction, face)
info["push_pull_confidence"] = "unchecked"
info["push_pull_note"] = "快速选择阶段不判断材料内外方向;执行拉伸/切除时会重新计算。"
# Keep ordinary selection cheap. Cylindrical-cap direction can
# scan adjacent surfaces and cost hundreds of ms on large STEP
# files; the full push/pull plan recomputes it when the user
# actually edits.
info["push_pull_status"] = "candidate"
info["feature_type"] = "可拉伸/切除平面候选"
info["feature_source_face_id"] = face_id
info["feature_highlight_face_ids"] = (face_id,)
info["feature_edit_actions"] = "拉伸/切除平面"
if bool(info.get("has_inner_boundaries")):
info["local_face_deform_ready"] = False
info["local_face_deform_face_count"] = 1
info["local_face_deform_blocker"] = "当前 Face 有内孔/内边界;请优先使用拉伸/切除、孔或槽的专门修改入口。"
else:
info["local_face_deform_ready"] = True
info["local_face_deform_face_count"] = 1
info["local_face_deform_blocker"] = ""
info["local_face_deform_status"] = "deferred"
info.update(
self._local_face_plane_size_info(
face_id,
face=face,
surf=surf,
center=_tuple_or_none(info.get("area_center")),
)
)
elif surface_type == GeomAbs_Cylinder:
cyl = surf.Cylinder()
axis = cyl.Axis()
radius = cyl.Radius()
u_span = abs(surf.LastUParameter() - surf.FirstUParameter())
swept_area = max(radius * max(u_span, 1e-9), 1e-9)
info["radius"] = radius
info["diameter"] = radius * 2.0
info["axis_point"] = _point_tuple(axis.Location())
info["axis"] = _dir_tuple(axis.Direction())
info["angular_span"] = u_span
info["is_full_cylinder"] = u_span >= math.tau * 0.98
info["height_estimate"] = props.Mass() / swept_area
info["feature_guess"] = "cylindrical face"
info["confidence"] = "unchecked"
info["material_toward_axis"] = "not sampled"
info["material_away_axis"] = "not sampled"
info["material_vote_summary"] = "quick selection skips material sampling"
info["material_sample_count"] = 0
info["note"] = "快速选择阶段不判断孔/槽/凸台;需要语义识别时切换特征探测级别。"
info["feature_source_face_id"] = face_id
info["feature_highlight_face_ids"] = (face_id,)
if u_span < math.tau * 0.92:
info["slot_kind"] = "partial-cylindrical-groove"
info["slot_angular_span"] = u_span
info["slot_open_angle"] = max(math.tau - u_span, 0.0)
info["slot_chord_width_estimate"] = 2.0 * radius * math.sin(min(u_span, math.tau) * 0.5)
info["slot_sagitta_depth_estimate"] = radius * (1.0 - math.cos(min(u_span, math.tau) * 0.5))
info["slot_arc_length_estimate"] = radius * u_span
elif surface_type == GeomAbs_Cone:
cone = surf.Cone()
info["axis_point"] = _point_tuple(cone.Location())
info["axis"] = _dir_tuple(cone.Axis().Direction())
info["reference_radius"] = cone.RefRadius()
info["semi_angle"] = cone.SemiAngle()
info["feature_reference_radius"] = cone.RefRadius()
info["feature_reference_diameter"] = cone.RefRadius() * 2.0
elif surface_type == GeomAbs_Sphere:
sphere = surf.Sphere()
info["center"] = _point_tuple(sphere.Location())
info["radius"] = sphere.Radius()
info["diameter"] = sphere.Radius() * 2.0
info["feature_sphere_radius"] = sphere.Radius()
info["feature_sphere_diameter"] = sphere.Radius() * 2.0
elif surface_type == GeomAbs_Torus:
torus = surf.Torus()
info["center"] = _point_tuple(torus.Location())
info["axis"] = _dir_tuple(torus.Axis().Direction())
info["major_radius"] = torus.MajorRadius()
info["minor_radius"] = torus.MinorRadius()
info["feature_torus_major_radius"] = torus.MajorRadius()
info["feature_torus_minor_radius"] = torus.MinorRadius()
elif surface_label in FREEFORM_FACE_SURFACES:
info.update(self._freeform_face_limit_fields(surface_label))
info.update(self._recognition_summary_fields(info))
self._quick_face_info_cache[face_id] = dict(info)
return dict(info)
def face_info(self, face_id: int) -> dict[str, object]:
if face_id in self._face_info_cache:
return dict(self._face_info_cache[face_id])
face = self.faces[face_id]
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
surf = BRepAdaptor_Surface(face)
surface_type = surf.GetType()
boundary_edges = len(list(TopologyExplorer(face, ignore_orientation=True).edges()))
info: dict[str, object] = {
"kind": "face",
"face_id": face_id,
"topological_face_id": face_id,
"logical_face_id": self.face_logical_id(face_id),
"face_region_logical_id": self.face_region_logical_id(face_id),
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"orientation": _orientation_name(face.Orientation()),
"surface": SURFACE_TYPES.get(surface_type, f"type {surface_type}"),
"area": props.Mass(),
"area_center": _point_tuple(props.CentreOfMass()),
"u_range": (surf.FirstUParameter(), surf.LastUParameter()),
"v_range": (surf.FirstVParameter(), surf.LastVParameter()),
"boundary_edges": boundary_edges,
}
info.update(_shape_bounds_info(face))
info.update(self._face_boundary_wire_info(face))
if surface_type == GeomAbs_Plane:
plane = surf.Plane()
direction = plane.Axis().Direction()
push_pull_direction = self._plane_push_pull_direction(face_id, surf)
info["plane_origin"] = _point_tuple(plane.Location())
info["normal"] = _dir_tuple(direction)
info["oriented_normal"] = _oriented_dir_tuple(direction, face)
info["push_pull_outward_direction"] = push_pull_direction["outward_direction"]
info["push_pull_inward_direction"] = push_pull_direction["inward_direction"]
info["push_pull_plus_side"] = push_pull_direction["plus_side_state"]
info["push_pull_minus_side"] = push_pull_direction["minus_side_state"]
info["push_pull_confidence"] = push_pull_direction["confidence"]
info["push_pull_note"] = push_pull_direction["note"]
info.update(self._local_face_deform_readiness(face_id))
info.update(
self._local_face_plane_size_info(
face_id,
face=face,
surf=surf,
center=_tuple_or_none(info.get("area_center")),
)
)
elif surface_type == GeomAbs_Cylinder:
cyl = surf.Cylinder()
axis = cyl.Axis()
radius = cyl.Radius()
u_span = abs(surf.LastUParameter() - surf.FirstUParameter())
swept_area = max(radius * max(u_span, 1e-9), 1e-9)
classification = self._classify_cylindrical_face(face_id, surf, detailed=True)
info["radius"] = cyl.Radius()
info["diameter"] = cyl.Radius() * 2.0
info["axis_point"] = _point_tuple(axis.Location())
info["axis"] = _dir_tuple(axis.Direction())
info["angular_span"] = u_span
info["is_full_cylinder"] = u_span >= math.tau * 0.98
info["height_estimate"] = props.Mass() / swept_area
info["feature_guess"] = classification["feature_guess"]
info["confidence"] = classification["confidence"]
info["material_toward_axis"] = classification["toward_axis"]
info["material_away_axis"] = classification["away_axis"]
info["material_vote_summary"] = classification["vote_summary"]
info["material_sample_count"] = classification["sample_count"]
info["note"] = classification["note"]
info.update(self._cylinder_end_opening_info(face_id, surf))
info.update(_cylinder_resize_readiness(info))
info.update(_cylinder_boss_resize_readiness(info))
info.update(_cylinder_depth_readiness(info))
info.update(_cylinder_suppress_readiness(info))
elif surface_type == GeomAbs_Cone:
cone = surf.Cone()
info["axis_point"] = _point_tuple(cone.Location())
info["axis"] = _dir_tuple(cone.Axis().Direction())
info["reference_radius"] = cone.RefRadius()
info["semi_angle"] = cone.SemiAngle()
elif surface_type == GeomAbs_Sphere:
sphere = surf.Sphere()
info["center"] = _point_tuple(sphere.Location())
info["radius"] = sphere.Radius()
info["diameter"] = sphere.Radius() * 2.0
elif surface_type == GeomAbs_Torus:
torus = surf.Torus()
info["center"] = _point_tuple(torus.Location())
info["axis"] = _dir_tuple(torus.Axis().Direction())
info["major_radius"] = torus.MajorRadius()
info["minor_radius"] = torus.MinorRadius()
elif str(info.get("surface") or "") in FREEFORM_FACE_SURFACES:
info.update(self._freeform_face_limit_fields(str(info.get("surface") or "")))
info.update(self._recognition_summary_fields(info))
self._face_info_cache[face_id] = dict(info)
return dict(info)
def _freeform_face_limit_fields(self, surface: str) -> dict[str, object]:
blocker = (
"当前 Face 是自由曲面/非基础解析曲面;STEP 里通常没有可直接修改的历史参数,"
"当前一级阶段不开放面积、尺寸、半径或偏移类参数化修改。"
"请先保留为只读诊断,后续需要专门的曲面控制点或曲面替换语义。"
)
return {
"feature_type": "自由曲面 Face(暂不支持参数化编辑)",
"feature_edit_actions": "只读诊断;不开放参数化修改",
"freeform_face_status": "blocked",
"freeform_face_risk": "blocked",
"freeform_face_blockers": blocker,
"local_face_deform_ready": False,
"local_face_deform_face_count": 1,
"local_face_deform_blocker": blocker,
"recognition_confidence": "low",
"recognition_risk": "blocked",
"recognition_blockers": blocker,
"note": f"{surface} 当前按自由曲面处理,不会伪装成平面、圆柱、圆锥、球面或环面参数。",
}
def face_surface_kind(self, face_id: int) -> str:
if face_id < 0 or face_id >= len(self.faces):
return ""
cached = self._face_info_cache.get(face_id)
if cached is not None and cached.get("surface") is not None:
return str(cached.get("surface"))
try:
surf = BRepAdaptor_Surface(self.faces[face_id])
surface_type = surf.GetType()
except Exception:
return ""
return SURFACE_TYPES.get(surface_type, f"type {surface_type}")
def face_cylinder_diameter(self, face_id: int) -> float | None:
if face_id < 0 or face_id >= len(self.faces):
return None
cached = self._face_info_cache.get(face_id)
if cached is not None and cached.get("diameter") is not None and cached.get("surface") == "cylinder":
try:
return float(cached["diameter"])
except (TypeError, ValueError):
pass
try:
surf = BRepAdaptor_Surface(self.faces[face_id])
if surf.GetType() != GeomAbs_Cylinder:
return None
return float(surf.Cylinder().Radius()) * 2.0
except Exception:
return None
def _recognition_summary_fields(self, info: dict[str, object]) -> dict[str, object]:
surface = str(info.get("surface") or "")
candidate = (
str(info.get("feature_type") or "").strip()
or str(info.get("feature_guess") or "").strip()
or (f"{surface} Face" if surface else "Face")
)
confidence = (
str(info.get("confidence") or "").strip()
or str(info.get("push_pull_confidence") or "").strip()
or str(info.get("shell_confidence") or "").strip()
or "unchecked"
)
if confidence == "unchecked":
feature_guess = str(info.get("feature_guess") or "")
has_axis = info.get("axis") not in {None, ""} or info.get("axis_point") not in {None, ""}
if surface == "plane" and info.get("boundary_edges") not in {None, ""}:
confidence = "high"
elif surface == "cylinder" and _float_or_none(info.get("radius")) is not None and has_axis:
confidence = "high" if feature_guess else "medium"
elif surface == "cone" and _float_or_none(info.get("reference_radius")) is not None and _float_or_none(info.get("semi_angle")) is not None:
confidence = "medium"
elif surface == "sphere" and _float_or_none(info.get("radius")) is not None:
confidence = "high"
elif surface == "torus" and _float_or_none(info.get("major_radius")) is not None and _float_or_none(info.get("minor_radius")) is not None:
confidence = "high"
if surface in FREEFORM_FACE_SURFACES:
confidence = "low"
risk_rank = {"low": 0, "medium": 1, "high": 2, "blocked": 3}
capability_specs = (
("resize_status", "resize_risk", "resize_blockers", "孔/槽/圆柱直径"),
("push_pull_status", "push_pull_risk", "push_pull_blockers", "平面拉伸/切除"),
("shell_status", "shell_risk", "shell_blockers", "壳体厚度"),
("cylinder_resize_status", "cylinder_resize_risk", "cylinder_resize_blockers", "圆柱直径/半径"),
("boss_resize_status", "boss_resize_risk", "boss_resize_blockers", "圆柱凸台直径/高度/轴心"),
("depth_status", "depth_risk", "depth_blockers", "盲孔/盲槽深度"),
("suppress_status", "suppress_risk", "suppress_blockers", "封堵孔/槽"),
("fillet_status", "fillet_risk", "fillet_blockers", "已有圆角半径"),
("chamfer_status", "chamfer_risk", "chamfer_blockers", "倒角"),
)
available_capabilities = {
status_key
for status_key, _risk_key, _blocker_key, _label in capability_specs
if str(info.get(status_key) or "").strip() in {"ready", "caution", "candidate"}
}
feature_type_text = str(info.get("feature_type") or "")
feature_actions_text = str(info.get("feature_edit_actions") or "")
has_planar_push_pull_candidate = (
surface == "plane"
and (
"拉伸/切除" in feature_type_text
or "拉伸/切除" in feature_actions_text
or str(info.get("push_pull_status") or "").strip() == "candidate"
)
)
has_local_face_deform = bool(info.get("local_face_deform_ready"))
has_slot_candidate = str(info.get("slot_status") or "").strip() == "candidate"
has_shell_candidate = str(info.get("shell_region_status") or "").strip() == "candidate"
has_analytic_surface_candidate = surface in {"cone", "sphere", "torus"} and bool(feature_type_text)
has_available_capability = bool(
available_capabilities
or has_planar_push_pull_candidate
or has_local_face_deform
or has_slot_candidate
or has_shell_candidate
or has_analytic_surface_candidate
)
risk = "low"
for key in (
"risk",
"first_level_topology_risk",
"freeform_face_risk",
):
value = str(info.get(key) or "").strip()
if risk_rank.get(value, -1) > risk_rank.get(risk, -1):
risk = value
if surface in FREEFORM_FACE_SURFACES:
risk = "blocked"
for status_key, risk_key, _blocker_key, _label in capability_specs:
value = str(info.get(risk_key) or "").strip()
if value == "blocked" and has_available_capability:
continue
if risk_rank.get(value, -1) > risk_rank.get(risk, -1):
risk = value
if confidence in {"low", "unchecked", "none"} and risk == "low":
risk = "medium"
evidence: list[str] = []
evidence_keys: list[str] = []
def add(key: str, text: str) -> None:
if text and key not in evidence_keys:
evidence_keys.append(key)
evidence.append(text)
if surface:
add("surface", f"曲面={surface}")
if surface == "cylinder" and info.get("radius") not in {None, ""}:
add("cylinder_geometry", f"圆柱半径={info.get('radius')}")
if surface == "cone":
if info.get("reference_radius") not in {None, ""}:
add("cone_geometry", f"圆锥参考半径={info.get('reference_radius')}")
if info.get("semi_angle") not in {None, ""}:
add("cone_angle", f"圆锥半角={info.get('semi_angle')}")
if surface == "sphere" and info.get("radius") not in {None, ""}:
add("sphere_geometry", f"球半径={info.get('radius')}")
if surface == "torus":
if info.get("major_radius") not in {None, ""}:
add("torus_major_radius", f"环面主半径={info.get('major_radius')}")
if info.get("minor_radius") not in {None, ""}:
add("torus_minor_radius", f"环面小半径={info.get('minor_radius')}")
if info.get("boundary_edges") not in {None, ""}:
add("boundary_edges", f"边界Edge={info.get('boundary_edges')}")
if info.get("same_domain_face_count") not in {None, ""}:
add("same_domain", f"同域Face={info.get('same_domain_face_count')}")
if info.get("first_level_adjacent_face_count") not in {None, ""}:
add("first_level_topology", f"一级相邻Face={info.get('first_level_adjacent_face_count')}")
elif info.get("feature_adjacent_face_ids") not in {None, ""}:
try:
adjacent_count = len(tuple(info.get("feature_adjacent_face_ids") or ()))
except TypeError:
adjacent_count = 0
add("first_level_topology", f"一级相邻Face={adjacent_count}")
if info.get("first_level_fact_summary") not in {None, ""}:
add("first_level_fact_graph", f"一级事实={info.get('first_level_fact_summary')}")
if info.get("material_vote_summary") not in {None, ""}:
add("material_votes", f"材料采样={info.get('material_vote_summary')}")
if info.get("feature_end_face_ids") not in {None, ""}:
try:
end_count = len(tuple(info.get("feature_end_face_ids") or ()))
except TypeError:
end_count = 0
add("end_faces", f"端面Face={end_count}")
if info.get("feature_bottom_face_ids") not in {None, ""}:
try:
bottom_count = len(tuple(info.get("feature_bottom_face_ids") or ()))
except TypeError:
bottom_count = 0
add("bottom_faces", f"疑似底面 Face={bottom_count}")
if info.get("slot_status") == "candidate":
add("slot_geometry", "部分圆柱槽/半孔几何")
if info.get("shell_region_status") == "candidate":
add("shell_opposite_face", "找到相对平面/壳体候选")
ready_actions: list[str] = []
limited_actions: list[str] = []
blockers: list[str] = []
limitations: list[str] = []
def add_unique(items: list[str], text: str) -> None:
if text and text not in items:
items.append(text)
def add_action(items: list[str], text: str) -> None:
if text and text not in items:
items.append(text)
for status_key, _risk_key, blocker_key, label in capability_specs:
status = str(info.get(status_key) or "").strip()
blocker_text = str(info.get(blocker_key) or "").strip()
if status in {"ready", "caution", "candidate"}:
add_action(ready_actions, label)
elif status == "blocked" and blocker_text:
add_action(limited_actions, label)
if has_planar_push_pull_candidate:
add_action(ready_actions, "平面拉伸/切除")
if has_local_face_deform:
add_action(ready_actions, "当前面面积/U向尺寸/V向尺寸/中心/偏移")
elif str(info.get("local_face_deform_blocker") or "").strip():
add_action(limited_actions, "局部重建尺寸/中心/偏移")
if has_shell_candidate:
add_action(ready_actions, "壳体厚度")
if has_slot_candidate:
add_action(ready_actions, "槽/半孔宽度/深度/弧长")
if surface == "cone" and feature_type_text:
add_action(ready_actions, "圆锥参考半径/直径/半角")
elif surface == "sphere" and feature_type_text:
add_action(ready_actions, "球面半径/直径")
elif surface == "torus" and feature_type_text:
add_action(ready_actions, "环面主/小半径或直径")
for key in ("local_face_deform_blocker", "first_level_topology_blockers"):
text = str(info.get(key) or "").strip()
if not text:
continue
if has_available_capability:
add_unique(limitations, text)
else:
add_unique(blockers, text)
for status_key, _risk_key, blocker_key, _label in capability_specs:
text = str(info.get(blocker_key) or "").strip()
if not text:
continue
status = str(info.get(status_key) or "").strip()
if status == "blocked" and has_available_capability:
add_unique(limitations, text)
else:
add_unique(blockers, text)
note = (
str(info.get("feature_mode") or "").strip()
or str(info.get("note") or "").strip()
or str(info.get("push_pull_note") or "").strip()
)
if note:
add("note", note)
confidence_points = {"high": 72, "medium": 56, "low": 34, "unchecked": 22, "none": 0}
risk_penalty = {"low": 0, "medium": 14, "high": 30, "blocked": 72}
score = confidence_points.get(confidence, 22)
score += min(len(evidence_keys) * 5, 24)
score -= risk_penalty.get(risk, 14)
if blockers:
score -= 35
elif limitations:
score -= min(len(limitations) * 4, 12)
score = max(0, min(100, int(round(score))))
if risk == "blocked" or blockers:
decision = "已阻止"
elif score >= 76 and risk == "low":
decision = "高可信候选"
elif score >= 56:
decision = "可尝试候选"
elif score >= 36:
decision = "需人工确认"
else:
decision = "不建议自动修改"
summary_parts = [candidate, f"置信度={confidence}", f"风险={risk}", f"评分={score}", f"结论={decision}"]
if evidence:
summary_evidence = list(evidence[:5])
if "first_level_fact_graph" in evidence_keys:
fact_text = evidence[evidence_keys.index("first_level_fact_graph")]
if fact_text not in summary_evidence:
if len(summary_evidence) >= 5:
summary_evidence[-1] = fact_text
else:
summary_evidence.append(fact_text)
summary_parts.append("证据:" + "".join(summary_evidence))
if ready_actions:
summary_parts.append("可改:" + "".join(ready_actions[:4]))
if blockers:
summary_parts.append("限制:" + "".join(blockers[:2]))
elif limitations:
summary_parts.append("受限能力:" + "".join(limitations[:2]))
if limited_actions:
summary_parts.append("受限修改:" + "".join(limited_actions[:4]))
return {
"recognition_candidate": candidate,
"recognition_confidence": confidence,
"recognition_risk": risk,
"recognition_score": score,
"recognition_decision": decision,
"recognition_evidence": "".join(evidence),
"recognition_evidence_keys": tuple(evidence_keys),
"recognition_ready_actions": "".join(ready_actions),
"recognition_limited_actions": "".join(limited_actions),
"recognition_blockers": "".join(blockers),
"recognition_limitations": "".join(limitations),
"recognition_summary": "".join(summary_parts),
}
def cached_feature_info(self, face_id: int) -> dict[str, object] | None:
cached = self._feature_info_cache.get(face_id)
return dict(cached) if cached is not None else None
def feature_info(self, face_id: int) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
if face_id in self._feature_info_cache:
return dict(self._feature_info_cache[face_id])
info = self.face_info(face_id)
surface = str(info.get("surface", ""))
if surface == "cylinder":
result = self._cylindrical_feature_info(face_id, info)
elif surface == "plane":
result = self._planar_feature_info(face_id, info)
elif surface == "cone":
result = self._conical_feature_info(face_id, info)
elif surface == "sphere":
result = self._spherical_feature_info(face_id, info)
elif surface == "torus":
result = self._toroidal_feature_info(face_id, info)
else:
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
result = dict(info)
result.update(
{
"kind": "feature",
"feature_type": "暂不支持的局部曲面候选",
"feature_source_face_id": face_id,
"feature_face_ids": (face_id,),
"feature_highlight_face_ids": (face_id,),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_edit_actions": "当前只读;复杂曲面局部面积修改暂未开放",
"feature_mode": (
"Feature 模式会把选中的Face解释为局部几何特征候选;"
"复杂曲面局部面积修改需要更明确的边界/约束重建,当前不会把面积伪装成直接可改参数。"
),
}
)
result.update(self._recognition_summary_fields(result))
self._feature_info_cache[face_id] = dict(result)
return dict(result)
def associated_feature_infos(
self,
face_id: int,
*,
max_depth: int = 3,
max_scan_faces: int = 72,
max_features: int = 10,
time_budget_seconds: float | None = None,
lightweight: bool = False,
) -> list[dict[str, object]]:
"""Detect editable feature candidates near the selected face.
STEP does not store a dependable CAD feature-history graph, so this
uses a shallow shared-edge walk. It can cross small cap/support faces to
reach a nearby hole, slot, boss, or analytic surface, but it avoids
scanning an entire solid through large carrier planes.
"""
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
if max_features <= 0:
return []
deadline = None
if time_budget_seconds is not None and time_budget_seconds > 0:
deadline = time.monotonic() + float(time_budget_seconds)
def budget_expired() -> bool:
return deadline is not None and time.monotonic() >= deadline
def selection_candidate_info(candidate_id: int) -> dict[str, object]:
cached = self.cached_feature_info(candidate_id)
if cached is not None:
return cached
info = self.quick_face_info(candidate_id)
surface = str(info.get("surface", "") or "")
result = dict(info)
result.setdefault("kind", "feature")
result.setdefault("feature_source_face_id", candidate_id)
result.setdefault("feature_face_ids", (candidate_id,))
result.setdefault("feature_highlight_face_ids", (candidate_id,))
if surface == "cylinder":
angular_span = _float_or_none(result.get("angular_span"))
is_partial = (
not _is_effectively_full_cylinder(result)
and angular_span is not None
and angular_span < math.tau * 0.92
)
if is_partial:
result.setdefault("feature_type", "槽/半孔候选")
result.setdefault("feature_guess", "cylindrical face")
result.setdefault(
"feature_edit_actions",
"轻量探测阶段只读取半径、直径、轴线和大致高度;点击具体参数时会重新确认槽/半孔语义。",
)
else:
result.setdefault("feature_type", "圆柱面候选")
result.setdefault("feature_guess", "cylindrical face")
result.setdefault(
"feature_edit_actions",
"轻量探测阶段只读取半径、直径、轴线和大致高度;点击具体参数时会重新确认孔、凸台或圆角语义。",
)
elif surface in {"cone", "sphere", "torus"}:
result.setdefault("feature_type", f"{surface} 面候选")
result.setdefault("feature_edit_actions", "轻量探测阶段只读取解析曲面参数;点击具体参数时会重新生成完整编辑计划。")
elif surface == "plane":
result["feature_type"] = "相邻平面 Face 候选"
result.setdefault("feature_guess", "adjacent plane face")
result.setdefault(
"feature_edit_actions",
"轻量探测阶段显示共享边的相邻平面 Face;点击具体参数时会切换到该 Face 并重新生成完整编辑计划。",
)
else:
result.setdefault("feature_type", "相邻几何候选")
result.setdefault("feature_edit_actions", "轻量探测阶段只显示相邻关系;暂不把它作为可修改特征。")
result.setdefault(
"feature_mode",
"相邻特征轻量探测:为保证旋转、悬停和点选流畅,选择时只读取直接邻域的基础几何;完整识别留到执行具体修改时再计算。",
)
result["associated_feature_scan_mode"] = "lightweight"
try:
result.update(self._recognition_summary_fields(result))
except Exception:
pass
return result
source_info = selection_candidate_info(face_id) if lightweight else self.feature_info(face_id)
source_area = max(float(source_info.get("area", 0.0) or 0.0), 1e-12)
source_feature_faces = set(_int_values(source_info.get("feature_face_ids"))) or {face_id}
visited = {face_id}
frontier: list[tuple[int, int]] = [(face_id, 0)]
candidate_hops: dict[int, int] = {}
while frontier and len(visited) < max_scan_faces:
if budget_expired():
break
current_id, depth = frontier.pop(0)
if depth >= max_depth:
continue
edge_ids = self._face_boundary_edge_ids(current_id)
neighbors = sorted(set(self._adjacent_face_ids_for_edges(edge_ids, current_id)) - {current_id})
for neighbor_id in neighbors:
if budget_expired():
break
candidate_hops[neighbor_id] = min(candidate_hops.get(neighbor_id, depth + 1), depth + 1)
if neighbor_id in visited or len(visited) >= max_scan_faces:
continue
visited.add(neighbor_id)
expand = True
if neighbor_id != face_id and depth >= 1:
quick = self.quick_face_info(neighbor_id)
neighbor_area = float(quick.get("area", 0.0) or 0.0)
if str(quick.get("surface", "")) == "plane" and neighbor_area > source_area * 8.0:
expand = False
if expand:
frontier.append((neighbor_id, depth + 1))
results: list[dict[str, object]] = []
seen_features: set[tuple[str, frozenset[int]]] = set()
for candidate_id, hop_count in sorted(candidate_hops.items(), key=lambda item: (item[1], item[0])):
if budget_expired():
break
if candidate_id in source_feature_faces:
continue
try:
info = selection_candidate_info(candidate_id) if lightweight else self.feature_info(candidate_id)
except Exception:
continue
surface = str(info.get("surface", "") or "")
feature_guess = str(info.get("feature_guess", "") or "")
feature_type = str(info.get("feature_type", "") or "")
if lightweight:
is_semantic = bool(
info.get("prismatic_extrusion_status") == "candidate"
or surface in {"plane", "cylinder", "cone", "sphere", "torus"}
)
if surface == "plane" and hop_count > 1:
is_semantic = False
else:
is_semantic = bool(
info.get("prismatic_extrusion_status") == "candidate"
or surface in {"cone", "sphere", "torus"}
or (
surface == "cylinder"
and feature_guess
in {
"hole/groove candidate",
"boss/outer-round candidate",
"round/fillet candidate",
}
)
)
if not is_semantic:
continue
identity_face_ids = _int_values(info.get("feature_face_ids"))
if info.get("prismatic_profile_status") == "candidate":
identity_face_ids = (
_int_values(info.get("prismatic_highlight_face_ids"))
or _int_values(info.get("feature_highlight_face_ids"))
)
feature_faces = frozenset(identity_face_ids or [candidate_id])
identity = (feature_type or feature_guess or surface, feature_faces)
if identity in seen_features:
continue
seen_features.add(identity)
related = dict(info)
association_label = ""
if lightweight and surface == "plane":
association_label = f"相邻平面 Face {candidate_id}"
related.update(
{
"association_source_face_id": candidate_id,
"association_hop_count": hop_count,
"association_relation": "shared-edge-topology",
"association_label": association_label,
"association_priority": (
0 if surface == "cylinder" else (1 if surface in {"cone", "sphere", "torus"} else 2)
),
}
)
results.append(related)
results.sort(
key=lambda item: (
int(item.get("association_priority", 9)),
int(item.get("association_hop_count", 99)),
int(item.get("association_source_face_id", 0)),
)
)
return results[:max_features]
def _toroidal_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
major_radius = float(info.get("major_radius", 0.0) or 0.0)
minor_radius = float(info.get("minor_radius", 0.0) or 0.0)
result = dict(info)
result.update(
{
"kind": "feature",
"feature_type": "环面候选",
"feature_source_face_id": face_id,
"feature_face_ids": (face_id,),
"feature_highlight_face_ids": (face_id,),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_edit_actions": "修改环面主半径/小半径",
"feature_mode": (
"这是从 STEP/B-Rep 环面直接识别出的几何候选;当前修改会围绕环面中心缩放所属零件/Solid,"
"主半径和小半径会等比例变化,不是 CAD 历史里的管径或圆角参数。"
),
"feature_torus_major_radius": major_radius,
"feature_torus_minor_radius": minor_radius,
}
)
return result
def _spherical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
radius = float(info.get("radius", 0.0) or 0.0)
result = dict(info)
result.update(
{
"kind": "feature",
"feature_type": "球面候选",
"feature_source_face_id": face_id,
"feature_face_ids": (face_id,),
"feature_highlight_face_ids": (face_id,),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_edit_actions": "修改球面半径/直径",
"feature_mode": (
"这是从 STEP/B-Rep 球面直接识别出的几何候选;修改会围绕球心缩放所属零件/Solid,"
"不是 CAD 历史里的球面或圆角参数。"
),
"feature_sphere_radius": radius,
"feature_sphere_diameter": radius * 2.0 if radius > 0 else "",
}
)
return result
def _conical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
reference_radius = float(info.get("reference_radius", 0.0) or 0.0)
boundary_info: dict[str, object] = {}
axis_point = _tuple_or_none(info.get("axis_point"))
axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis")))
if axis_point is not None and axis_direction is not None:
try:
circles = self._conical_face_circle_boundaries(face_id, axis_point, axis_direction)
except Exception:
circles = []
if len(circles) == 2:
sorted_circles = sorted(circles, key=lambda item: float(item["radius"]))
small = sorted_circles[0]
large = sorted_circles[1]
small_center = tuple(float(value) for value in small["center"])
large_center = tuple(float(value) for value in large["center"])
height = _vector_length(_tuple_sub(large_center, small_center))
small_radius = float(small["radius"])
large_radius = float(large["radius"])
if height > 1e-9 and large_radius > small_radius > 1e-9:
boundary_info.update(
{
"feature_cone_small_radius": small_radius,
"feature_cone_small_diameter": small_radius * 2.0,
"feature_cone_large_radius": large_radius,
"feature_cone_large_diameter": large_radius * 2.0,
"feature_cone_height": height,
"feature_cone_boundary_half_angle_degrees": math.degrees(
math.atan((large_radius - small_radius) / height)
),
}
)
result = dict(info)
result.update(
{
"kind": "feature",
"feature_type": "圆锥面候选",
"feature_source_face_id": face_id,
"feature_face_ids": (face_id,),
"feature_highlight_face_ids": (face_id,),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_reference_radius": reference_radius,
"feature_reference_diameter": reference_radius * 2.0 if reference_radius > 0 else "",
"feature_edit_actions": "修改圆锥参考半径/直径/半角",
"feature_mode": (
"这是从 STEP/B-Rep 圆锥面直接识别出的几何候选;简单圆锥会解析重建,"
"嵌入式锥孔/沉孔会优先局部重切,不是 CAD 历史里的锥孔或倒角参数。"
),
**boundary_info,
}
)
return result
def _planar_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
coplanar_face_ids = self._connected_coplanar_planar_face_ids(face_id)
boundary_edge_ids = self._region_boundary_edge_ids(coplanar_face_ids)
shell_info = self._planar_shell_region_info(face_id, coplanar_face_ids, info)
prismatic_info = self._planar_rectangular_profile_info(face_id, coplanar_face_ids, info, shell_info)
if len(coplanar_face_ids) > 1:
scope_note = f"已检测到 {len(coplanar_face_ids)} 个共面且相接/重叠的 face,拉伸/切除时会作为同一片平面区域处理。"
else:
scope_note = "当前 face 没有检测到可一起拉伸/切除的共面相接/重叠邻居。"
edit_actions = "拉伸/切除平面"
if shell_info.get("shell_region_status") == "candidate":
edit_actions += ";调整壳体厚度"
if prismatic_info.get("prismatic_profile_status") == "candidate":
edit_actions = "调整规则矩形轮廓长度/宽度"
if prismatic_info.get("prismatic_extrusion_status") == "candidate":
edit_actions += ";调整棱柱高度/凹槽深度"
else:
edit_actions += ";沿法向拉伸/切除"
highlight_face_ids = set(coplanar_face_ids)
highlight_face_ids.update(_int_values(prismatic_info.get("prismatic_highlight_face_ids")))
result = dict(info)
result.update(
{
"kind": "feature",
"feature_type": "可拉伸/切除平面候选",
"feature_source_face_id": face_id,
"feature_face_ids": tuple(coplanar_face_ids),
"feature_highlight_face_ids": tuple(coplanar_face_ids),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_adjacent_face_ids": tuple(
sorted(set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - set(coplanar_face_ids))
),
"push_pull_scope_face_ids": tuple(coplanar_face_ids),
"push_pull_scope_face_count": len(coplanar_face_ids),
"push_pull_scope_note": scope_note,
"feature_edit_actions": edit_actions,
"feature_mode": "这是从 B-Rep 几何推断出的平面编辑候选,不是 CAD 历史特征。",
**shell_info,
**prismatic_info,
}
)
return result
def _planar_rectangular_profile_info(
self,
face_id: int,
coplanar_face_ids: Iterable[int],
info: dict[str, object],
shell_info: dict[str, object],
) -> dict[str, object]:
region_ids = sorted({int(item) for item in coplanar_face_ids})
if region_ids != [face_id]:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "共面区域包含多个 Face,暂不把整体包围盒当作规则矩形特征尺寸。",
}
edge_ids = self._face_boundary_edge_ids(face_id)
if len(edge_ids) != 4:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "规则矩形轮廓需要恰好四条边。",
}
direction_groups: list[dict[str, object]] = []
for edge_id in edge_ids:
try:
curve = BRepAdaptor_Curve(self.edges[edge_id])
if curve.GetType() != GeomAbs_Line:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "轮廓含非直线边,不按规则矩形特征处理。",
}
start = _point_tuple(curve.Value(curve.FirstParameter()))
end = _point_tuple(curve.Value(curve.LastParameter()))
vector = _tuple_sub(end, start)
length = math.sqrt(_tuple_dot(vector, vector))
direction = _tuple_normalized(vector)
except Exception:
direction = None
length = 0.0
if direction is None or length <= 1e-9:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "矩形轮廓存在退化边或无法读取的直线边。",
}
matched_group = None
for group in direction_groups:
group_direction = _tuple_or_none(group.get("direction"))
if group_direction is not None and abs(_tuple_dot(direction, group_direction)) >= 0.999:
matched_group = group
break
if matched_group is None:
matched_group = {"direction": direction, "lengths": [], "edge_ids": []}
direction_groups.append(matched_group)
matched_group["lengths"].append(length)
matched_group["edge_ids"].append(edge_id)
if len(direction_groups) != 2 or any(len(group["lengths"]) != 2 for group in direction_groups):
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "四条边没有形成两组稳定的平行对边。",
}
first_direction = _tuple_or_none(direction_groups[0].get("direction"))
second_direction = _tuple_or_none(direction_groups[1].get("direction"))
if first_direction is None or second_direction is None or abs(_tuple_dot(first_direction, second_direction)) > 0.01:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "两组对边不垂直,不按规则矩形特征处理。",
}
for group in direction_groups:
lengths = [float(item) for item in group["lengths"]]
average = sum(lengths) / len(lengths)
if max(abs(item - average) for item in lengths) > max(average * 1e-4, 1e-7):
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "矩形候选的相对边长度不一致。",
}
group["average_length"] = average
direction_groups.sort(key=lambda group: float(group["average_length"]), reverse=True)
length = float(direction_groups[0]["average_length"])
width = float(direction_groups[1]["average_length"])
area = _float_or_none(info.get("area"))
area_ratio = area / max(length * width, 1e-12) if area is not None else 0.0
if area is None or abs(area_ratio - 1.0) > 0.01:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "轮廓面积与长乘宽不一致,可能存在内孔或非矩形裁剪。",
"prismatic_profile_area_ratio": area_ratio,
}
adjacent_side_ids = sorted(set(self._adjacent_face_ids_for_edges(edge_ids, face_id)) - {face_id})
try:
opposite_face_id = int(shell_info["shell_opposite_face_id"])
except (KeyError, TypeError, ValueError):
opposite_face_id = None
connected_side_ids: list[int] = []
if opposite_face_id is not None:
for side_id in adjacent_side_ids:
side_neighbors = self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(side_id), side_id)
if opposite_face_id in side_neighbors:
connected_side_ids.append(side_id)
reference_face_ids = [opposite_face_id] if opposite_face_id is not None else []
topology_reference = False
signed_extrusion = _float_or_none(shell_info.get("shell_signed_thickness"))
if len(connected_side_ids) < 2:
source_plane = BRepAdaptor_Surface(self.faces[face_id]).Plane()
source_normal = source_plane.Axis().Direction()
solid_id = self.face_solid_ids[face_id]
tolerance = max(_shape_diagonal(self.faces[face_id]) * 1e-6, 1e-6)
groups: list[dict[str, object]] = []
for side_id in adjacent_side_ids:
neighbors = self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(side_id), side_id)
for candidate_id in neighbors:
if candidate_id == face_id or candidate_id in region_ids:
continue
if solid_id >= 0 and self.face_solid_ids[candidate_id] != solid_id:
continue
try:
candidate_surface = BRepAdaptor_Surface(self.faces[candidate_id])
if candidate_surface.GetType() != GeomAbs_Plane:
continue
candidate_plane = candidate_surface.Plane()
normal_dot = _direction_dot(source_normal, candidate_plane.Axis().Direction())
if abs(normal_dot) < 0.995:
continue
signed_distance = _axis_parameter(
source_plane.Location(),
source_normal,
candidate_plane.Location(),
)
except Exception:
continue
if abs(signed_distance) <= tolerance:
continue
matched_group = None
for group in groups:
if abs(float(group["signed_distance"]) - signed_distance) <= tolerance * 20.0:
matched_group = group
break
if matched_group is None:
matched_group = {
"signed_distance": signed_distance,
"face_ids": set(),
"side_ids": set(),
"normal_dot": normal_dot,
}
groups.append(matched_group)
matched_group["face_ids"].add(candidate_id)
matched_group["side_ids"].add(side_id)
eligible_groups = [group for group in groups if len(group["side_ids"]) >= 2]
if eligible_groups:
eligible_groups.sort(key=lambda group: (-len(group["side_ids"]), abs(float(group["signed_distance"]))))
best_group = eligible_groups[0]
reference_face_ids = sorted(int(item) for item in best_group["face_ids"])
connected_side_ids = sorted(int(item) for item in best_group["side_ids"])
opposite_face_id = reference_face_ids[0]
signed_extrusion = float(best_group["signed_distance"])
topology_reference = True
support_ratio = len(connected_side_ids) / max(len(adjacent_side_ids), 1)
shell_info.update(
{
"shell_region_status": "candidate",
"shell_region_kind": "prismatic-topology-reference",
"shell_source_face_ids": tuple(region_ids),
"shell_opposite_face_id": opposite_face_id,
"shell_thickness_estimate": abs(signed_extrusion),
"shell_signed_thickness": signed_extrusion,
"shell_overlap_ratio_estimate": support_ratio,
"shell_opposite_normal_dot": best_group["normal_dot"],
"shell_confidence": "high" if support_ratio >= 0.99 else "medium",
"shell_note": "通过矩形轮廓的相邻侧壁找到高度/深度基准。",
}
)
extrusion_candidate = opposite_face_id is not None and len(connected_side_ids) >= 2
profile_confidence = "high" if len(adjacent_side_ids) == 4 and area_ratio >= 0.999 else "medium"
feature_type = "规则矩形棱柱候选" if extrusion_candidate else "规则矩形平面候选"
feature_semantics = "generic-prismatic"
if extrusion_candidate and reference_face_ids:
reference_area = 0.0
for reference_id in reference_face_ids:
props = GProp_GProps()
brepgprop.SurfaceProperties(self.faces[reference_id], props)
reference_area += float(props.Mass())
oriented_normal = _tuple_normalized(_tuple_or_none(info.get("oriented_normal")))
source_normal_tuple = _tuple_normalized(
_dir_tuple(BRepAdaptor_Surface(self.faces[face_id]).Plane().Axis().Direction())
)
if reference_area > area * 1.2 and oriented_normal is not None and source_normal_tuple is not None:
outward_offset = float(signed_extrusion or 0.0) * _tuple_dot(source_normal_tuple, oriented_normal)
if outward_offset > 0:
feature_type = "矩形口袋候选"
feature_semantics = "subtractive-pocket"
else:
feature_type = "矩形凸台候选"
feature_semantics = "additive-boss"
result: dict[str, object] = {
"prismatic_profile_status": "candidate",
"prismatic_profile_kind": "rectangular-planar-profile",
"prismatic_profile_confidence": profile_confidence,
"confidence": profile_confidence,
"prismatic_length": length,
"prismatic_width": width,
"prismatic_length_direction": direction_groups[0]["direction"],
"prismatic_width_direction": direction_groups[1]["direction"],
"prismatic_profile_area_ratio": area_ratio,
"prismatic_side_face_ids": tuple(adjacent_side_ids),
"prismatic_connected_side_face_ids": tuple(connected_side_ids),
"prismatic_reference_face_ids": tuple(reference_face_ids),
"prismatic_feature_semantics": feature_semantics,
"prismatic_profile_note": "四条直线边形成两组等长平行对边,面积与长乘宽一致。",
"feature_type": feature_type,
"local_face_width": length,
"local_face_height": width,
"local_face_width_direction": direction_groups[0]["direction"],
"local_face_height_direction": direction_groups[1]["direction"],
}
if extrusion_candidate:
extrusion = _float_or_none(shell_info.get("shell_thickness_estimate"))
extrusion_confidence = "high" if len(connected_side_ids) == 4 else "medium"
result.update(
{
"prismatic_extrusion_status": "candidate",
"prismatic_extrusion_estimate": extrusion if extrusion is not None else "",
"prismatic_reference_face_id": opposite_face_id,
"prismatic_extrusion_confidence": extrusion_confidence,
"confidence": extrusion_confidence,
"prismatic_reference_source": "side-wall-topology" if topology_reference else "overlapping-plane",
"prismatic_highlight_face_ids": tuple(sorted({face_id, *reference_face_ids, *connected_side_ids})),
"prismatic_extrusion_note": "相对平面通过至少两个侧壁与当前矩形面相连。",
}
)
return result
def _planar_shell_region_info(
self,
face_id: int,
coplanar_face_ids: Iterable[int],
info: dict[str, object],
) -> dict[str, object]:
try:
source_surf = BRepAdaptor_Surface(self.faces[face_id])
except Exception:
return {
"shell_region_status": "not-detected",
"shell_region_note": "无法读取当前平面,不能估算壳体区域。",
}
if source_surf.GetType() != GeomAbs_Plane:
return {}
plane = source_surf.Plane()
normal = plane.Axis().Direction()
u_dir, v_dir = _plane_basis_dirs(normal)
valid_region_ids = sorted({int(item) for item in coplanar_face_ids if 0 <= int(item) < len(self.faces)})
if not valid_region_ids:
valid_region_ids = [face_id]
try:
region_shape = _compound_from_shapes(self.faces[item] for item in valid_region_ids)
source_interval = _shape_plane_interval(region_shape, plane.Location(), u_dir, v_dir)
except Exception:
source_interval = _shape_plane_interval(self.faces[face_id], plane.Location(), u_dir, v_dir)
if source_interval is None:
return {
"shell_region_status": "not-detected",
"shell_region_note": "当前平面区域缺少稳定投影范围,不能估算壳体厚度。",
}
def interval_length(interval: tuple[float, float]) -> float:
return max(float(max(interval) - min(interval)), 0.0)
def overlap_length(left: tuple[float, float], right: tuple[float, float]) -> float:
left_min, left_max = min(left), max(left)
right_min, right_max = min(right), max(right)
return max(min(left_max, right_max) - max(left_min, right_min), 0.0)
source_area = max(interval_length(source_interval[0]) * interval_length(source_interval[1]), 1e-12)
diagonal = max(_shape_diagonal(self.shape), 1.0)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
source_solid_id = self.face_solid_ids[face_id]
region_id_set = set(valid_region_ids)
best: dict[str, object] | None = None
best_score: tuple[float, float] | None = None
for candidate_id, face in enumerate(self.faces):
if candidate_id in region_id_set:
continue
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
try:
candidate_surf = BRepAdaptor_Surface(face)
except Exception:
continue
if candidate_surf.GetType() != GeomAbs_Plane:
continue
candidate_plane = candidate_surf.Plane()
normal_dot = _direction_dot(normal, candidate_plane.Axis().Direction())
if abs(normal_dot) < 0.985:
continue
thickness = abs(_axis_parameter(plane.Location(), normal, candidate_plane.Location()))
if thickness <= tolerance:
continue
interval = _shape_plane_interval(face, plane.Location(), u_dir, v_dir)
if interval is None:
continue
overlap_area = overlap_length(source_interval[0], interval[0]) * overlap_length(source_interval[1], interval[1])
overlap_ratio = overlap_area / source_area
if overlap_ratio <= 0.02:
continue
score = (thickness, -overlap_ratio)
if best_score is None or score < best_score:
best_score = score
best = {
"shell_opposite_face_id": candidate_id,
"shell_thickness_estimate": thickness,
"shell_signed_thickness": _axis_parameter(plane.Location(), normal, candidate_plane.Location()),
"shell_overlap_ratio_estimate": overlap_ratio,
"shell_opposite_normal_dot": normal_dot,
}
if best is None:
return {
"shell_region_status": "not-detected",
"shell_region_note": "未找到与当前平面投影重叠的相对平面;暂不能估算局部壳体厚度。",
}
thickness = float(best["shell_thickness_estimate"])
overlap_ratio = float(best["shell_overlap_ratio_estimate"])
local_width = _float_or_none(info.get("local_face_width"))
local_height = _float_or_none(info.get("local_face_height"))
local_spans = [value for value in (local_width, local_height) if value is not None and value > tolerance]
if local_spans and thickness > min(local_spans) * 1.5:
return {
"shell_region_status": "not-detected",
"shell_opposite_face_id": best["shell_opposite_face_id"],
"shell_thickness_estimate": thickness,
"shell_overlap_ratio_estimate": overlap_ratio,
"shell_region_note": "相对平面距离明显大于当前面的局部短边,不按壳体厚度处理。",
}
thin_ratio = thickness / diagonal
if overlap_ratio >= 0.55 and thin_ratio <= 0.08:
confidence = "high"
elif overlap_ratio >= 0.25 and thin_ratio <= 0.18:
confidence = "medium"
else:
confidence = "low"
kind = "thin-wall-opposite-plane-candidate" if thin_ratio <= 0.18 else "opposite-plane-region-candidate"
return {
"shell_region_kind": kind,
"shell_region_status": "candidate",
"shell_confidence": confidence,
"shell_source_face_ids": tuple(valid_region_ids),
**best,
"shell_note": (
"通过同一 solid 内投影重叠的相对平面估算壳体厚度;"
"这是 B-Rep 几何近似,不等同于原 CAD 壳命令参数。"
),
}
def _cylindrical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
side_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id]
surf = BRepAdaptor_Surface(self.faces[face_id])
axis_range = self._cylindrical_axis_range(face_id, surf, side_face_ids)
side_face_set = set(side_face_ids)
boundary_edge_ids = self._region_boundary_edge_ids(side_face_ids)
adjacent_face_ids = sorted(set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - side_face_set)
domain_info = dict(info)
domain_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
domain_info["height_estimate"] = axis_range["span"]
angular_spans: list[float] = []
for side_id in side_face_ids:
try:
side_surface = BRepAdaptor_Surface(self.faces[side_id])
angular_spans.append(abs(side_surface.LastUParameter() - side_surface.FirstUParameter()))
except Exception:
continue
combined_angular_span = min(sum(angular_spans), math.tau) if angular_spans else float(info.get("angular_span", 0.0))
domain_info["angular_span"] = combined_angular_span
end_faces = self._cylindrical_end_face_groups(face_id, adjacent_face_ids, domain_info)
end_face_ids = end_faces["end_face_ids"]
bottom_face_ids = end_faces["bottom_face_ids"]
opening_face_ids = end_faces["opening_face_ids"]
guess = str(info.get("feature_guess", "cylindrical face"))
has_two_axial_caps = bool(end_faces["start_end_face_ids"] and end_faces["end_end_face_ids"])
support_face_ids_for_round = sorted(set(adjacent_face_ids) - set(end_face_ids))
material_toward = str(info.get("material_toward_axis", "") or "")
material_away = str(info.get("material_away_axis", "") or "")
complete_cylinder_with_caps = combined_angular_span >= math.tau * 0.92 and has_two_axial_caps
if (
guess == "round/fillet candidate"
and has_two_axial_caps
and material_toward == "inside"
and "outside" in material_away
and (complete_cylinder_with_caps or len(support_face_ids_for_round) < 2)
):
info = dict(info)
info["feature_guess"] = "boss/outer-round candidate"
info["confidence"] = "medium"
info["note"] = (
"cocylindrical region has material inside its axis and explicit planar caps at both ends"
)
domain_info["feature_guess"] = info["feature_guess"]
slot_info = self._cylindrical_slot_info(face_id, adjacent_face_ids, end_face_ids, domain_info)
fillet_info = self._cylindrical_existing_fillet_info(face_id, adjacent_face_ids, end_face_ids, domain_info)
guess = str(info.get("feature_guess", "cylindrical face"))
angular_span = combined_angular_span
if guess == "hole/groove candidate":
if angular_span < math.tau * 0.92:
feature_type = "槽/半孔候选"
edit_actions = "调整圆柱孔径;调整槽/半孔宽度;调整槽/半孔深度;调整槽/半孔圆弧长度;调整槽/半孔圆弧角度;调整槽孔总长度"
else:
feature_type = "圆柱孔候选"
edit_actions = "调整圆柱孔径"
if info.get("cylinder_end_type") == "blind" and bottom_face_ids:
edit_actions += ";调整盲孔/盲槽深度"
else:
edit_actions += ";孔深调整需要明确盲孔底面"
if angular_span >= math.tau * 0.92:
edit_actions += ";封堵圆柱孔"
elif guess == "round/fillet candidate":
feature_type = "圆角/倒圆候选"
edit_actions = "可尝试修改已有圆角半径;当前版本会先移除圆角面,再在恢复出的锐边上重新倒圆。"
elif guess == "boss/outer-round candidate":
feature_type = "凸台/外圆候选"
edit_actions = "调整圆柱凸台直径;调整圆柱凸台高度;修改圆柱凸台轴心坐标。"
else:
feature_type = "未明确圆柱特征"
edit_actions = "可尝试调整圆柱孔径,但风险较高。"
highlight_face_ids = tuple(
sorted(
{
*side_face_ids,
*end_face_ids,
*slot_info.get("feature_slot_boundary_face_ids", ()),
*fillet_info.get("feature_existing_fillet_support_face_ids", ()),
}
)
)
result = dict(info)
result.update(
{
"kind": "feature",
"feature_type": feature_type,
"feature_source_face_id": face_id,
"feature_face_ids": tuple(side_face_ids),
"feature_side_face_ids": tuple(side_face_ids),
"feature_end_face_ids": tuple(end_face_ids),
"feature_bottom_face_ids": tuple(bottom_face_ids),
"feature_opening_face_ids": tuple(opening_face_ids),
"feature_highlight_face_ids": highlight_face_ids,
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_adjacent_face_ids": tuple(adjacent_face_ids),
"same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]),
"same_domain_height_estimate": axis_range["span"],
"same_domain_angular_span": combined_angular_span,
"angular_span": combined_angular_span,
"is_full_cylinder": combined_angular_span >= math.tau * 0.92,
"same_domain_range_source": axis_range["range_source"],
"same_domain_face_ids": tuple(side_face_ids),
"same_domain_face_count": len(side_face_ids),
"same_domain_note": (
"已把同一实体内同轴、同半径且轴向连续/重叠的圆柱 face 当作同一几何区域。"
if len(side_face_ids) > 1
else "当前圆柱 face 没有检测到可合并选择的同域圆柱面。"
),
"feature_start_end_face_ids": tuple(end_faces["start_end_face_ids"]),
"feature_end_end_face_ids": tuple(end_faces["end_end_face_ids"]),
"feature_bottom_confidence": end_faces["bottom_confidence"],
"feature_bottom_detection": end_faces["bottom_detection"],
"feature_bottom_note": end_faces["bottom_note"],
"feature_edit_actions": edit_actions,
"feature_mode": "这是从 B-Rep 圆柱面、相邻面和材料采样推断出的局部特征候选。",
**slot_info,
**fillet_info,
}
)
return result
def _cylindrical_slot_info(
self,
face_id: int,
adjacent_face_ids: list[int],
end_face_ids: Iterable[int],
info: dict[str, object],
) -> dict[str, object]:
guess = str(info.get("feature_guess", "cylindrical face"))
angular_span = float(info.get("angular_span", 0.0))
if guess != "hole/groove candidate" or angular_span >= math.tau * 0.92:
return {}
radius = max(float(info.get("radius", 0.0)), 0.0)
span = min(max(angular_span, 0.0), math.tau)
boundary_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids))
chord_width = 2.0 * radius * math.sin(span / 2.0) if radius > 0 else 0.0
sagitta_depth = radius * (1.0 - math.cos(min(span, math.pi) / 2.0)) if radius > 0 else 0.0
return {
"slot_kind": "partial-cylindrical-groove",
"slot_status": "candidate",
"slot_angular_span": angular_span,
"slot_open_angle": max(math.tau - span, 0.0),
"slot_chord_width_estimate": chord_width,
"slot_arc_length_estimate": radius * span,
"slot_sagitta_depth_estimate": sagitta_depth,
"feature_slot_face_ids": (face_id,),
"feature_slot_boundary_face_ids": tuple(boundary_face_ids),
"slot_note": (
"这是由局部圆柱面推断出的槽/半孔候选;宽度和深度是几何估算,"
"不是 CAD 历史里的参数。"
),
}
def _cylindrical_existing_fillet_info(
self,
face_id: int,
adjacent_face_ids: list[int],
end_face_ids: Iterable[int],
info: dict[str, object],
) -> dict[str, object]:
if str(info.get("feature_guess", "cylindrical face")) != "round/fillet candidate":
return {}
radius = max(float(info.get("radius", 0.0)), 0.0)
angular_span = min(max(float(info.get("angular_span", 0.0)), 0.0), math.tau)
support_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids))
return {
"existing_fillet_kind": "cylindrical-round-face",
"existing_fillet_status": "candidate",
"existing_fillet_radius_estimate": radius,
"existing_fillet_angular_span": angular_span,
"existing_fillet_arc_length_estimate": radius * angular_span,
"feature_existing_fillet_face_ids": (face_id,),
"feature_existing_fillet_support_face_ids": tuple(support_face_ids),
"existing_fillet_note": (
"这是由局部小半径圆柱面推断出的已有圆角/倒圆候选;"
"当前版本可尝试使用 defeature + 重新倒圆修改半径;"
"复杂 blend 或支撑面不明确时可能失败并回滚。"
),
}
def _cylindrical_end_face_groups(
self,
face_id: int,
adjacent_face_ids: list[int],
info: dict[str, object],
) -> dict[str, object]:
axis_point_values = info.get("axis_point")
axis_values = info.get("axis")
v_range = info.get("v_range")
if not isinstance(axis_point_values, tuple) or not isinstance(axis_values, tuple) or not isinstance(v_range, tuple):
return {
"end_face_ids": [],
"start_end_face_ids": [],
"end_end_face_ids": [],
"bottom_face_ids": [],
"opening_face_ids": [],
"bottom_note": "缺少圆柱轴线或参数范围,无法判断端面/底面。",
}
axis_point = gp_Pnt(*axis_point_values)
axis_dir = gp_Dir(float(axis_values[0]), float(axis_values[1]), float(axis_values[2]))
v_min = min(float(v_range[0]), float(v_range[1]))
v_max = max(float(v_range[0]), float(v_range[1]))
span = max(v_max - v_min, 1e-9)
radius = float(info.get("radius", 0.0))
tolerance = max(span * 0.08, radius * 0.2, 0.05)
start_end_face_ids: list[int] = []
end_end_face_ids: list[int] = []
cap_scan_skipped = False
for adjacent_id in adjacent_face_ids:
match = self._axis_end_match_for_planar_face(
adjacent_id,
axis_point,
axis_dir,
v_min,
v_max,
tolerance,
radial_tolerance=None,
)
if match == "start":
start_end_face_ids.append(adjacent_id)
elif match == "end":
end_end_face_ids.append(adjacent_id)
if info.get("cylinder_end_type") == "blind" and (not start_end_face_ids or not end_end_face_ids):
if len(self.faces) <= 1000:
scanned = self._axis_cap_face_candidates(
face_id,
axis_point,
axis_dir,
v_min,
v_max,
radius,
span,
set(adjacent_face_ids),
)
if not start_end_face_ids:
start_end_face_ids.extend(scanned["start"])
if not end_end_face_ids:
end_end_face_ids.extend(scanned["end"])
else:
cap_scan_skipped = True
bottom_face_ids: list[int] = []
opening_face_ids: list[int] = []
if info.get("start_end_open") is True:
opening_face_ids.extend(start_end_face_ids)
elif info.get("start_end_state") == "inside":
bottom_face_ids.extend(start_end_face_ids)
if info.get("end_end_open") is True:
opening_face_ids.extend(end_end_face_ids)
elif info.get("end_end_state") == "inside":
bottom_face_ids.extend(end_end_face_ids)
end_face_ids = sorted({*start_end_face_ids, *end_end_face_ids})
bottom_face_ids = sorted(set(bottom_face_ids))
opening_face_ids = sorted(set(opening_face_ids))
bottom_detection = "axis-cap-scan" if any(
face_id not in adjacent_face_ids for face_id in bottom_face_ids
) else "adjacent-end-face"
bottom_confidence = "medium" if bottom_detection == "axis-cap-scan" else "high"
if not end_face_ids:
note = "没有在圆柱边界附近找到平面端面。"
if cap_scan_skipped:
note += " 当前模型 Face 数较多,已跳过全模型底面扫描以避免选择时卡顿;如需改盲孔/盲槽深度,可以手动填写底面 Face ID。"
elif bottom_face_ids:
if bottom_detection == "axis-cap-scan":
note = "已通过轴线端部采样和轴线附近圆盘面扫描标记疑似底面;这是几何推断,不是 CAD 历史孔深。"
else:
note = "已根据圆柱轴线端部 inside/outside 采样标记疑似底面;这是几何推断,不是 CAD 历史孔深。"
else:
note = "已找到端面候选,但端部采样显示这些端面更像开口附近的相邻面。"
if cap_scan_skipped:
note += " 当前模型 Face 数较多,已跳过全模型底面扫描以避免选择时卡顿。"
return {
"end_face_ids": end_face_ids,
"start_end_face_ids": sorted(set(start_end_face_ids)),
"end_end_face_ids": sorted(set(end_end_face_ids)),
"bottom_face_ids": bottom_face_ids,
"opening_face_ids": opening_face_ids,
"bottom_confidence": bottom_confidence if bottom_face_ids else "none",
"bottom_detection": bottom_detection if bottom_face_ids else "none",
"bottom_note": note,
}
def _axis_end_match_for_planar_face(
self,
face_id: int,
axis_point: gp_Pnt,
axis_dir: gp_Dir,
v_min: float,
v_max: float,
tolerance: float,
radial_tolerance: float | None,
) -> str | None:
surf = BRepAdaptor_Surface(self.faces[face_id])
if surf.GetType() != GeomAbs_Plane:
return None
normal = surf.Plane().Axis().Direction()
if abs(_direction_dot(normal, axis_dir)) < 0.65:
return None
if radial_tolerance is not None:
center = _surface_center(self.faces[face_id])
if _point_axis_distance(axis_point, axis_dir, center) > radial_tolerance:
return None
parameters = _shape_axis_parameters(self.faces[face_id], axis_point, axis_dir)
if not parameters:
return None
start_distance = min(abs(parameter - v_min) for parameter in parameters)
end_distance = min(abs(parameter - v_max) for parameter in parameters)
if min(start_distance, end_distance) > tolerance:
return None
return "start" if start_distance <= end_distance else "end"
def _axis_cap_face_candidates(
self,
face_id: int,
axis_point: gp_Pnt,
axis_dir: gp_Dir,
v_min: float,
v_max: float,
radius: float,
span: float,
adjacent_face_ids: set[int],
) -> dict[str, list[int]]:
source_solid_id = self.face_solid_ids[face_id]
tolerance = max(span * 0.12, radius * 0.35, 0.08)
radial_tolerance = max(radius * 1.2, tolerance)
start: list[int] = []
end: list[int] = []
for candidate_id in range(len(self.faces)):
if candidate_id == face_id or candidate_id in adjacent_face_ids:
continue
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
match = self._axis_end_match_for_planar_face(
candidate_id,
axis_point,
axis_dir,
v_min,
v_max,
tolerance,
radial_tolerance=radial_tolerance,
)
if match == "start":
start.append(candidate_id)
elif match == "end":
end.append(candidate_id)
return {"start": sorted(set(start)), "end": sorted(set(end))}
def _bottom_face_axis_parameter(
self,
bottom_face_ids: Iterable[int],
axis_point: gp_Pnt,
axis_dir: gp_Dir,
expected_parameter: float,
) -> float | None:
candidates: list[float] = []
for bottom_face_id in bottom_face_ids:
if bottom_face_id < 0 or bottom_face_id >= len(self.faces):
continue
parameters = _shape_axis_parameters(self.faces[bottom_face_id], axis_point, axis_dir)
if not parameters:
continue
candidates.append(sum(parameters) / len(parameters))
if not candidates:
return None
return min(candidates, key=lambda parameter: abs(parameter - expected_parameter))
def _face_boundary_edge_ids(self, face_id: int) -> list[int]:
if face_id in self._face_edge_ids_cache:
return list(self._face_edge_ids_cache[face_id])
if face_id < 0 or face_id >= len(self.faces):
return []
face_edges = list(TopologyExplorer(self.faces[face_id], ignore_orientation=True).edges())
edge_ids: list[int] = []
for edge_id, edge in enumerate(self.edges):
if any(_same_shape(edge, face_edge) for face_edge in face_edges):
edge_ids.append(edge_id)
self._face_edge_ids_cache[face_id] = list(edge_ids)
return edge_ids
def face_first_level_topology(self, face_id: int) -> dict[str, object]:
"""Return the explicit first-level B-Rep neighborhood for a Face.
The current project defines first-level Face topology as the selected
Face region plus Faces that share a boundary Edge with that region.
Vertex-only contacts are reported through boundary vertex counts, but
they are not used as propagation edges at this stage.
"""
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
cached = self._face_first_level_topology_cache.get(face_id)
if cached is not None:
return dict(cached)
source_solid_id = self.face_solid_ids[face_id] if face_id < len(self.face_solid_ids) else -1
source_part_id = self.face_part_ids[face_id] if face_id < len(self.face_part_ids) else -1
try:
same_domain_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id]
except Exception:
same_domain_face_ids = [face_id]
same_domain_face_ids = tuple(sorted({int(item) for item in same_domain_face_ids if 0 <= int(item) < len(self.faces)}))
if not same_domain_face_ids:
same_domain_face_ids = (face_id,)
same_domain_set = set(same_domain_face_ids)
selected_boundary_edge_ids = tuple(self._face_boundary_edge_ids(face_id))
region_boundary_edge_ids = tuple(self._region_boundary_edge_ids(same_domain_face_ids))
shared_edges_by_face: dict[int, list[int]] = {}
for edge_id in region_boundary_edge_ids:
for candidate_id in self._edge_adjacent_face_ids(edge_id):
if candidate_id in same_domain_set:
continue
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
shared_edges_by_face.setdefault(candidate_id, []).append(edge_id)
adjacent_face_ids = tuple(sorted(shared_edges_by_face))
first_level_face_ids = tuple(sorted({*same_domain_face_ids, *adjacent_face_ids}))
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
vertex_points_by_key: dict[tuple[int, int, int], tuple[float, float, float]] = {}
for item in same_domain_face_ids:
explorer = TopExp_Explorer(self.faces[item], TopAbs_VERTEX)
while explorer.More():
vertex = topods.Vertex(explorer.Current())
point = _point_tuple(BRep_Tool.Pnt(vertex))
vertex_points_by_key[self._local_point_key(point, tolerance)] = point
explorer.Next()
boundary_vertex_points = tuple(vertex_points_by_key[key] for key in sorted(vertex_points_by_key))
adjacent_surface_types: list[tuple[int, str]] = []
for adjacent_id in adjacent_face_ids:
adjacent_surface_types.append((adjacent_id, self.face_surface_kind(adjacent_id)))
shared_edge_refs = tuple(
{
"face_id": adjacent_id,
"edge_ids": tuple(sorted(set(edge_ids))),
"edge_count": len(set(edge_ids)),
"surface": self.face_surface_kind(adjacent_id),
}
for adjacent_id, edge_ids in sorted(shared_edges_by_face.items())
)
topology = {
"topology_relation_model": "STEP/B-Rep shared-edge first-level",
"topology_relation_depth": 1,
"topology_relation_scope": "selected same-domain region + direct shared-edge adjacent Faces",
"topology_relation_boundary": "shared-edge",
"topology_ignored_relation_depths": ("second-level", "third-level", "deeper"),
"topology_ignored_relation_note": (
"当前阶段只传播一级关系;相邻 Face 再连接出去的二级、三级拓扑只作为后续目标,不自动递归编辑。"
),
"source_face_id": face_id,
"source_part_id": source_part_id,
"source_solid_id": source_solid_id,
"same_domain_face_ids": same_domain_face_ids,
"same_domain_face_count": len(same_domain_face_ids),
"same_domain_region_kind": "same-domain-region" if len(same_domain_face_ids) > 1 else "single-face",
"selected_boundary_edge_ids": selected_boundary_edge_ids,
"selected_boundary_edge_count": len(selected_boundary_edge_ids),
"first_level_boundary_edge_ids": region_boundary_edge_ids,
"first_level_boundary_edge_count": len(region_boundary_edge_ids),
"first_level_boundary_vertex_points": boundary_vertex_points,
"first_level_boundary_vertex_count": len(boundary_vertex_points),
"first_level_adjacent_face_ids": adjacent_face_ids,
"first_level_adjacent_face_count": len(adjacent_face_ids),
"first_level_adjacent_surface_types": tuple(adjacent_surface_types),
"first_level_shared_edges_by_face": shared_edge_refs,
"first_level_face_ids": first_level_face_ids,
"first_level_face_count": len(first_level_face_ids),
"first_level_topology_note": (
f"已识别当前 Face 区域 {len(same_domain_face_ids)} 个 Face、"
f"边界 Edge {len(region_boundary_edge_ids)} 条、"
f"边界 Vertex {len(boundary_vertex_points)} 个、"
f"共享边一级相邻 Face {len(adjacent_face_ids)} 个;"
"当前编辑计划只处理这些一级关系。"
),
}
for item in same_domain_face_ids:
self._face_first_level_topology_cache[item] = dict(topology)
return dict(topology)
def cylindrical_feature_first_level_topology(self, face_id: int) -> dict[str, object]:
"""Return the first-level B-Rep neighborhood for a cylindrical feature.
For holes and slots, first-level topology means the selected cylindrical
side region and Faces that directly share one of its boundary Edges.
Paired slot ends reached through another planar wall are deliberately
not promoted to first-level topology at this stage.
"""
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
cached = self._cylindrical_first_level_topology_cache.get(face_id)
if cached is not None:
return dict(cached)
info = self.face_info(face_id)
if info.get("surface") != "cylinder":
raise ValueError(f"Face {face_id} is not a cylindrical feature face")
source_solid_id = self.face_solid_ids[face_id] if face_id < len(self.face_solid_ids) else -1
source_part_id = self.face_part_ids[face_id] if face_id < len(self.face_part_ids) else -1
feature = self.feature_info(face_id)
def valid_face_ids(values: object) -> tuple[int, ...]:
result: list[int] = []
for item in _int_values(values):
if item < 0 or item >= len(self.faces):
continue
if source_part_id >= 0 and self.face_part_ids[item] != source_part_id:
continue
if source_solid_id >= 0 and self.face_solid_ids[item] != source_solid_id:
continue
result.append(item)
return tuple(sorted(set(result)))
side_face_ids = valid_face_ids(feature.get("feature_side_face_ids"))
if not side_face_ids:
side_face_ids = valid_face_ids(feature.get("feature_face_ids"))
if not side_face_ids:
try:
side_face_ids = valid_face_ids(self.connected_same_domain_face_ids(face_id))
except Exception:
side_face_ids = ()
if not side_face_ids:
side_face_ids = (face_id,)
side_face_set = set(side_face_ids)
selected_boundary_edge_ids = tuple(self._face_boundary_edge_ids(face_id))
region_boundary_edge_ids = tuple(self._region_boundary_edge_ids(side_face_ids))
shared_edges_by_face: dict[int, list[int]] = {}
for edge_id in region_boundary_edge_ids:
for candidate_id in self._edge_adjacent_face_ids(edge_id):
if candidate_id in side_face_set:
continue
if source_part_id >= 0 and self.face_part_ids[candidate_id] != source_part_id:
continue
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
shared_edges_by_face.setdefault(candidate_id, []).append(edge_id)
adjacent_face_ids = tuple(sorted(shared_edges_by_face))
end_face_ids = valid_face_ids(feature.get("feature_end_face_ids"))
bottom_face_ids = valid_face_ids(feature.get("feature_bottom_face_ids"))
opening_face_ids = valid_face_ids(feature.get("feature_opening_face_ids"))
slot_boundary_face_ids = valid_face_ids(feature.get("feature_slot_boundary_face_ids"))
first_level_face_ids = tuple(sorted({*side_face_ids, *adjacent_face_ids}))
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
vertex_points_by_key: dict[tuple[int, int, int], tuple[float, float, float]] = {}
for edge_id in region_boundary_edge_ids:
if edge_id < 0 or edge_id >= len(self.edges):
continue
explorer = TopExp_Explorer(self.edges[edge_id], TopAbs_VERTEX)
while explorer.More():
vertex = topods.Vertex(explorer.Current())
point = _point_tuple(BRep_Tool.Pnt(vertex))
vertex_points_by_key[self._local_point_key(point, tolerance)] = point
explorer.Next()
boundary_vertex_points = tuple(vertex_points_by_key[key] for key in sorted(vertex_points_by_key))
adjacent_surface_types = tuple((item, self.face_surface_kind(item)) for item in adjacent_face_ids)
shared_edge_refs = tuple(
{
"face_id": adjacent_id,
"edge_ids": tuple(sorted(set(edge_ids))),
"edge_count": len(set(edge_ids)),
"surface": self.face_surface_kind(adjacent_id),
}
for adjacent_id, edge_ids in sorted(shared_edges_by_face.items())
)
angular_span = feature.get("slot_angular_span", feature.get("angular_span", info.get("angular_span")))
topology = {
"topology_relation_model": "STEP/B-Rep cylindrical-feature shared-edge first-level",
"topology_relation_depth": 1,
"topology_relation_scope": "selected cylindrical same-domain side region + direct shared-edge adjacent Faces",
"topology_relation_boundary": "shared-edge",
"topology_ignored_relation_depths": ("second-level", "third-level", "deeper"),
"topology_ignored_relation_note": (
"Only direct shared-edge neighbors of the cylindrical side region are treated as first-level topology. "
"Faces reached through those neighbors are recorded later as second-level or deeper relationships."
),
"source_face_id": face_id,
"source_part_id": source_part_id,
"source_solid_id": source_solid_id,
"feature_type": feature.get("feature_type"),
"feature_guess": feature.get("feature_guess", info.get("feature_guess")),
"slot_kind": feature.get("slot_kind", ""),
"cylinder_end_type": feature.get("cylinder_end_type", info.get("cylinder_end_type")),
"is_full_cylinder": bool(feature.get("is_full_cylinder", info.get("is_full_cylinder", False))),
"angular_span": angular_span,
"cylindrical_feature_side_face_ids": side_face_ids,
"cylindrical_feature_side_face_count": len(side_face_ids),
"cylindrical_feature_selected_boundary_edge_ids": selected_boundary_edge_ids,
"cylindrical_feature_selected_boundary_edge_count": len(selected_boundary_edge_ids),
"cylindrical_feature_boundary_edge_ids": region_boundary_edge_ids,
"cylindrical_feature_boundary_edge_count": len(region_boundary_edge_ids),
"cylindrical_feature_boundary_vertex_points": boundary_vertex_points,
"cylindrical_feature_boundary_vertex_count": len(boundary_vertex_points),
"cylindrical_feature_adjacent_face_ids": adjacent_face_ids,
"cylindrical_feature_adjacent_face_count": len(adjacent_face_ids),
"cylindrical_feature_adjacent_surface_types": adjacent_surface_types,
"cylindrical_feature_shared_edges_by_face": shared_edge_refs,
"cylindrical_feature_first_level_face_ids": first_level_face_ids,
"cylindrical_feature_first_level_face_count": len(first_level_face_ids),
"cylindrical_feature_end_face_ids": end_face_ids,
"cylindrical_feature_end_face_count": len(end_face_ids),
"cylindrical_feature_bottom_face_ids": bottom_face_ids,
"cylindrical_feature_bottom_face_count": len(bottom_face_ids),
"cylindrical_feature_opening_face_ids": opening_face_ids,
"cylindrical_feature_opening_face_count": len(opening_face_ids),
"cylindrical_feature_slot_boundary_face_ids": slot_boundary_face_ids,
"cylindrical_feature_slot_boundary_face_count": len(slot_boundary_face_ids),
"first_level_topology_note": (
f"Cylindrical side Faces={len(side_face_ids)}, boundary Edges={len(region_boundary_edge_ids)}, "
f"boundary Vertices={len(boundary_vertex_points)}, direct adjacent Faces={len(adjacent_face_ids)}. "
"Second-level and deeper propagation is not automatic in this stage."
),
}
for item in side_face_ids:
self._cylindrical_first_level_topology_cache[item] = dict(topology)
return dict(topology)
def face_first_level_facts(self, face_id: int, scope: str = "auto") -> dict[str, object]:
"""Return a unified first-level fact graph for recognition and UI.
This is intentionally a fact layer, not a feature-history guess. It
normalizes the selected region, boundary Edges/Vertices, direct
shared-edge neighbors, and ignored deeper relation depths so Face,
cylinder, hole, slot, and later feature recognizers can share the same
evidence contract.
"""
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
requested_scope = str(scope or "auto")
surface = self.face_surface_kind(face_id)
resolved_scope = requested_scope
if requested_scope == "auto":
resolved_scope = "cylindrical-feature" if surface == "cylinder" else "face"
if resolved_scope not in {"face", "cylindrical-feature"}:
raise ValueError(f"Unknown first-level fact scope: {scope}")
cache_key = (int(face_id), resolved_scope)
cached = self._face_first_level_fact_cache.get(cache_key)
if cached is not None:
return dict(cached)
if resolved_scope == "cylindrical-feature":
topology = self.cylindrical_feature_first_level_topology(face_id)
subject_face_ids = tuple(_int_values(topology.get("cylindrical_feature_side_face_ids"))) or (face_id,)
boundary_edge_ids = tuple(_int_values(topology.get("cylindrical_feature_boundary_edge_ids")))
boundary_vertex_points = tuple(topology.get("cylindrical_feature_boundary_vertex_points") or ())
adjacent_face_ids = tuple(_int_values(topology.get("cylindrical_feature_adjacent_face_ids")))
included_face_ids = tuple(_int_values(topology.get("cylindrical_feature_first_level_face_ids"))) or tuple(
sorted({*subject_face_ids, *adjacent_face_ids})
)
role_groups = (
{
"role": "cylindrical-side",
"face_ids": subject_face_ids,
"count": len(subject_face_ids),
},
{
"role": "direct-adjacent",
"face_ids": adjacent_face_ids,
"count": len(adjacent_face_ids),
},
{
"role": "end/opening",
"face_ids": tuple(_int_values(topology.get("cylindrical_feature_end_face_ids"))),
"count": int(topology.get("cylindrical_feature_end_face_count", 0) or 0),
},
{
"role": "blind-bottom",
"face_ids": tuple(_int_values(topology.get("cylindrical_feature_bottom_face_ids"))),
"count": int(topology.get("cylindrical_feature_bottom_face_count", 0) or 0),
},
{
"role": "slot-boundary",
"face_ids": tuple(_int_values(topology.get("cylindrical_feature_slot_boundary_face_ids"))),
"count": int(topology.get("cylindrical_feature_slot_boundary_face_count", 0) or 0),
},
)
subject_role = "cylindrical side region"
source_model = "cylindrical-feature"
else:
topology = self.face_first_level_topology(face_id)
subject_face_ids = tuple(_int_values(topology.get("same_domain_face_ids"))) or (face_id,)
boundary_edge_ids = tuple(_int_values(topology.get("first_level_boundary_edge_ids")))
boundary_vertex_points = tuple(topology.get("first_level_boundary_vertex_points") or ())
adjacent_face_ids = tuple(_int_values(topology.get("first_level_adjacent_face_ids")))
included_face_ids = tuple(_int_values(topology.get("first_level_face_ids"))) or tuple(
sorted({*subject_face_ids, *adjacent_face_ids})
)
role_groups = (
{
"role": "selected-same-domain-region",
"face_ids": subject_face_ids,
"count": len(subject_face_ids),
},
{
"role": "direct-adjacent",
"face_ids": adjacent_face_ids,
"count": len(adjacent_face_ids),
},
)
subject_role = "selected same-domain Face region"
source_model = "face"
adjacent_surface_types = tuple(topology.get("first_level_adjacent_surface_types") or ()) or tuple(
topology.get("cylindrical_feature_adjacent_surface_types") or ()
)
shared_edges = tuple(topology.get("first_level_shared_edges_by_face") or ()) or tuple(
topology.get("cylindrical_feature_shared_edges_by_face") or ()
)
ignored_depths = tuple(topology.get("topology_ignored_relation_depths") or ("second-level", "third-level"))
summary = (
f"{subject_role}: Face {len(subject_face_ids)}, boundary Edge {len(boundary_edge_ids)}, "
f"boundary Vertex {len(boundary_vertex_points)}, direct adjacent Face {len(adjacent_face_ids)}; "
"deeper relations are recorded as future propagation targets, not edited automatically."
)
facts = {
"first_level_fact_model": "STEP/B-Rep first-level fact graph",
"first_level_fact_source_model": source_model,
"first_level_fact_status": "ready",
"first_level_fact_relation_depth": 1,
"first_level_fact_relation_boundary": "shared-edge",
"first_level_fact_scope": resolved_scope,
"first_level_fact_subject_role": subject_role,
"first_level_fact_subject_face_ids": tuple(sorted(set(subject_face_ids))),
"first_level_fact_subject_face_count": len(set(subject_face_ids)),
"first_level_fact_boundary_edge_ids": tuple(sorted(set(boundary_edge_ids))),
"first_level_fact_boundary_edge_count": len(set(boundary_edge_ids)),
"first_level_fact_boundary_vertex_points": boundary_vertex_points,
"first_level_fact_boundary_vertex_count": len(boundary_vertex_points),
"first_level_fact_adjacent_face_ids": tuple(sorted(set(adjacent_face_ids))),
"first_level_fact_adjacent_face_count": len(set(adjacent_face_ids)),
"first_level_fact_adjacent_surface_types": adjacent_surface_types,
"first_level_fact_shared_edges_by_face": shared_edges,
"first_level_fact_included_face_ids": tuple(sorted(set(included_face_ids))),
"first_level_fact_included_face_count": len(set(included_face_ids)),
"first_level_fact_role_groups": role_groups,
"first_level_fact_ignored_relation_depths": ignored_depths,
"first_level_fact_ignored_relation_note": topology.get("topology_ignored_relation_note", ""),
"first_level_fact_summary": summary,
}
for item in facts["first_level_fact_subject_face_ids"]:
self._face_first_level_fact_cache[(int(item), resolved_scope)] = dict(facts)
self._face_first_level_fact_cache[cache_key] = dict(facts)
return dict(facts)
def _face_boundary_wire_info(self, face: TopoDS_Shape) -> dict[str, object]:
try:
boundary_wires = len(_explore(face, TopAbs_WIRE))
except Exception:
boundary_wires = 0
inner_boundary_wires = max(boundary_wires - 1, 0)
return {
"boundary_wires": boundary_wires,
"inner_boundary_wires": inner_boundary_wires,
"has_inner_boundaries": inner_boundary_wires > 0,
}
def _local_face_deform_readiness(self, face_id: int) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
return {
"local_face_deform_ready": False,
"local_face_deform_blocker": "Face ID 不存在,不能做局部 Face 变形。",
}
solid_id = self.face_solid_ids[face_id] if face_id < len(self.face_solid_ids) else -1
cache_key = solid_id if solid_id >= 0 else -(face_id + 1)
cached = self._local_face_deform_readiness_cache.get(cache_key)
if cached is not None:
return dict(cached)
def remember(info: dict[str, object]) -> dict[str, object]:
self._local_face_deform_readiness_cache[cache_key] = dict(info)
return dict(info)
if solid_id < 0 or solid_id >= len(self.solids):
return remember(
{
"local_face_deform_ready": False,
"local_face_deform_face_count": 1,
"local_face_deform_blocker": "找不到当前 Face 所属 Solid,不能做局部重建。",
}
)
solid_face_ids = [index for index, item in enumerate(self.face_solid_ids) if item == solid_id]
if not solid_face_ids:
solid_face_ids = [face_id]
face_count = len(solid_face_ids)
if face_count > 128:
return remember(
{
"local_face_deform_ready": False,
"local_face_deform_face_count": face_count,
"local_face_deform_blocker": "所属 Solid 的 Face 数量超过 128,当前版本不开放局部重建。",
}
)
source_shape = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else self.faces[face_id]
tolerance = max(_shape_diagonal(source_shape) * 1e-7, 1e-6)
for item in solid_face_ids:
face = self.faces[item]
try:
surface = BRepAdaptor_Surface(face)
except Exception:
return remember(
{
"local_face_deform_ready": False,
"local_face_deform_face_count": face_count,
"local_face_deform_blocker": "所属 Solid 里有 Face 不能稳定读取曲面类型,不能做局部 Face 变形。",
}
)
if surface.GetType() != GeomAbs_Plane:
return remember(
{
"local_face_deform_ready": False,
"local_face_deform_face_count": face_count,
"local_face_deform_blocker": "所属 Solid 含有曲面,当前版本只对简单全平面 Solid 开放局部重建。",
}
)
wire_info = self._face_boundary_wire_info(face)
if bool(wire_info.get("has_inner_boundaries")):
return remember(
{
"local_face_deform_ready": False,
"local_face_deform_face_count": face_count,
"local_face_deform_blocker": "所属 Solid 里有带内孔/内边界的 Face,请优先使用孔、槽或拉伸/切除等专门修改方式。",
}
)
try:
if len(self._local_deform_face_vertex_points(face, tolerance)) < 3:
return remember(
{
"local_face_deform_ready": False,
"local_face_deform_face_count": face_count,
"local_face_deform_blocker": "所属 Solid 里有 Face 顶点环不能稳定读取,不能做局部 Face 变形。",
}
)
except Exception:
return remember(
{
"local_face_deform_ready": False,
"local_face_deform_face_count": face_count,
"local_face_deform_blocker": "所属 Solid 里有 Face 顶点环读取失败,不能做局部 Face 变形。",
}
)
return remember(
{
"local_face_deform_ready": True,
"local_face_deform_face_count": face_count,
"local_face_deform_blocker": "",
}
)
def face_boundary_edge_ids(self, face_id: int) -> list[int]:
if face_id < 0 or face_id >= len(self.faces):
return []
return self._face_boundary_edge_ids(face_id)
def face_logical_id(self, face_id: int) -> int:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
if face_id < len(self.face_logical_ids):
return int(self.face_logical_ids[face_id])
return face_id
def face_region_logical_id(self, face_id: int) -> int:
face_ids = self.connected_same_domain_face_ids(face_id) or [face_id]
return min(self.face_logical_id(item) for item in face_ids if 0 <= item < len(self.faces))
def face_ids_for_logical_id(self, logical_id: int) -> list[int]:
logical_id = int(logical_id)
return [face_id for face_id in range(len(self.faces)) if self.face_logical_id(face_id) == logical_id]
def resolve_face_selection_id(self, face_or_logical_id: int) -> int | None:
logical_matches = self.face_ids_for_logical_id(int(face_or_logical_id))
if logical_matches:
return logical_matches[0]
if 0 <= int(face_or_logical_id) < len(self.faces):
return int(face_or_logical_id)
return None
def assign_logical_face_region(self, logical_id: int, face_ids: Iterable[int]) -> None:
valid_face_ids = sorted({int(face_id) for face_id in face_ids if 0 <= int(face_id) < len(self.faces)})
if not valid_face_ids:
return
for face_id in valid_face_ids:
self.face_logical_ids[face_id] = int(logical_id)
self._quick_face_info_cache.pop(face_id, None)
self._face_info_cache.pop(face_id, None)
self._feature_info_cache.pop(face_id, None)
def assign_logical_face_region_exclusive(self, logical_id: int, face_ids: Iterable[int]) -> None:
valid_face_ids = sorted({int(face_id) for face_id in face_ids if 0 <= int(face_id) < len(self.faces)})
if not valid_face_ids:
return
logical_id = int(logical_id)
replacement_id = max([logical_id, len(self.faces), *[int(item) for item in self.face_logical_ids]], default=logical_id) + 1
for face_id, current_logical_id in enumerate(list(self.face_logical_ids)):
if int(current_logical_id) != logical_id or face_id in valid_face_ids:
continue
self.face_logical_ids[face_id] = replacement_id
replacement_id += 1
self._quick_face_info_cache.pop(face_id, None)
self._face_info_cache.pop(face_id, None)
self._feature_info_cache.pop(face_id, None)
self.assign_logical_face_region(logical_id, valid_face_ids)
def nearest_edge_id_to_point(
self,
edge_ids: Iterable[int],
point: tuple[float, float, float] | None,
) -> int | None:
valid_edge_ids = [int(edge_id) for edge_id in edge_ids if 0 <= int(edge_id) < len(self.edges)]
if not valid_edge_ids:
return None
if point is None:
return valid_edge_ids[0]
px, py, pz = (float(point[0]), float(point[1]), float(point[2]))
best_edge_id: int | None = None
best_distance = math.inf
for edge_id in valid_edge_ids:
try:
samples = discretize_edge(self.edges[edge_id], 0.35)
except Exception:
samples = []
if len(samples) < 2:
try:
curve = BRepAdaptor_Curve(self.edges[edge_id])
samples = [
_point_tuple(curve.Value(curve.FirstParameter())),
_point_tuple(curve.Value(curve.LastParameter())),
]
except Exception:
samples = []
if not samples:
continue
sample_points = [(float(coords[0]), float(coords[1]), float(coords[2])) for coords in samples]
if len(sample_points) == 1:
distance = _point_distance_sq((px, py, pz), sample_points[0])
else:
distance = min(
_point_segment_distance_sq((px, py, pz), start, end)
for start, end in zip(sample_points, sample_points[1:])
)
if distance < best_distance:
best_distance = distance
best_edge_id = edge_id
return best_edge_id if best_edge_id is not None else valid_edge_ids[0]
def nearest_face_id_to_point(
self,
face_ids: Iterable[int],
point: tuple[float, float, float] | None,
) -> int | None:
valid_face_ids = [int(face_id) for face_id in face_ids if 0 <= int(face_id) < len(self.faces)]
if not valid_face_ids:
return None
if point is None:
return valid_face_ids[0]
point_vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(float(point[0]), float(point[1]), float(point[2]))).Vertex()
exact_face_ids = valid_face_ids
if len(valid_face_ids) > 48:
scored_face_ids: list[tuple[float, int]] = []
for face_id in valid_face_ids:
scored_face_ids.append((self._face_bounds_distance_sq(face_id, point), face_id))
scored_face_ids.sort(key=lambda item: item[0])
exact_face_ids = [face_id for _distance, face_id in scored_face_ids[:48]]
best_face_id: int | None = None
best_distance = math.inf
for face_id in exact_face_ids:
try:
extrema = BRepExtrema_DistShapeShape(point_vertex, self.faces[face_id])
extrema.Perform()
if not extrema.IsDone():
continue
distance = float(extrema.Value())
except Exception:
center = _surface_center(self.faces[face_id])
distance = math.sqrt(
_point_distance_sq(
(float(point[0]), float(point[1]), float(point[2])),
_point_tuple(center),
)
)
if distance < best_distance:
best_distance = distance
best_face_id = face_id
return best_face_id if best_face_id is not None else exact_face_ids[0]
def _face_bounds_distance_sq(self, face_id: int, point: tuple[float, float, float]) -> float:
px, py, pz = (float(point[0]), float(point[1]), float(point[2]))
try:
xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(self.faces[face_id])
dx = max(xmin - px, 0.0, px - xmax)
dy = max(ymin - py, 0.0, py - ymax)
dz = max(zmin - pz, 0.0, pz - zmax)
return dx * dx + dy * dy + dz * dz
except Exception:
try:
center = _surface_center(self.faces[face_id])
return _point_distance_sq((px, py, pz), _point_tuple(center))
except Exception:
return math.inf
def _adjacent_face_ids_for_edges(self, edge_ids: list[int], face_id: int) -> list[int]:
if not edge_ids:
return []
source_solid_id = self.face_solid_ids[face_id]
adjacent: set[int] = set()
for edge_id in edge_ids:
if edge_id < 0 or edge_id >= len(self.edges):
continue
for candidate_id in self._edge_adjacent_face_ids(edge_id):
if candidate_id == face_id:
continue
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
adjacent.add(candidate_id)
return sorted(adjacent)
def _connected_coplanar_planar_face_ids(self, face_id: int) -> list[int]:
if face_id < 0 or face_id >= len(self.faces):
return []
source_surf = BRepAdaptor_Surface(self.faces[face_id])
if source_surf.GetType() != GeomAbs_Plane:
return [face_id]
source_solid_id = self.face_solid_ids[face_id]
tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3)
visited = {face_id}
queue = [face_id]
while queue:
current_id = queue.pop(0)
for adjacent_id in self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(current_id), current_id):
if adjacent_id in visited:
continue
if source_solid_id >= 0 and self.face_solid_ids[adjacent_id] != source_solid_id:
continue
candidate_surf = BRepAdaptor_Surface(self.faces[adjacent_id])
if _surfaces_are_coplanar(source_surf, candidate_surf, tolerance):
visited.add(adjacent_id)
queue.append(adjacent_id)
shared_edge_result = sorted(visited)
if len(self.faces) > 600 or len(shared_edge_result) > 1:
return shared_edge_result
interval_tolerance = max(tolerance * 20.0, _shape_diagonal(self.shape) * 1e-6, 1e-4)
plane = source_surf.Plane()
axis_point = plane.Location()
u_dir, v_dir = _plane_basis_dirs(plane.Axis().Direction())
candidates: dict[int, tuple[tuple[float, float], tuple[float, float]]] = {}
for candidate_id, face in enumerate(self.faces):
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
candidate_surf = BRepAdaptor_Surface(face)
if not _surfaces_are_coplanar(source_surf, candidate_surf, tolerance):
continue
interval = _shape_plane_interval(face, axis_point, u_dir, v_dir)
if interval is not None:
candidates[candidate_id] = interval
if face_id in candidates:
visited = {face_id}
queue = [face_id]
while queue:
current_id = queue.pop(0)
current_interval = candidates[current_id]
for candidate_id, candidate_interval in candidates.items():
if candidate_id in visited:
continue
if _plane_intervals_touch_or_overlap(
current_interval,
candidate_interval,
interval_tolerance,
):
visited.add(candidate_id)
queue.append(candidate_id)
return sorted(visited)
return sorted(visited)
def connected_same_domain_face_ids(self, face_id: int) -> list[int]:
if face_id < 0 or face_id >= len(self.faces):
return []
if face_id in self._same_domain_face_ids_cache:
return list(self._same_domain_face_ids_cache[face_id])
source_surf = BRepAdaptor_Surface(self.faces[face_id])
surface_type = source_surf.GetType()
if surface_type == GeomAbs_Plane:
face_ids = self._connected_coplanar_planar_face_ids(face_id)
elif surface_type == GeomAbs_Cylinder:
face_ids = self._connected_cocylindrical_face_ids(face_id)
else:
face_ids = [face_id]
face_ids = sorted(set(face_ids or [face_id]))
for item in face_ids:
self._same_domain_face_ids_cache[item] = list(face_ids)
return list(face_ids)
def _connected_cocylindrical_face_ids(self, face_id: int) -> list[int]:
if face_id < 0 or face_id >= len(self.faces):
return []
source_solid_id = self.face_solid_ids[face_id]
source_surf = BRepAdaptor_Surface(self.faces[face_id])
if source_surf.GetType() != GeomAbs_Cylinder:
return [face_id]
cylinder = source_surf.Cylinder()
axis = cylinder.Axis()
axis_point = axis.Location()
axis_dir = axis.Direction()
radius = max(float(cylinder.Radius()), 0.0)
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
visited = {face_id}
queue = [face_id]
while queue:
current_id = queue.pop(0)
for adjacent_id in self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(current_id), current_id):
if adjacent_id in visited:
continue
if source_solid_id >= 0 and self.face_solid_ids[adjacent_id] != source_solid_id:
continue
candidate_surf = BRepAdaptor_Surface(self.faces[adjacent_id])
if _surfaces_are_cocylindrical(source_surf, candidate_surf, tolerance):
visited.add(adjacent_id)
queue.append(adjacent_id)
shared_edge_result = sorted(visited)
if len(self.faces) > 600 or len(shared_edge_result) > 1:
return shared_edge_result
interval_tolerance = max(tolerance * 50.0, diagonal * 1e-5, radius * 1e-4, 1e-3)
source_interval = _shape_axis_interval(self.faces[face_id], axis_point, axis_dir)
candidates: dict[int, tuple[float, float]] = {}
for candidate_id, face in enumerate(self.faces):
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
candidate_surf = BRepAdaptor_Surface(face)
if not _surfaces_are_cocylindrical(source_surf, candidate_surf, tolerance):
continue
interval = _shape_axis_interval(face, axis_point, axis_dir)
if interval is not None:
candidates[candidate_id] = interval
if source_interval is not None and face_id in candidates:
visited = {face_id}
queue = [face_id]
while queue:
current_id = queue.pop(0)
current_interval = candidates[current_id]
for candidate_id, candidate_interval in candidates.items():
if candidate_id in visited:
continue
if _intervals_touch_or_overlap(
current_interval,
candidate_interval,
interval_tolerance,
):
visited.add(candidate_id)
queue.append(candidate_id)
return sorted(visited)
return sorted(visited)
def _cylindrical_axis_range(
self,
face_id: int,
surf: BRepAdaptor_Surface | None = None,
face_ids: Iterable[int] | None = None,
) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
surf = surf or BRepAdaptor_Surface(self.faces[face_id])
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Selected face is not cylindrical.")
cyl = surf.Cylinder()
axis = cyl.Axis()
axis_point = axis.Location()
axis_dir = axis.Direction()
source_v_min = min(float(surf.FirstVParameter()), float(surf.LastVParameter()))
source_v_max = max(float(surf.FirstVParameter()), float(surf.LastVParameter()))
if face_ids is None:
domain_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id]
else:
domain_face_ids = sorted({int(item) for item in face_ids if 0 <= int(item) < len(self.faces)})
if face_id not in domain_face_ids:
domain_face_ids.append(face_id)
domain_face_ids.sort()
intervals: list[tuple[float, float]] = []
tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3)
for item in domain_face_ids:
item_surf = BRepAdaptor_Surface(self.faces[item])
if item_surf.GetType() != GeomAbs_Cylinder:
continue
if not _surfaces_are_cocylindrical(item_surf, surf, tolerance):
continue
interval = _shape_axis_interval(self.faces[item], axis_point, axis_dir)
if interval is not None:
intervals.append(interval)
if intervals and len(domain_face_ids) > 1:
v_min = min(interval[0] for interval in intervals)
v_max = max(interval[1] for interval in intervals)
range_source = "same-domain-cylinder-faces"
else:
v_min = source_v_min
v_max = source_v_max
range_source = "selected-face-v-range"
return {
"axis_point": axis_point,
"axis_direction": axis_dir,
"v_min": v_min,
"v_max": v_max,
"span": max(v_max - v_min, 0.0),
"source_v_min": source_v_min,
"source_v_max": source_v_max,
"same_domain_face_ids": tuple(domain_face_ids),
"same_domain_face_count": len(domain_face_ids),
"range_source": range_source,
}
def _region_boundary_edge_ids(self, face_ids: Iterable[int]) -> list[int]:
counts: dict[int, int] = {}
for face_id in face_ids:
for edge_id in self._face_boundary_edge_ids(face_id):
counts[edge_id] = counts.get(edge_id, 0) + 1
return sorted(edge_id for edge_id, count in counts.items() if count == 1)
def _push_pull_profile_shape(self, face_ids: Iterable[int]) -> TopoDS_Shape:
valid_face_ids = [int(face_id) for face_id in face_ids if 0 <= int(face_id) < len(self.faces)]
if not valid_face_ids:
raise ValueError("No planar faces were found for push/pull.")
if len(valid_face_ids) > 1:
boundary_edge_ids = self._region_boundary_edge_ids(valid_face_ids)
if boundary_edge_ids:
try:
wire = BRepBuilderAPI_MakeWire()
for edge_id in boundary_edge_ids:
wire.Add(topods.Edge(self.edges[edge_id]))
if not hasattr(wire, "IsDone") or wire.IsDone():
face_builder = BRepBuilderAPI_MakeFace(wire.Wire())
if not hasattr(face_builder, "IsDone") or face_builder.IsDone():
profile = face_builder.Face()
if not profile.IsNull():
_ensure_valid_shape(profile)
return profile
except Exception:
pass
profile_faces = [self.faces[face_id] for face_id in valid_face_ids]
if not profile_faces:
raise ValueError("No planar faces were found for push/pull.")
return _unify_same_domain_shape(_compound_from_shapes(profile_faces))
def _face_region_mapping_specs(self, face_ids: Iterable[int]) -> list[dict[str, object]]:
specs: list[dict[str, object]] = []
seen_logical_ids: set[int] = set()
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
for face_id in sorted({int(item) for item in face_ids if 0 <= int(item) < len(self.faces)}):
logical_id = self.face_region_logical_id(face_id)
if logical_id in seen_logical_ids:
continue
seen_logical_ids.add(logical_id)
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() == GeomAbs_Plane:
plane = surf.Plane()
normal = plane.Axis().Direction()
u_dir, v_dir = _plane_basis_dirs(normal)
interval = _shape_plane_interval(face, plane.Location(), u_dir, v_dir)
if interval is None:
continue
specs.append(
{
"surface": "plane",
"logical_id": logical_id,
"part_id": self.face_part_ids[face_id],
"point": _point_tuple(plane.Location()),
"normal": _dir_tuple(normal),
"u_dir": _dir_tuple(u_dir),
"v_dir": _dir_tuple(v_dir),
"interval": interval,
"tolerance": tolerance,
}
)
elif surf.GetType() == GeomAbs_Cylinder:
cylinder = surf.Cylinder()
axis = cylinder.Axis()
interval = _shape_axis_interval(face, axis.Location(), axis.Direction())
if interval is None:
continue
specs.append(
{
"surface": "cylinder",
"logical_id": logical_id,
"part_id": self.face_part_ids[face_id],
"axis_point": _point_tuple(axis.Location()),
"axis_direction": _dir_tuple(axis.Direction()),
"radius": float(cylinder.Radius()),
"interval": interval,
"tolerance": tolerance,
}
)
return specs
def _apply_face_region_mapping_specs(self, specs: Iterable[dict[str, object]]) -> None:
for spec in specs:
logical_id = int(spec.get("logical_id", -1))
if logical_id < 0:
continue
seed_face_ids = self._matching_face_ids_for_region_spec(spec)
if not seed_face_ids:
continue
region_ids: set[int] = set()
for seed_face_id in seed_face_ids:
region_ids.update(self.connected_same_domain_face_ids(seed_face_id) or [seed_face_id])
self.assign_logical_face_region(logical_id, region_ids)
def _matching_face_ids_for_region_spec(self, spec: dict[str, object]) -> list[int]:
surface = str(spec.get("surface", ""))
part_id = int(spec.get("part_id", -1))
tolerance_value = spec.get("tolerance")
if tolerance_value is None:
tolerance_value = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3)
tolerance = float(tolerance_value)
matches: list[int] = []
for face_id, face in enumerate(self.faces):
if part_id >= 0 and self.face_part_ids[face_id] != part_id:
continue
surf = BRepAdaptor_Surface(face)
if surface == "plane":
if not _surface_matches_plane_spec(surf, spec, tolerance):
continue
plane = surf.Plane()
u_dir = gp_Dir(*spec["u_dir"])
v_dir = gp_Dir(*spec["v_dir"])
interval = _shape_plane_interval(face, gp_Pnt(*spec["point"]), u_dir, v_dir)
if interval is not None and _plane_intervals_touch_or_overlap(interval, spec["interval"], max(tolerance * 20.0, 1e-4)):
matches.append(face_id)
elif surface == "cylinder":
if not _surface_matches_cylinder_spec(surf, spec, tolerance):
continue
interval = _shape_axis_interval(face, gp_Pnt(*spec["axis_point"]), gp_Dir(*spec["axis_direction"]))
interval_tolerance = max(tolerance * 50.0, _shape_diagonal(self.shape) * 1e-5, float(spec.get("radius", 0.0)) * 1e-4, 1e-3)
if interval is not None and _intervals_touch_or_overlap(interval, spec["interval"], interval_tolerance):
matches.append(face_id)
return matches
def _edge_vertex_points(self, edge_id: int) -> tuple[tuple[float, float, float], ...]:
if edge_id < 0 or edge_id >= len(self.edges):
return ()
cached = self._edge_vertex_points_cache.get(edge_id)
if cached is not None:
return tuple(cached)
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
points_by_key: dict[tuple[int, int, int], tuple[float, float, float]] = {}
explorer = TopExp_Explorer(self.edges[edge_id], TopAbs_VERTEX)
while explorer.More():
vertex = topods.Vertex(explorer.Current())
point = _point_tuple(BRep_Tool.Pnt(vertex))
points_by_key[self._local_point_key(point, tolerance)] = point
explorer.Next()
points = tuple(points_by_key[key] for key in sorted(points_by_key))
self._edge_vertex_points_cache[edge_id] = points
return points
def _edge_vertex_key_edge_ids(self) -> dict[tuple[int, int, int, int, int], set[int]]:
cached = self._edge_vertex_key_edge_ids_cache
if cached is not None:
return cached
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
mapping: dict[tuple[int, int, int, int, int], set[int]] = {}
for edge_id in range(len(self.edges)):
part_id = self.edge_part_ids[edge_id] if edge_id < len(self.edge_part_ids) else -1
solid_id = self._edge_solid_id(edge_id)
for point in self._edge_vertex_points(edge_id):
key = (int(part_id), int(solid_id), *self._local_point_key(point, tolerance))
mapping.setdefault(key, set()).add(edge_id)
self._edge_vertex_key_edge_ids_cache = mapping
return mapping
def _edge_shared_vertex_adjacent_edge_ids(self, edge_id: int) -> tuple[int, ...]:
if edge_id < 0 or edge_id >= len(self.edges):
return ()
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
part_id = self.edge_part_ids[edge_id] if edge_id < len(self.edge_part_ids) else -1
solid_id = self._edge_solid_id(edge_id)
mapping = self._edge_vertex_key_edge_ids()
adjacent: set[int] = set()
for point in self._edge_vertex_points(edge_id):
key = (int(part_id), int(solid_id), *self._local_point_key(point, tolerance))
adjacent.update(mapping.get(key, set()))
adjacent.discard(edge_id)
return tuple(sorted(item for item in adjacent if 0 <= item < len(self.edges)))
def edge_first_level_topology(self, edge_id: int) -> dict[str, object]:
"""Return the explicit first-level B-Rep neighborhood for an Edge."""
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
cached = self._edge_first_level_topology_cache.get(edge_id)
if cached is not None:
return dict(cached)
source_part_id = self.edge_part_ids[edge_id] if edge_id < len(self.edge_part_ids) else -1
source_solid_id = self._edge_solid_id(edge_id)
endpoint_points = self._edge_vertex_points(edge_id)
adjacent_edge_ids = self._edge_shared_vertex_adjacent_edge_ids(edge_id)
adjacent_face_ids = tuple(self._edge_adjacent_face_ids(edge_id))
adjacent_surface_types = tuple((face_id, self.face_surface_kind(face_id)) for face_id in adjacent_face_ids)
adjacent_face_boundary_edges: set[int] = set()
shared_edges_by_face: list[dict[str, object]] = []
for face_id in adjacent_face_ids:
boundary_edge_ids = tuple(self._face_boundary_edge_ids(face_id))
adjacent_face_boundary_edges.update(boundary_edge_ids)
shared_edges_by_face.append(
{
"face_id": face_id,
"edge_ids": (edge_id,),
"edge_count": 1,
"surface": self.face_surface_kind(face_id),
"face_boundary_edge_ids": boundary_edge_ids,
}
)
first_level_edge_ids = tuple(sorted({edge_id, *adjacent_edge_ids}))
topology = {
"topology_relation_model": "STEP/B-Rep edge first-level",
"topology_relation_depth": 1,
"topology_relation_scope": "selected Edge + endpoint Vertices + shared-endpoint Edges + direct incident Faces",
"topology_relation_boundary": "shared-vertex/shared-face",
"topology_ignored_relation_depths": ("second-level", "third-level", "deeper"),
"topology_ignored_relation_note": (
"Edge first-level topology stops at endpoint Vertices, shared-endpoint Edges, "
"and Faces that directly use the selected Edge. Edges/Faces reached through those Faces "
"are second-level or deeper and are not propagated automatically in this stage."
),
"source_edge_id": edge_id,
"source_part_id": source_part_id,
"source_solid_id": source_solid_id,
"selected_edge_id": edge_id,
"selected_edge_ids": (edge_id,),
"selected_edge_count": 1,
"first_level_vertex_points": endpoint_points,
"first_level_vertex_count": len(endpoint_points),
"first_level_adjacent_edge_ids": adjacent_edge_ids,
"first_level_adjacent_edge_count": len(adjacent_edge_ids),
"first_level_edge_ids": first_level_edge_ids,
"first_level_edge_count": len(first_level_edge_ids),
"first_level_adjacent_face_ids": adjacent_face_ids,
"first_level_adjacent_face_count": len(adjacent_face_ids),
"first_level_adjacent_surface_types": adjacent_surface_types,
"first_level_shared_edges_by_face": tuple(shared_edges_by_face),
"first_level_adjacent_face_boundary_edge_ids": tuple(sorted(adjacent_face_boundary_edges)),
"first_level_adjacent_face_boundary_edge_count": len(adjacent_face_boundary_edges),
"first_level_topology_note": (
f"Selected Edge 1, endpoint Vertex {len(endpoint_points)}, "
f"shared-endpoint Edge {len(adjacent_edge_ids)}, direct adjacent Face {len(adjacent_face_ids)}. "
"Second-level and deeper propagation is not automatic in this stage."
),
}
self._edge_first_level_topology_cache[edge_id] = dict(topology)
return dict(topology)
def edge_first_level_facts(self, edge_id: int) -> dict[str, object]:
"""Return a unified first-level fact graph for Edge recognition and edit plans."""
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
cached = self._edge_first_level_fact_cache.get(edge_id)
if cached is not None:
return dict(cached)
topology = self.edge_first_level_topology(edge_id)
endpoint_points = tuple(topology.get("first_level_vertex_points") or ())
adjacent_edge_ids = tuple(_int_values(topology.get("first_level_adjacent_edge_ids")))
adjacent_face_ids = tuple(_int_values(topology.get("first_level_adjacent_face_ids")))
included_edge_ids = tuple(_int_values(topology.get("first_level_edge_ids"))) or (edge_id,)
ignored_depths = tuple(topology.get("topology_ignored_relation_depths") or ("second-level", "third-level"))
role_groups = (
{"role": "selected-edge", "edge_ids": (edge_id,), "count": 1},
{"role": "endpoint-vertices", "vertex_points": endpoint_points, "count": len(endpoint_points)},
{"role": "shared-endpoint-edges", "edge_ids": adjacent_edge_ids, "count": len(adjacent_edge_ids)},
{"role": "direct-incident-faces", "face_ids": adjacent_face_ids, "count": len(adjacent_face_ids)},
)
facts = {
"first_level_fact_model": "STEP/B-Rep first-level fact graph",
"first_level_fact_source_model": "edge",
"first_level_fact_status": "ready",
"first_level_fact_relation_depth": 1,
"first_level_fact_relation_boundary": "shared-vertex/shared-face",
"first_level_fact_scope": "edge",
"first_level_fact_subject_role": "selected Edge",
"first_level_fact_subject_edge_ids": (edge_id,),
"first_level_fact_subject_edge_count": 1,
"first_level_fact_boundary_edge_ids": (edge_id,),
"first_level_fact_boundary_edge_count": 1,
"first_level_fact_boundary_vertex_points": endpoint_points,
"first_level_fact_boundary_vertex_count": len(endpoint_points),
"first_level_fact_adjacent_edge_ids": adjacent_edge_ids,
"first_level_fact_adjacent_edge_count": len(adjacent_edge_ids),
"first_level_fact_adjacent_face_ids": adjacent_face_ids,
"first_level_fact_adjacent_face_count": len(adjacent_face_ids),
"first_level_fact_adjacent_surface_types": tuple(topology.get("first_level_adjacent_surface_types") or ()),
"first_level_fact_shared_edges_by_face": tuple(topology.get("first_level_shared_edges_by_face") or ()),
"first_level_fact_included_edge_ids": included_edge_ids,
"first_level_fact_included_edge_count": len(included_edge_ids),
"first_level_fact_included_face_ids": adjacent_face_ids,
"first_level_fact_included_face_count": len(adjacent_face_ids),
"first_level_fact_role_groups": role_groups,
"first_level_fact_ignored_relation_depths": ignored_depths,
"first_level_fact_ignored_relation_note": topology.get("topology_ignored_relation_note", ""),
"first_level_fact_summary": (
f"selected Edge 1, endpoint Vertex {len(endpoint_points)}, "
f"shared-endpoint Edge {len(adjacent_edge_ids)}, direct adjacent Face {len(adjacent_face_ids)}; "
"deeper relations are recorded as future propagation targets, not edited automatically."
),
}
self._edge_first_level_fact_cache[edge_id] = dict(facts)
return dict(facts)
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