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

5714 lines
280 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
from collections import Counter
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 .asitus_bridge import run_asitus_hole_recognition
from .operations import OperationMixin
from .polydata import PolydataMixin
from .recognition_graph import recognize_through_hole_regions
from .recognition_priority import (
external_relation_score_bonus,
feature_recognition_priority,
feature_recognition_priority_label,
feature_recognition_priority_reason,
feature_recognition_sort_key,
)
from .transforms import TransformMixin
from .geometry_utils import * # noqa: F403
from .model_types import PartNode, TopologyStats
from .step_io import (
_load_brep_shape,
_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._asitus_hole_regions_attempted = False
self._asitus_hole_regions_loading = False
self._asitus_hole_region_cache: dict[int, list[int]] = {}
self._asitus_hole_recognition_info: dict[str, object] = {}
self._asitus_face_relation_cache: dict[int, dict[str, object]] = {}
self._asitus_adjacency_relation_cache: dict[tuple[int, int], dict[str, object]] = {}
self._asitus_geometric_relation_cache: dict[tuple[int, int], list[dict[str, object]]] = {}
self._internal_hole_regions_attempted = False
self._internal_hole_region_cache: dict[int, list[int]] = {}
self._internal_recognition_info: dict[str, object] = {}
self._recognition_graph_cache: dict[int, object] = {}
self._through_hole_regions_cache: dict[tuple[object, ...], list[object]] = {}
self._topology_refresh_generation = 0
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._part_solid_count_cache: dict[int, int] = {}
self._open_shell_context_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._editable_feature_candidates_cache: dict[tuple[object, ...], list[dict[str, object]]] = {}
self._cylindrical_feature_candidates_cache: dict[tuple[object, ...], list[dict[str, object]]] = {}
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._face_mesh_deflections: dict[int, float] = {}
self.refresh_topology()
@classmethod
def from_shape(cls, filename: str | Path, shape: TopoDS_Shape, *, name: str | None = None) -> "StepModel":
path = Path(filename)
part_name = name or path.stem or "model"
parts = [PartNode(1, part_name, "part", shape, path=part_name)]
return cls(path, parts, shape)
@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)
@classmethod
def load_internal_brep(cls, filename: str | Path) -> "StepModel":
path = Path(filename)
if not path.exists():
raise FileNotFoundError(path)
return cls.from_shape(path, _load_brep_shape(path), name=path.stem)
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 scdm_local_face_signatures(self) -> list[dict[str, object]]:
"""Return cheap geometry hints used to map SCDM raw objects back to local Face IDs."""
signatures: list[dict[str, object]] = []
for face_id, face in enumerate(self.faces):
try:
surf = BRepAdaptor_Surface(face)
surface_type = surf.GetType()
except Exception:
continue
signature: dict[str, object] = {
"faceId": int(face_id),
"logicalFaceId": self.face_logical_id(face_id),
"surfaceType": SURFACE_TYPES.get(surface_type, f"type {surface_type}"),
}
try:
if surface_type == GeomAbs_Plane:
plane = surf.Plane()
origin = _point_tuple(plane.Location())
normal = _dir_tuple(plane.Axis().Direction())
signature["center"] = origin
signature["axis"] = normal
signature["planeOffset"] = (
origin[0] * normal[0]
+ origin[1] * normal[1]
+ origin[2] * normal[2]
)
elif surface_type == GeomAbs_Cylinder:
cylinder = surf.Cylinder()
axis = cylinder.Axis()
radius = cylinder.Radius()
signature["center"] = _point_tuple(axis.Location())
signature["axis"] = _dir_tuple(axis.Direction())
signature["radius"] = radius
signature["diameter"] = radius * 2.0
elif surface_type == GeomAbs_Cone:
cone = surf.Cone()
signature["center"] = _point_tuple(cone.Location())
signature["axis"] = _dir_tuple(cone.Axis().Direction())
signature["radius"] = cone.RefRadius()
elif surface_type == GeomAbs_Sphere:
sphere = surf.Sphere()
radius = sphere.Radius()
signature["center"] = _point_tuple(sphere.Location())
signature["radius"] = radius
signature["diameter"] = radius * 2.0
elif surface_type == GeomAbs_Torus:
torus = surf.Torus()
signature["center"] = _point_tuple(torus.Location())
signature["axis"] = _dir_tuple(torus.Axis().Direction())
signature["majorRadius"] = torus.MajorRadius()
signature["minorRadius"] = torus.MinorRadius()
except Exception:
pass
signatures.append(signature)
return signatures
def refresh_topology(self) -> None:
self._topology_refresh_generation = int(getattr(self, "_topology_refresh_generation", 0)) + 1
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._asitus_hole_regions_attempted = False
self._asitus_hole_regions_loading = False
self._asitus_hole_region_cache.clear()
self._asitus_hole_recognition_info.clear()
self._asitus_face_relation_cache.clear()
self._asitus_adjacency_relation_cache.clear()
self._asitus_geometric_relation_cache.clear()
self._internal_hole_regions_attempted = False
self._internal_hole_region_cache.clear()
self._internal_recognition_info.clear()
self._recognition_graph_cache.clear()
self._through_hole_regions_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._part_solid_count_cache.clear()
self._open_shell_context_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._editable_feature_candidates_cache.clear()
self._cylindrical_feature_candidates_cache.clear()
self._face_polydata_cache.clear()
self._edge_polydata_cache.clear()
self._mesh_deflection = None
self._face_mesh_deflections.clear()
solid_id = 0
for part in self.display_parts():
part_solids = _explore(part.shape, TopAbs_SOLID)
self._part_solid_count_cache[part.id] = len(part_solids)
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()
self._open_shell_context_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()
self._open_shell_context_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:
info = dict(full_info)
info.update(self._asitus_face_summary_fields(face_id))
if str(info.get("surface") or "") == "cylinder":
try:
hint = self._quick_cylindrical_feature_hint(
face_id,
BRepAdaptor_Surface(self.faces[face_id]),
info,
)
for key in (
"same_domain_face_ids",
"same_domain_face_count",
"feature_highlight_face_ids",
"same_domain_angular_span",
"same_domain_note",
"same_domain_v_range",
"same_domain_height_estimate",
"same_domain_range_source",
):
if key in hint:
info[key] = hint[key]
if bool(hint.get("is_full_cylinder")):
info["is_full_cylinder"] = True
if hint.get("angular_span") is not None:
info["angular_span"] = hint["angular_span"]
except Exception:
info.setdefault("feature_highlight_face_ids", (face_id,))
if not str(info.get("feature_type") or ""):
info.update(self._cylindrical_feature_label_fields(info))
info.update(self._recognition_summary_fields(info))
return 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"] = "拉伸/切除平面"
info.update(
self._local_face_plane_size_info(
face_id,
face=face,
surf=surf,
center=_tuple_or_none(info.get("area_center")),
)
)
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.update(self._local_face_deform_readiness(face_id))
if bool(info.get("local_face_deform_ready")):
info["local_face_deform_status"] = "ready"
info.update(self._local_face_size_parameter_readiness(face_id, info))
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
info.update(self._quick_cylindrical_feature_hint(face_id, surf, info))
if info.get("feature_guess") == "hole/groove candidate":
try:
side_face_ids = _int_values(info.get("same_domain_face_ids")) or [face_id]
axis_range = self._cylindrical_axis_range(face_id, surf, side_face_ids)
info.update(self._cylinder_end_opening_info(face_id, surf, axis_range))
info.update(self._cylindrical_feature_label_fields(info))
except Exception:
pass
info.update(_cylinder_resize_readiness(info))
info.update(_cylinder_depth_readiness(info))
info.update(_cylinder_suppress_readiness(info))
elif info.get("feature_guess") == "boss/outer-round candidate":
info.update(
{
"resize_status": "blocked",
"resize_risk": "blocked",
"resize_blockers": "当前对象快速识别为凸台/外圆;请使用凸台直径/高度/轴心入口,不按孔径重切。",
"resize_warnings": "",
"resize_note": "当前对象快速识别为凸台/外圆;请使用凸台直径/高度/轴心入口,不按孔径重切。",
}
)
info.update(_cylinder_boss_resize_readiness(info))
elif info.get("feature_guess") == "round/fillet candidate":
info.update(
{
"resize_status": "blocked",
"resize_risk": "blocked",
"resize_blockers": "当前对象快速识别为圆角/倒圆;请使用已有圆角半径入口,不按孔径重切。",
"resize_warnings": "",
"resize_note": "当前对象快速识别为圆角/倒圆;请使用已有圆角半径入口,不按孔径重切。",
}
)
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._asitus_face_summary_fields(face_id))
if surface_type == GeomAbs_Cylinder:
info.update(self._asitus_cylindrical_feature_hint_fields(face_id, info))
info.update(self._recognition_summary_fields(info))
self._quick_face_info_cache[face_id] = dict(info)
return dict(info)
def _cylindrical_feature_label_fields(self, info: dict[str, object]) -> dict[str, object]:
guess = str(info.get("feature_guess", "cylindrical face"))
angular_span = _float_or_none(info.get("same_domain_angular_span"))
if angular_span is None:
angular_span = _float_or_none(info.get("angular_span")) or 0.0
is_full = bool(info.get("is_full_cylinder")) or angular_span >= math.tau * 0.92
if guess == "hole/groove candidate":
if not is_full:
return {
"feature_type": "槽/半孔候选",
"feature_edit_actions": (
"调整槽/半孔宽度、深度、圆弧长度、圆弧角度;"
"完整槽孔配对会在执行时计算。"
),
}
edit_actions = "调整圆柱孔径"
if info.get("cylinder_end_type") == "blind":
edit_actions += ";调整盲孔/盲槽深度"
else:
edit_actions += ";孔深调整需要明确盲孔底面"
edit_actions += ";封堵圆柱孔"
return {
"feature_type": "圆柱孔候选",
"feature_edit_actions": edit_actions,
}
if guess == "boss/outer-round candidate":
return {
"feature_type": "凸台/外圆候选",
"feature_edit_actions": "调整圆柱凸台直径;调整圆柱凸台高度;修改圆柱凸台轴心坐标。",
}
if guess == "round/fillet candidate":
return {
"feature_type": "圆角/倒圆候选",
"feature_edit_actions": "可尝试修改已有圆角半径;支撑面会在执行时计算。",
}
return {
"feature_type": "未明确圆柱特征",
"feature_edit_actions": "可查看圆柱直径/半径;复杂语义需要手动扫描或执行计划确认。",
}
def _asitus_cylindrical_feature_hint_fields(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
return {}
if not self._asitus_adjacency_relation_cache and not self._asitus_geometric_relation_cache:
return {}
angle_types: Counter[str] = Counter()
relation_types: Counter[str] = Counter()
related_face_ids: set[int] = set()
for pair, relation in self._asitus_adjacency_relation_cache.items():
if int(face_id) not in pair:
continue
related_face_ids.update(int(item) for item in pair if int(item) != int(face_id))
angle_type = str(relation.get("angle_type") or "adjacent").strip().lower()
if angle_type:
angle_types[angle_type] += 1
for pair, relations in self._asitus_geometric_relation_cache.items():
if int(face_id) not in pair:
continue
related_face_ids.update(int(item) for item in pair if int(item) != int(face_id))
for relation in relations:
relation_type = str(relation.get("relation_type") or "").strip().lower()
if relation_type:
relation_types[relation_type] += 1
if not angle_types and not relation_types:
return {}
guess = str(info.get("feature_guess") or "")
orientation = str(info.get("orientation") or "")
angular_span = _float_or_none(info.get("same_domain_angular_span"))
if angular_span is None:
angular_span = _float_or_none(info.get("angular_span")) or 0.0
is_full = bool(info.get("is_full_cylinder")) or angular_span >= math.tau * 0.92
is_partial = 1.0e-6 < angular_span < math.tau * 0.92
radius = _float_or_none(info.get("radius")) or 0.0
boundary_edges = int(info.get("boundary_edges") or 0)
solid_id = self.face_solid_ids[face_id] if 0 <= face_id < len(self.face_solid_ids) else -1
solid_shape = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else self.shape
solid_diagonal = max(_shape_diagonal(solid_shape), 1.0)
is_small_radius = radius > 0 and radius <= solid_diagonal * 0.04
is_fillet_radius = radius > 0 and radius <= solid_diagonal * 0.12
smooth_count = sum(value for key, value in angle_types.items() if "smooth" in key)
convex_count = sum(value for key, value in angle_types.items() if "convex" in key)
concave_count = sum(value for key, value in angle_types.items() if "concave" in key)
tangent_count = int(relation_types.get("tangent", 0))
coaxial_count = int(relation_types.get("coaxial", 0))
parallel_axis_count = int(relation_types.get("parallel_axis", 0))
planar_relation_count = sum(
int(relation_types.get(key, 0))
for key in ("coplanar", "parallel", "perpendicular")
)
slot_score = 0
if is_partial and guess == "hole/groove candidate":
slot_score += 4
slot_score += min((smooth_count + tangent_count) * 3, 6)
slot_score += min(concave_count * 2, 4)
slot_score += min(planar_relation_count, 3)
fillet_score = 0
if is_partial and (
guess == "round/fillet candidate"
or (is_fillet_radius and guess not in {"hole/groove candidate", "boss/outer-round candidate"})
):
fillet_score += 3
if is_small_radius:
fillet_score += 4
fillet_score += min((smooth_count + tangent_count) * 4, 8)
if boundary_edges >= 4:
fillet_score += 2
boss_score = 0
is_boss_like = (
guess == "boss/outer-round candidate"
or (
str(info.get("material_toward_axis") or "") == "inside"
and "outside" in str(info.get("material_away_axis") or "")
)
or (is_full and orientation == "forward")
)
if is_full and is_boss_like:
boss_score += 5
boss_score += min(convex_count * 2, 4)
boss_score += min(coaxial_count + parallel_axis_count, 4)
boss_score += min(planar_relation_count, 3)
scores = {
"slot": slot_score,
"boss": boss_score,
"fillet": fillet_score,
}
preferred, hint_score = max(scores.items(), key=lambda item: item[1])
if hint_score <= 0:
return {}
labels = {
"slot": "槽 / 半孔",
"boss": "凸台 / 外圆",
"fillet": "圆角 / 倒圆",
}
relation_summary = ", ".join(f"{key}:{value}" for key, value in sorted(relation_types.items()))
angle_summary = ", ".join(f"{key}:{value}" for key, value in sorted(angle_types.items()))
summary_parts = [f"{labels[preferred]} +{hint_score}"]
if relation_summary:
summary_parts.append(f"几何关系 {relation_summary}")
if angle_summary:
summary_parts.append(f"AAG角度 {angle_summary}")
return {
"analysis_situs_feature_hint_status": "ready",
"analysis_situs_feature_hint_preferred": preferred,
"analysis_situs_feature_hint_label": labels[preferred],
"analysis_situs_feature_hint_score": hint_score,
"analysis_situs_feature_hint_summary": "".join(summary_parts),
"analysis_situs_feature_hint_related_face_ids": tuple(sorted(related_face_ids)),
"analysis_situs_slot_hint_score": slot_score,
"analysis_situs_boss_hint_score": boss_score,
"analysis_situs_fillet_hint_score": fillet_score,
}
def _quick_cylindrical_feature_hint(
self,
face_id: int,
surf: BRepAdaptor_Surface,
info: dict[str, object],
) -> dict[str, object]:
"""Cheap cylinder labeling for immediate selection feedback.
This intentionally avoids material-side sampling. It only combines
directly connected co-cylindrical fragments and uses face orientation as
a hint. It must not trigger Analysis Situs or the internal recognition
graph; full edit plans still recompute and guard the real feature
semantics before changing geometry.
"""
radius = _float_or_none(info.get("radius")) or 0.0
selected_span = _float_or_none(info.get("angular_span")) or 0.0
orientation = str(info.get("orientation") or "")
try:
boundary_edges = int(info.get("boundary_edges", 0) or 0)
except (TypeError, ValueError):
boundary_edges = 0
solid_id = self.face_solid_ids[face_id] if 0 <= face_id < len(self.face_solid_ids) else -1
solid_shape = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else self.shape
solid_diagonal = _shape_diagonal(solid_shape)
side_face_ids = [face_id]
combined_span = selected_span
same_domain_note = "快速识别:当前圆柱没有检测到直接相接的同域碎面。"
axis_range: dict[str, object] | None = None
try:
side_face_ids = self._connected_cocylindrical_face_ids(face_id) or [face_id]
spans: list[float] = []
for side_id in side_face_ids:
side_surf = BRepAdaptor_Surface(self.faces[side_id])
if side_surf.GetType() == GeomAbs_Cylinder:
spans.append(abs(float(side_surf.LastUParameter()) - float(side_surf.FirstUParameter())))
if spans:
combined_span = min(sum(spans), math.tau)
axis_range = self._cylindrical_axis_range(face_id, surf, side_face_ids)
same_domain_note = (
f"快速识别:已把 {len(side_face_ids)} 个直接相接的同域圆柱碎面合并判断。"
if len(side_face_ids) > 1
else same_domain_note
)
except Exception:
axis_range = None
result: dict[str, object] = {
"same_domain_face_ids": tuple(side_face_ids),
"same_domain_face_count": len(side_face_ids),
"feature_highlight_face_ids": tuple(side_face_ids),
"angular_span": combined_span,
"same_domain_angular_span": combined_span,
"same_domain_note": same_domain_note,
"is_full_cylinder": combined_span >= math.tau * 0.92,
}
if axis_range is not None:
result.update(
{
"same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]),
"same_domain_height_estimate": axis_range["span"],
"same_domain_range_source": axis_range["range_source"],
"height_estimate": axis_range["span"],
}
)
is_full = bool(result["is_full_cylinder"])
is_partial = not is_full and 1e-6 < combined_span < math.tau * 0.92
is_small_radius = solid_diagonal > 0 and radius <= solid_diagonal * 0.04
is_fillet_radius = solid_diagonal > 0 and radius <= solid_diagonal * 0.12
is_quarter_roundish = 0.15 <= selected_span <= math.pi * 1.05
is_fillet_like = is_partial and is_quarter_roundish and is_fillet_radius and boundary_edges >= 4
if is_full and orientation == "reversed":
result.update(
{
"feature_guess": "hole/groove candidate",
"feature_type": "圆柱孔候选",
"feature_edit_actions": "调整圆柱孔径;孔深、轴心或封堵会在执行时重新确认一级关系。",
"confidence": "medium" if len(side_face_ids) > 1 else "high",
"slot_kind": "",
"slot_status": "",
"slot_angular_span": "",
"slot_open_angle": "",
"slot_chord_width_estimate": "",
"slot_arc_length_estimate": "",
"slot_sagitta_depth_estimate": "",
"note": "快速识别:完整圆柱且 Face 方向为 reversed,先按孔候选处理;完整计划会再做材料采样确认。",
}
)
elif is_full and orientation == "forward":
result.update(
{
"feature_guess": "boss/outer-round candidate",
"feature_type": "凸台/外圆候选",
"feature_edit_actions": "调整凸台/外圆直径;高度和轴心会在执行时重新确认一级关系。",
"confidence": "medium" if len(side_face_ids) > 1 else "high",
"slot_kind": "",
"slot_status": "",
"slot_angular_span": "",
"slot_open_angle": "",
"slot_chord_width_estimate": "",
"slot_arc_length_estimate": "",
"slot_sagitta_depth_estimate": "",
"note": "快速识别:完整圆柱且 Face 方向为 forward,先按凸台/外圆候选处理;完整计划会再做材料采样确认。",
}
)
elif is_fillet_like and is_small_radius:
result.update(
{
"feature_guess": "round/fillet candidate",
"feature_type": "圆角/倒圆候选",
"feature_edit_actions": "可尝试修改已有圆角半径;支撑面会在执行时重新确认。",
"confidence": "medium",
"note": "快速识别:小半径部分圆柱,先按圆角/倒圆候选处理。",
}
)
elif is_partial:
span = min(max(combined_span, 0.0), math.tau)
result.update(
{
"feature_guess": "hole/groove candidate",
"feature_type": "槽/半孔候选",
"feature_edit_actions": "调整槽/半孔宽度、深度、弧长或弧角;执行时会重新确认槽壁一级关系。",
"confidence": "medium" if orientation == "reversed" else "low",
"slot_kind": "partial-cylindrical-groove",
"slot_status": "candidate",
"slot_angular_span": combined_span,
"slot_open_angle": max(math.tau - span, 0.0),
"slot_chord_width_estimate": 2.0 * radius * math.sin(span * 0.5) if radius > 0 else 0.0,
"slot_arc_length_estimate": radius * span,
"slot_sagitta_depth_estimate": radius * (1.0 - math.cos(min(span, math.pi) * 0.5)) if radius > 0 else 0.0,
"note": "快速识别:部分圆柱先按槽/半孔候选处理;完整计划会再做材料采样和边界确认。",
}
)
else:
result.update(
{
"feature_guess": "cylindrical face",
"confidence": "unchecked",
"note": "快速识别无法稳定判断孔、槽、凸台或圆角;执行具体修改时会生成完整计划。",
}
)
return result
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(self._asitus_face_summary_fields(face_id))
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")),
)
)
info.update(self._local_face_size_parameter_readiness(face_id, info))
try:
info.update(self.face_first_level_topology(face_id))
info.update(self.face_first_level_facts(face_id, scope="face"))
except Exception as exc:
info.setdefault("topology_relation_status", "unavailable")
info.setdefault("topology_relation_message", str(exc))
info.setdefault("first_level_planar_relation_status", "unavailable")
info.setdefault("first_level_planar_relation_summary", str(exc))
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))
info.update(self._cylindrical_feature_label_fields(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._asitus_face_summary_fields(face_id))
if surface_type == GeomAbs_Cylinder:
info.update(self._asitus_cylindrical_feature_hint_fields(face_id, info))
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 face_plane_position_along(self, face_id: int, direction: tuple[float, float, float]) -> tuple[float, tuple[float, float, float]] | None:
if face_id < 0 or face_id >= len(self.faces):
return None
try:
surf = BRepAdaptor_Surface(self.faces[face_id])
if surf.GetType() != GeomAbs_Plane:
return None
plane = surf.Plane()
origin = _point_tuple(plane.Location())
normal = _dir_tuple(plane.Axis().Direction())
value = origin[0] * direction[0] + origin[1] * direction[1] + origin[2] * direction[2]
return float(value), normal
except Exception:
return None
def _recognition_summary_fields(self, info: dict[str, object]) -> dict[str, object]:
surface = str(info.get("surface") or "")
user_priority = feature_recognition_priority(info)
user_priority_label = feature_recognition_priority_label(info)
user_priority_reason = feature_recognition_priority_reason(info)
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"
external_relation_bonus = external_relation_score_bonus(info)
if (
external_relation_bonus >= 8
and surface not in FREEFORM_FACE_SURFACES
and confidence in {"low", "unchecked", "none"}
):
confidence = "medium"
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", "封堵孔/槽"),
("existing_fillet_status", "existing_fillet_risk", "existing_fillet_blockers", "已有圆角半径"),
("existing_chamfer_status", "existing_chamfer_risk", "existing_chamfer_blockers", "已有倒角距离"),
("fillet_status", "fillet_risk", "fillet_blockers", "已有圆角半径"),
("chamfer_status", "chamfer_risk", "chamfer_blockers", "倒角"),
)
feature_guess_for_capability = str(info.get("feature_guess") or "")
def capability_is_relevant(status_key: str) -> bool:
if surface != "cylinder":
return True
if feature_guess_for_capability == "hole/groove candidate":
return status_key in {"resize_status", "depth_status", "suppress_status", "cylinder_resize_status"}
if feature_guess_for_capability == "boss/outer-round candidate":
return status_key in {"boss_resize_status", "cylinder_resize_status"}
if feature_guess_for_capability == "round/fillet candidate":
return status_key in {"existing_fillet_status", "fillet_status"}
return status_key in {"resize_status", "cylinder_resize_status"}
relevant_capability_specs = tuple(
spec for spec in capability_specs if capability_is_relevant(spec[0])
)
available_capabilities = {
status_key
for status_key, _risk_key, _blocker_key, _label in relevant_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_open_shell_context = str(info.get("open_shell_context_status") or "").strip() == "limited"
has_existing_chamfer_candidate = str(info.get("existing_chamfer_status") or "").strip() == "candidate"
has_analytic_surface_candidate = surface in {"cone", "sphere", "torus"} and bool(feature_type_text)
if has_existing_chamfer_candidate:
has_planar_push_pull_candidate = False
has_local_face_deform = False
has_shell_candidate = False
is_multistep_prismatic_blocked = str(info.get("multistep_prismatic_status") or "").strip() == "blocked"
if is_multistep_prismatic_blocked:
has_planar_push_pull_candidate = False
has_local_face_deform = False
has_shell_candidate = False
has_analytic_surface_candidate = False
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"
if is_multistep_prismatic_blocked:
risk = "blocked"
confidence = "medium" if confidence not in {"high", "medium"} else confidence
for status_key, risk_key, _blocker_key, _label in relevant_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("asitus_adjacent_face_count") not in {None, ""}:
add(
"analysis_situs_aag",
f"Analysis Situs AAG adjacent Face={info.get('asitus_adjacent_face_count')}",
)
if info.get("asitus_geometric_relation_summary") not in {None, ""}:
add(
"analysis_situs_geometry",
f"Analysis Situs geometry={info.get('asitus_geometric_relation_summary')}",
)
if info.get("analysis_situs_feature_hint_summary") not in {None, ""}:
add(
"analysis_situs_feature_hint",
f"Analysis Situs hint={info.get('analysis_situs_feature_hint_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", "找到相对平面/壳体候选")
if is_multistep_prismatic_blocked:
add("multistep_prismatic", "多台阶过渡面已识别为受限")
if has_open_shell_context:
add("open_shell_context", "识别到开口薄壁壳体上下文")
if has_existing_chamfer_candidate:
add("existing_chamfer_geometry", "识别到简单已有倒角斜面")
add("user_operation_priority", f"常用操作优先级={user_priority_label}")
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 relevant_capability_specs:
status = str(info.get(status_key) or "").strip()
blocker_text = str(info.get(blocker_key) or "").strip()
if status in {"ready", "caution", "candidate"}:
add_action(ready_actions, label)
elif status == "blocked" and blocker_text:
add_action(limited_actions, label)
if has_planar_push_pull_candidate:
add_action(ready_actions, "平面拉伸/切除")
if has_local_face_deform:
add_action(ready_actions, "当前面面内长度/面内宽度/中心/偏移")
elif str(info.get("local_face_deform_blocker") or "").strip():
add_action(limited_actions, "局部重建尺寸/中心/偏移")
if has_shell_candidate:
add_action(ready_actions, "壳体厚度")
if has_open_shell_context:
add_action(limited_actions, "完整抽壳/开口面编辑")
if has_slot_candidate:
add_action(ready_actions, "槽/半孔宽度/深度/弧长")
if has_existing_chamfer_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 (
"multistep_prismatic_blockers",
"local_face_deform_blocker",
"first_level_topology_blockers",
"open_shell_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 relevant_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 += external_relation_bonus
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"优先级={user_priority_label}",
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_user_priority": user_priority,
"recognition_user_priority_label": user_priority_label,
"recognition_user_priority_reason": user_priority_reason,
"recognition_evidence": "".join(evidence),
"recognition_evidence_keys": tuple(evidence_keys),
"recognition_external_relation_score_bonus": external_relation_bonus,
"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}"
priority = feature_recognition_priority(related)
related.update(
{
"association_source_face_id": candidate_id,
"association_hop_count": hop_count,
"association_relation": "shared-edge-topology",
"association_label": association_label,
"association_priority": priority,
"recognition_user_priority": priority,
"recognition_user_priority_label": feature_recognition_priority_label(related),
"recognition_user_priority_reason": feature_recognition_priority_reason(related),
}
)
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)
multistep_info = self._planar_multistep_prismatic_block_info(face_id, info, prismatic_info)
open_shell_info = self._planar_open_shell_context_info(face_id, info, shell_info, prismatic_info)
chamfer_info = self._planar_existing_chamfer_info(face_id, info)
if len(coplanar_face_ids) > 1:
scope_note = f"已检测到 {len(coplanar_face_ids)} 个共面且相接/重叠的 face,拉伸/切除时会作为同一片平面区域处理。"
else:
scope_note = "当前 face 没有检测到可一起拉伸/切除的共面相接/重叠邻居。"
edit_actions = "拉伸/切除平面"
feature_type = "可拉伸/切除平面候选"
feature_mode = "这是从 B-Rep 几何推断出的平面编辑候选,不是 CAD 历史特征。"
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 += ";沿法向拉伸/切除"
if chamfer_info.get("existing_chamfer_status") == "candidate":
feature_type = "已有倒角平面候选"
edit_actions = "修改已有倒角距离"
feature_mode = (
"这是从小面积斜平面、两张支撑平面和一级边界推断出的已有倒角候选;"
"当前只按简单等距直线倒角处理,不恢复 CAD 历史特征。"
)
if multistep_info.get("multistep_prismatic_status") == "blocked":
edit_actions = "只读诊断;当前不开放多台阶凸台整组联动修改"
feature_type = multistep_info.get("feature_type", "复杂多台阶凸台过渡面(暂不支持修改)")
if open_shell_info.get("open_shell_context_status") == "limited" and multistep_info.get(
"multistep_prismatic_status"
) != "blocked":
edit_actions += ";完整抽壳/开口面编辑暂未实现"
highlight_face_ids = set(coplanar_face_ids)
highlight_face_ids.update(_int_values(prismatic_info.get("prismatic_highlight_face_ids")))
highlight_face_ids.update(_int_values(open_shell_info.get("open_shell_highlight_face_ids")))
highlight_face_ids.update(_int_values(chamfer_info.get("feature_existing_chamfer_support_face_ids")))
highlight_face_ids.update(_int_values(chamfer_info.get("feature_existing_chamfer_end_face_ids")))
result = dict(info)
result.update(
{
"kind": "feature",
"feature_type": feature_type,
"feature_source_face_id": face_id,
"feature_face_ids": tuple(coplanar_face_ids),
"feature_highlight_face_ids": tuple(sorted(highlight_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": feature_mode,
**shell_info,
**prismatic_info,
**chamfer_info,
**multistep_info,
**open_shell_info,
}
)
if multistep_info.get("multistep_prismatic_status") == "blocked":
result.update(
{
"feature_type": multistep_info.get("feature_type", "复杂多台阶凸台过渡面(暂不支持修改)"),
"feature_edit_actions": edit_actions,
"feature_mode": (
"这是多台阶凸台/基座之间的过渡平面;当前阶段只支持顶层规则矩形台阶独立编辑,"
"不把整组台阶联动重建伪装成可改参数。"
),
"recognition_confidence": "medium",
"recognition_risk": "blocked",
"recognition_blockers": multistep_info.get("multistep_prismatic_blockers", ""),
}
)
elif chamfer_info.get("existing_chamfer_status") == "candidate":
result.update(
{
"feature_type": "已有倒角平面候选",
"feature_guess": "chamfer candidate",
"feature_edit_actions": "修改已有倒角距离",
"feature_mode": feature_mode,
"local_face_deform_ready": False,
"local_face_deform_blocker": "",
}
)
return result
def _planar_existing_chamfer_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
if info.get("surface") != "plane":
return {}
if bool(info.get("has_inner_boundaries")):
return {}
try:
solid_id = int(info.get("solid_id"))
except (TypeError, ValueError):
solid_id = -1
if solid_id < 0 or solid_id >= len(self.solids):
return {}
normal = _tuple_or_none(info.get("normal"))
if normal is None:
return {}
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
if len(boundary_edge_ids) != 4:
return {}
edge_rows: list[dict[str, object]] = []
for edge_id in boundary_edge_ids:
try:
edge_info = self.edge_info(edge_id)
except Exception:
continue
if edge_info.get("curve") != "line":
return {}
length = _float_or_none(edge_info.get("length"))
direction = _tuple_or_none(edge_info.get("direction"))
if length is None or length <= 1e-9 or direction is None:
return {}
edge_rows.append(
{
"edge_id": edge_id,
"length": float(length),
"direction": direction,
}
)
if len(edge_rows) != 4:
return {}
lengths = sorted(float(row["length"]) for row in edge_rows)
shortest = lengths[0]
second_shortest = lengths[1]
longest = lengths[-1]
diagonal = max(_shape_diagonal(self.solids[solid_id][1]), 1.0)
length_tolerance = max(shortest * 0.18, diagonal * 1e-5, 1e-5)
if abs(second_shortest - shortest) > length_tolerance:
return {}
if longest < shortest * 2.0:
return {}
support_candidates: list[dict[str, object]] = []
end_face_ids: list[int] = []
adjacent_face_ids = sorted(set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - {face_id})
for adjacent_id in adjacent_face_ids:
if self.face_solid_ids[adjacent_id] != solid_id:
continue
try:
adjacent_info = self.face_info(adjacent_id)
except Exception:
continue
if adjacent_info.get("surface") != "plane":
continue
adjacent_normal = _tuple_or_none(adjacent_info.get("normal"))
if adjacent_normal is None:
continue
chamfer_dot = abs(_tuple_dot(normal, adjacent_normal))
if 0.52 <= chamfer_dot <= 0.86:
support_candidates.append(
{
"face_id": adjacent_id,
"normal": adjacent_normal,
"chamfer_dot": chamfer_dot,
}
)
elif chamfer_dot <= 0.16:
end_face_ids.append(adjacent_id)
best_pair: tuple[float, dict[str, object], dict[str, object]] | None = None
target_chamfer_dot = math.sqrt(0.5)
for left_index, left in enumerate(support_candidates):
for right in support_candidates[left_index + 1 :]:
support_dot = abs(_tuple_dot(left["normal"], right["normal"]))
if support_dot > 0.22:
continue
balance = abs(float(left["chamfer_dot"]) - target_chamfer_dot) + abs(
float(right["chamfer_dot"]) - target_chamfer_dot
)
score = support_dot + balance
if best_pair is None or score < best_pair[0]:
best_pair = (score, left, right)
if best_pair is None:
return {}
support_face_ids = tuple(sorted((int(best_pair[1]["face_id"]), int(best_pair[2]["face_id"]))))
distance_estimate = shortest / math.sqrt(2.0)
if distance_estimate <= 0.0 or distance_estimate > diagonal * 0.16:
return {}
short_edge_ids = tuple(
int(row["edge_id"])
for row in edge_rows
if abs(float(row["length"]) - shortest) <= length_tolerance
)
long_edge_rows = sorted(edge_rows, key=lambda row: float(row["length"]), reverse=True)
long_edge_ids = tuple(int(row["edge_id"]) for row in long_edge_rows[:2])
chamfer_angle = math.degrees(math.acos(max(-1.0, min(1.0, float(best_pair[1]["chamfer_dot"])))))
support_angle = math.degrees(
math.acos(max(-1.0, min(1.0, abs(_tuple_dot(best_pair[1]["normal"], best_pair[2]["normal"])))))
)
warnings: list[str] = []
if abs(support_angle - 90.0) > 8.0:
warnings.append("两张支撑面不是严格 90 度,倒角距离只是近似估算。")
if abs(chamfer_angle - 45.0) > 8.0:
warnings.append("倒角面不是严格 45 度,当前只按等距倒角近似处理。")
return {
"feature_guess": "chamfer candidate",
"existing_chamfer_kind": "planar-equal-distance-straight-chamfer",
"existing_chamfer_status": "candidate",
"existing_chamfer_risk": "medium" if not warnings else "high",
"existing_chamfer_warnings": "".join(warnings),
"existing_chamfer_blockers": "",
"existing_chamfer_distance_estimate": distance_estimate,
"existing_chamfer_cross_edge_length_estimate": shortest,
"existing_chamfer_long_edge_length_estimate": longest,
"existing_chamfer_support_angle_degrees": support_angle,
"existing_chamfer_face_angle_degrees": chamfer_angle,
"feature_existing_chamfer_face_ids": (face_id,),
"feature_existing_chamfer_support_face_ids": support_face_ids,
"feature_existing_chamfer_end_face_ids": tuple(sorted(set(end_face_ids))),
"feature_existing_chamfer_short_edge_ids": short_edge_ids,
"feature_existing_chamfer_long_edge_ids": long_edge_ids,
"existing_chamfer_note": (
"这是由小面积斜平面和两张近似垂直支撑面推断出的简单等距倒角;"
"当前只承诺单个直线倒角面,倒角链、不等距倒角和复杂过渡面会继续保持受限。"
),
}
def _planar_multistep_prismatic_block_info(
self,
face_id: int,
info: dict[str, object],
prismatic_info: dict[str, object],
) -> dict[str, object]:
if info.get("surface") != "plane":
return {}
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
inner_boundary_wires = int(info.get("inner_boundary_wires", 0) or 0)
if inner_boundary_wires > 1:
return {}
if inner_boundary_wires == 1 and len(boundary_edge_ids) < 6:
return {}
if str(prismatic_info.get("prismatic_feature_semantics") or "") in {"additive-boss", "subtractive-pocket"}:
return {}
try:
source_surface = BRepAdaptor_Surface(self.faces[face_id])
if source_surface.GetType() != GeomAbs_Plane:
return {}
source_plane = source_surface.Plane()
source_normal = source_plane.Axis().Direction()
except Exception:
return {}
adjacent_face_ids = sorted(set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - {face_id})
if len(adjacent_face_ids) < 6:
return {}
solid_id = self.face_solid_ids[face_id]
tolerance = max(_shape_diagonal(self.faces[face_id]) * 1e-6, 1e-6)
positive_parallel_faces: set[int] = set()
negative_parallel_faces: set[int] = set()
positive_side_faces: set[int] = set()
negative_side_faces: set[int] = set()
for side_id in adjacent_face_ids:
if side_id < 0 or side_id >= len(self.faces):
continue
try:
side_surface = BRepAdaptor_Surface(self.faces[side_id])
except Exception:
continue
if side_surface.GetType() != GeomAbs_Plane:
continue
side_neighbors = self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(side_id), side_id)
for candidate_id in side_neighbors:
if candidate_id == face_id or candidate_id < 0 or candidate_id >= len(self.faces):
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
if signed_distance > 0:
positive_parallel_faces.add(candidate_id)
positive_side_faces.add(side_id)
else:
negative_parallel_faces.add(candidate_id)
negative_side_faces.add(side_id)
if not positive_parallel_faces or not negative_parallel_faces:
return {}
blockers = (
"当前 Face 位于多台阶凸台/基座的中间过渡位置,上下两侧都连接了其它平行台阶面;"
"当前阶段只支持顶层规则矩形台阶独立修改,暂不支持多台阶整组联动重建。"
)
return {
"multistep_prismatic_status": "blocked",
"multistep_prismatic_kind": "intermediate-step-plane",
"multistep_prismatic_blockers": blockers,
"multistep_prismatic_note": blockers,
"multistep_prismatic_positive_face_ids": tuple(sorted(positive_parallel_faces)),
"multistep_prismatic_negative_face_ids": tuple(sorted(negative_parallel_faces)),
"multistep_prismatic_positive_side_face_ids": tuple(sorted(positive_side_faces)),
"multistep_prismatic_negative_side_face_ids": tuple(sorted(negative_side_faces)),
"feature_type": "复杂多台阶凸台过渡面(暂不支持修改)",
}
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"],
}
result.update(self._local_face_size_parameter_readiness(face_id, {**info, **result}))
prismatic_rebuild_blockers: list[str] = []
if extrusion_candidate:
extrusion = _float_or_none(shell_info.get("shell_thickness_estimate"))
extrusion_confidence = "high" if len(connected_side_ids) == 4 else "medium"
reference_face_id = opposite_face_id
part_id = self.face_part_ids[face_id] if 0 <= face_id < len(self.face_part_ids) else -1
part_solid_count = self._part_solid_count_cache.get(part_id, 0)
if feature_semantics not in {"additive-boss", "subtractive-pocket"}:
prismatic_rebuild_blockers.append("当前矩形平面还没有稳定判断为凸台或口袋。")
if len(connected_side_ids) != 4:
prismatic_rebuild_blockers.append("当前矩形特征需要四个侧壁都能识别后才开放长宽局部重建。")
if reference_face_id is None:
prismatic_rebuild_blockers.append("当前矩形特征缺少稳定参考面。")
if extrusion is None or extrusion <= 1e-9:
prismatic_rebuild_blockers.append("当前矩形特征缺少稳定高度/深度。")
if part_solid_count != 1:
prismatic_rebuild_blockers.append("矩形凸台/口袋长宽局部重建当前只对单 Solid 零件开放。")
result.update(
{
"prismatic_extrusion_status": "candidate",
"prismatic_extrusion_estimate": extrusion if extrusion is not None else "",
"prismatic_reference_face_id": reference_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": "相对平面通过至少两个侧壁与当前矩形面相连。",
"part_solid_count": part_solid_count,
}
)
else:
prismatic_rebuild_blockers.append("当前矩形平面还没有识别出稳定高度/深度,不能按完整凸台/口袋局部重建。")
if prismatic_rebuild_blockers:
result.update(
{
"rectangular_prismatic_size_rebuild_ready": False,
"rectangular_prismatic_size_rebuild_blocker": "".join(prismatic_rebuild_blockers),
}
)
else:
result.update(
{
"rectangular_prismatic_size_rebuild_ready": True,
"rectangular_prismatic_size_rebuild_blocker": "",
}
)
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 _open_shell_context_for_solid(self, solid_id: int) -> dict[str, object]:
cached = self._open_shell_context_cache.get(solid_id)
if cached is not None:
return dict(cached)
result: dict[str, object] = {"open_shell_context_status": "not-detected"}
if solid_id < 0 or solid_id >= len(self.solids):
self._open_shell_context_cache[solid_id] = dict(result)
return result
face_ids = [index for index, current_solid_id in enumerate(self.face_solid_ids) if current_solid_id == solid_id]
if len(face_ids) < 10:
self._open_shell_context_cache[solid_id] = dict(result)
return result
solid_shape = self.solids[solid_id][1]
try:
bounds = _shape_bounds_info(solid_shape)
except Exception:
self._open_shell_context_cache[solid_id] = dict(result)
return result
bbox_min = _tuple_or_none(bounds.get("bbox_min"))
bbox_max = _tuple_or_none(bounds.get("bbox_max"))
bbox_size = _tuple_or_none(bounds.get("bbox_size"))
if bbox_min is None or bbox_max is None or bbox_size is None:
self._open_shell_context_cache[solid_id] = dict(result)
return result
positive_sizes = [value for value in bbox_size if value > 1e-9]
if len(positive_sizes) < 3:
self._open_shell_context_cache[solid_id] = dict(result)
return result
diagonal = max(_shape_diagonal(solid_shape), 1.0)
tolerance = min(max(diagonal * 1e-6, 1e-6), 1e-3)
smallest_solid_span = min(positive_sizes)
narrow_span_limit = max(smallest_solid_span * 0.28, diagonal * 0.015, tolerance * 10.0)
side_areas = [[0.0, 0.0] for _ in range(3)]
side_face_ids: list[list[list[int]]] = [[[], []] for _ in range(3)]
narrow_face_ids: set[int] = set()
narrow_spans: list[float] = []
planar_face_ids: list[int] = []
for candidate_id in face_ids:
try:
surf = BRepAdaptor_Surface(self.faces[candidate_id])
except Exception:
continue
if surf.GetType() != GeomAbs_Plane:
continue
planar_face_ids.append(candidate_id)
candidate_info = self.face_info(candidate_id)
area = _float_or_none(candidate_info.get("area")) or 0.0
center = _tuple_or_none(candidate_info.get("area_center")) or _tuple_or_none(candidate_info.get("bbox_center"))
if center is not None and area > 0.0:
for axis_index in range(3):
axis_tolerance = max(tolerance, abs(bbox_size[axis_index]) * 1e-5)
if abs(center[axis_index] - bbox_min[axis_index]) <= axis_tolerance:
side_areas[axis_index][0] += area
side_face_ids[axis_index][0].append(candidate_id)
if abs(center[axis_index] - bbox_max[axis_index]) <= axis_tolerance:
side_areas[axis_index][1] += area
side_face_ids[axis_index][1].append(candidate_id)
local_width = _float_or_none(candidate_info.get("local_face_width"))
local_height = _float_or_none(candidate_info.get("local_face_height"))
spans = [value for value in (local_width, local_height) if value is not None and value > tolerance]
if len(spans) >= 2:
small = min(spans)
large = max(spans)
if small <= narrow_span_limit and large >= small * 2.2:
narrow_face_ids.add(candidate_id)
narrow_spans.append(small)
if len(planar_face_ids) < 10 or len(narrow_face_ids) < 4 or not narrow_spans:
self._open_shell_context_cache[solid_id] = dict(result)
return result
sorted_spans = sorted(narrow_spans)
wall_thickness = sorted_spans[len(sorted_spans) // 2]
open_candidates: list[tuple[float, int, int, float, float]] = []
for axis_index in range(3):
other_axes = [index for index in range(3) if index != axis_index]
full_side_area = max(bbox_size[other_axes[0]] * bbox_size[other_axes[1]], 1e-12)
minus_ratio = side_areas[axis_index][0] / full_side_area
plus_ratio = side_areas[axis_index][1] / full_side_area
for open_side_index, open_ratio, closed_ratio in (
(0, minus_ratio, plus_ratio),
(1, plus_ratio, minus_ratio),
):
if closed_ratio >= 0.72 and 0.02 <= open_ratio <= 0.68:
contrast = closed_ratio - open_ratio
open_candidates.append((contrast, axis_index, open_side_index, open_ratio, closed_ratio))
if not open_candidates:
self._open_shell_context_cache[solid_id] = dict(result)
return result
open_candidates.sort(reverse=True)
_contrast, open_axis, open_side_index, open_ratio, closed_ratio = open_candidates[0]
rim_face_ids = set(side_face_ids[open_axis][open_side_index])
closed_side_face_ids = set(side_face_ids[open_axis][1 - open_side_index])
interior_face_ids: set[int] = set()
offset_tolerance = max(wall_thickness * 0.38, tolerance * 10.0)
for candidate_id in planar_face_ids:
candidate_info = self.face_info(candidate_id)
center = _tuple_or_none(candidate_info.get("area_center")) or _tuple_or_none(candidate_info.get("bbox_center"))
normal = _tuple_normalized(_tuple_or_none(candidate_info.get("normal")))
if center is None or normal is None:
continue
for axis_index in range(3):
if abs(normal[axis_index]) < 0.92:
continue
side_distance = min(
abs(center[axis_index] - bbox_min[axis_index]),
abs(center[axis_index] - bbox_max[axis_index]),
)
if abs(side_distance - wall_thickness) <= offset_tolerance:
interior_face_ids.add(candidate_id)
open_direction = [0.0, 0.0, 0.0]
open_direction[open_axis] = 1.0 if open_side_index == 1 else -1.0
axis_names = ("X", "Y", "Z")
related_face_ids = set(narrow_face_ids)
related_face_ids.update(rim_face_ids)
related_face_ids.update(interior_face_ids)
related_face_ids.update(closed_side_face_ids)
blockers = (
"已识别为开口薄壁壳体上下文;当前只支持局部壳体厚度、平面推拉或整体缩放,"
"完整抽壳/开口面特征级重建暂未实现。"
)
result = {
"open_shell_context_status": "limited",
"open_shell_kind": "open-thin-wall-cavity",
"open_shell_axis": axis_names[open_axis],
"open_shell_side": "max" if open_side_index == 1 else "min",
"open_shell_open_direction": tuple(open_direction),
"open_shell_wall_thickness_estimate": wall_thickness,
"open_shell_open_side_area_ratio": open_ratio,
"open_shell_closed_side_area_ratio": closed_ratio,
"open_shell_rim_face_ids": tuple(sorted(rim_face_ids)),
"open_shell_closed_side_face_ids": tuple(sorted(closed_side_face_ids)),
"open_shell_inner_face_ids": tuple(sorted(interior_face_ids)),
"open_shell_thin_wall_face_ids": tuple(sorted(narrow_face_ids)),
"open_shell_related_face_ids": tuple(sorted(related_face_ids)),
"open_shell_highlight_face_ids": tuple(sorted(related_face_ids)),
"open_shell_limited_action": "完整抽壳/开口面编辑",
"open_shell_blockers": blockers,
"open_shell_note": blockers,
}
self._open_shell_context_cache[solid_id] = dict(result)
return result
def _planar_open_shell_context_info(
self,
face_id: int,
info: dict[str, object],
shell_info: dict[str, object],
prismatic_info: dict[str, object],
) -> dict[str, object]:
if bool(info.get("has_inner_boundaries")) or int(info.get("inner_boundary_wires", 0) or 0) > 0:
return {}
try:
solid_id = int(info.get("solid_id", -1))
except (TypeError, ValueError):
solid_id = -1
context = self._open_shell_context_for_solid(solid_id)
if context.get("open_shell_context_status") != "limited":
return {}
related_face_ids = set(_int_values(context.get("open_shell_related_face_ids")))
rim_face_ids = set(_int_values(context.get("open_shell_rim_face_ids")))
inner_face_ids = set(_int_values(context.get("open_shell_inner_face_ids")))
thin_wall_face_ids = set(_int_values(context.get("open_shell_thin_wall_face_ids")))
wall_thickness = _float_or_none(context.get("open_shell_wall_thickness_estimate"))
shell_thickness = _float_or_none(shell_info.get("shell_thickness_estimate"))
related = face_id in related_face_ids
if (
not related
and wall_thickness is not None
and shell_thickness is not None
and shell_info.get("shell_region_status") == "candidate"
and abs(shell_thickness - wall_thickness) <= max(wall_thickness * 0.38, 1e-6)
):
related = True
if not related and prismatic_info.get("prismatic_profile_status") == "candidate":
related = self._planar_face_is_near_open_shell_offset(face_id, info, context)
if not related:
return {}
if face_id in rim_face_ids:
role = "opening-rim"
elif face_id in inner_face_ids:
role = "inner-shell-face"
elif face_id in thin_wall_face_ids:
role = "thin-wall-face"
elif shell_info.get("shell_region_status") == "candidate":
role = "shell-thickness-face"
else:
role = "shell-context-face"
result = dict(context)
result["open_shell_current_face_role"] = role
result["open_shell_current_face_id"] = face_id
return result
def _planar_face_is_near_open_shell_offset(
self,
face_id: int,
info: dict[str, object],
context: dict[str, object],
) -> bool:
try:
solid_id = int(info.get("solid_id", -1))
except (TypeError, ValueError):
return False
if solid_id < 0 or solid_id >= len(self.solids):
return False
wall_thickness = _float_or_none(context.get("open_shell_wall_thickness_estimate"))
if wall_thickness is None or wall_thickness <= 1e-9:
return False
center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
normal = _tuple_normalized(_tuple_or_none(info.get("normal")))
if center is None or normal is None:
return False
try:
bounds = _shape_bounds_info(self.solids[solid_id][1])
except Exception:
return False
bbox_min = _tuple_or_none(bounds.get("bbox_min"))
bbox_max = _tuple_or_none(bounds.get("bbox_max"))
if bbox_min is None or bbox_max is None:
return False
tolerance = max(wall_thickness * 0.38, 1e-6)
for axis_index in range(3):
if abs(normal[axis_index]) < 0.92:
continue
side_distance = min(
abs(center[axis_index] - bbox_min[axis_index]),
abs(center[axis_index] - bbox_max[axis_index]),
)
if abs(side_distance - wall_thickness) <= tolerance:
return True
return False
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":
fillet_status = str(fillet_info.get("existing_fillet_status") or "")
fillet_chain_status = str(fillet_info.get("existing_fillet_chain_status") or "")
if fillet_status == "blocked":
feature_type = "复杂圆角链候选(暂不支持修改)"
edit_actions = "只读诊断;复杂长链、变半径圆角链或角部 blend 暂不开放稳定重建。"
elif fillet_chain_status == "same-radius-chain-candidate":
feature_type = "简单等半径圆角链候选"
edit_actions = "可尝试修改整条同半径圆角链半径/弧长;当前版本会一起移除链上圆角面,再在恢复出的锐边上重新倒圆。"
else:
feature_type = "圆角/倒圆候选"
edit_actions = "可尝试修改已有圆角半径/弧长;当前版本会先移除圆角面,再在恢复出的锐边上重新倒圆。"
elif guess == "boss/outer-round candidate":
feature_type = "凸台/外圆候选"
edit_actions = "调整圆柱凸台直径;调整圆柱凸台高度;修改圆柱凸台轴心坐标。"
else:
feature_type = "未明确圆柱特征"
edit_actions = "可尝试调整圆柱孔径,但风险较高。"
if str(slot_info.get("slot_status") or "") == "blocked":
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,
}
)
result.update(self._asitus_cylindrical_feature_hint_fields(face_id, result))
if angular_span >= math.tau * 0.92:
result.update(
{
"slot_kind": "",
"slot_status": "",
"slot_risk": "",
"slot_blockers": "",
"slot_angular_span": "",
"slot_open_angle": "",
"slot_chord_width_estimate": "",
"slot_arc_length_estimate": "",
"slot_sagitta_depth_estimate": "",
"feature_slot_face_ids": (),
"feature_slot_boundary_face_ids": (),
}
)
scoped_readiness_info = dict(result)
scoped_readiness_info["angular_span"] = combined_angular_span
scoped_readiness_info["height_estimate"] = axis_range["span"]
scoped_readiness_info["feature_guess"] = guess
if guess == "hole/groove candidate":
result.update(_cylinder_resize_readiness(scoped_readiness_info))
result.update(_cylinder_depth_readiness(scoped_readiness_info))
result.update(_cylinder_suppress_readiness(scoped_readiness_info))
elif guess == "boss/outer-round candidate":
result.update(
{
"resize_status": "blocked",
"resize_risk": "blocked",
"resize_blockers": "当前对象已识别为凸台/外圆;请使用凸台直径/高度/轴心入口,不按孔径重切。",
"resize_warnings": "",
"resize_note": "当前对象已识别为凸台/外圆;请使用凸台直径/高度/轴心入口,不按孔径重切。",
}
)
result.update(_cylinder_boss_resize_readiness(scoped_readiness_info))
elif guess == "round/fillet candidate":
result.update(
{
"resize_status": "blocked",
"resize_risk": "blocked",
"resize_blockers": "当前对象已识别为圆角/倒圆;请使用已有圆角半径入口,不按孔径重切。",
"resize_warnings": "",
"resize_note": "当前对象已识别为圆角/倒圆;请使用已有圆角半径入口,不按孔径重切。",
}
)
if str(slot_info.get("slot_status") or "") == "blocked":
blocker = str(slot_info.get("slot_blockers") or "当前槽/半孔属于复杂槽或多槽组,暂不开放稳定修改。")
result.update(
{
"resize_status": "blocked",
"resize_risk": "blocked",
"resize_blockers": blocker,
"resize_warnings": "",
"resize_note": blocker,
"depth_status": "blocked",
"depth_risk": "blocked",
"depth_blockers": blocker,
"suppress_status": "blocked",
"suppress_risk": "blocked",
"suppress_blockers": blocker,
"recognition_confidence": "low",
"recognition_risk": "blocked",
"recognition_blockers": blocker,
}
)
return result
def _partial_slot_pair_ambiguity_info(
self,
face_id: int,
*,
radius: float,
axis_direction: tuple[float, float, float] | None,
axis_mid_point: tuple[float, float, float] | None,
height: float,
boundary_face_ids: Iterable[int],
) -> dict[str, object]:
if radius <= 1e-9 or axis_direction is None or axis_mid_point is None or height <= 1e-9:
return {}
part_id = self.face_part_ids[face_id]
solid_id = self.face_solid_ids[face_id]
solid_shape = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else self.shape
diagonal = max(_shape_diagonal(solid_shape), radius, height, 1.0)
radius_tolerance = max(radius * 0.08, diagonal * 1e-5, 1e-4)
height_tolerance = max(height * 0.25, diagonal * 1e-5, 1e-4)
min_center_distance = max(radius * 1.2, diagonal * 1e-5, 1e-4)
source_boundary = set(int(item) for item in boundary_face_ids)
compatible_face_ids: list[int] = []
shared_boundary_face_ids: list[int] = []
unshared_boundary_face_ids: list[int] = []
for other_face_id, other_face in enumerate(self.faces):
if other_face_id == face_id:
continue
if self.face_part_ids[other_face_id] != part_id:
continue
if solid_id >= 0 and self.face_solid_ids[other_face_id] != solid_id:
continue
try:
other_surf = BRepAdaptor_Surface(other_face)
except Exception:
continue
if other_surf.GetType() != GeomAbs_Cylinder:
continue
other_cyl = other_surf.Cylinder()
other_radius = float(other_cyl.Radius())
if abs(other_radius - radius) > radius_tolerance:
continue
other_axis = _tuple_normalized(_dir_tuple(other_cyl.Axis().Direction()))
if other_axis is None or abs(_tuple_dot(axis_direction, other_axis)) < 1.0 - 1e-4:
continue
other_span = abs(float(other_surf.LastUParameter()) - float(other_surf.FirstUParameter()))
if other_span <= 1e-6 or other_span >= math.tau * 0.92:
continue
try:
other_side_face_ids = self.connected_same_domain_face_ids(other_face_id) or [other_face_id]
other_range = self._cylindrical_axis_range(other_face_id, other_surf, other_side_face_ids)
except Exception:
other_side_face_ids = [other_face_id]
other_range = {
"v_min": float(other_surf.FirstVParameter()),
"v_max": float(other_surf.LastVParameter()),
}
other_height = abs(float(other_range["v_max"]) - float(other_range["v_min"]))
if abs(other_height - height) > height_tolerance:
continue
other_mid_parameter = (float(other_range["v_min"]) + float(other_range["v_max"])) * 0.5
other_mid = _point_tuple(
_point_on_axis(other_cyl.Axis().Location(), other_cyl.Axis().Direction(), other_mid_parameter)
)
raw_offset = _tuple_sub(other_mid, axis_mid_point)
axis_offset = _tuple_scale(axis_direction, _tuple_dot(raw_offset, axis_direction))
section_offset = _tuple_sub(raw_offset, axis_offset)
if _vector_length(section_offset) <= min_center_distance:
continue
try:
other_boundary_edges = self._region_boundary_edge_ids(other_side_face_ids)
other_adjacent = sorted(
set(self._adjacent_face_ids_for_edges(other_boundary_edges, other_face_id))
- set(other_side_face_ids)
)
other_domain_info = {
"v_range": (other_range["v_min"], other_range["v_max"]),
"height_estimate": other_height,
"angular_span": other_span,
}
other_end_faces = self._cylindrical_end_face_groups(other_face_id, other_adjacent, other_domain_info)
other_boundary = set(other_adjacent) - set(other_end_faces["end_face_ids"])
except Exception:
other_boundary = set()
compatible_face_ids.append(other_face_id)
if source_boundary & other_boundary:
shared_boundary_face_ids.append(other_face_id)
else:
unshared_boundary_face_ids.append(other_face_id)
blockers = ""
if len(compatible_face_ids) >= 2 and not shared_boundary_face_ids:
blockers = (
"检测到多个同半径、同轴向且深度相近的槽端,但没有共享侧壁能确定稳定配对;"
"这更像交叉槽、多槽组或复杂草图槽。当前一级阶段暂不开放槽宽、槽深、弧长、弧角或槽轴心修改,"
"避免把局部圆柱面误当成独立槽特征。"
)
elif len(shared_boundary_face_ids) > 1:
blockers = "检测到多个可能共享侧壁的槽端,槽端配对不唯一;当前一级阶段暂不处理多槽组联动。"
return {
"slot_pair_candidate_face_ids": tuple(sorted(compatible_face_ids)),
"slot_pair_candidate_count": len(compatible_face_ids),
"slot_pair_shared_boundary_face_ids": tuple(sorted(shared_boundary_face_ids)),
"slot_pair_shared_boundary_count": len(shared_boundary_face_ids),
"slot_pair_unshared_boundary_face_ids": tuple(sorted(unshared_boundary_face_ids)),
"slot_pair_ambiguity_status": "blocked" if blockers else "candidate",
"slot_pair_ambiguity_blockers": blockers,
}
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))
axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis")))
axis_point = _tuple_or_none(info.get("axis_point"))
v_range = info.get("v_range")
height = _float_or_none(info.get("height_estimate")) or 0.0
axis_mid_point = None
if axis_point is not None and axis_direction is not None and isinstance(v_range, (tuple, list)) and len(v_range) >= 2:
v_min = _float_or_none(v_range[0])
v_max = _float_or_none(v_range[1])
if v_min is not None and v_max is not None:
v_mid = (v_min + v_max) * 0.5
axis_mid_point = (
axis_point[0] + axis_direction[0] * v_mid,
axis_point[1] + axis_direction[1] * v_mid,
axis_point[2] + axis_direction[2] * v_mid,
)
ambiguity_info = self._partial_slot_pair_ambiguity_info(
face_id,
radius=radius,
axis_direction=axis_direction,
axis_mid_point=axis_mid_point,
height=height,
boundary_face_ids=boundary_face_ids,
)
slot_blockers = str(ambiguity_info.get("slot_pair_ambiguity_blockers") or "")
slot_status = "blocked" if slot_blockers else "candidate"
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": slot_status,
"slot_risk": "blocked" if slot_status == "blocked" else "medium",
"slot_blockers": slot_blockers,
"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": (
slot_blockers
if slot_blockers
else "这是由局部圆柱面推断出的槽/半孔候选;宽度和深度是几何估算,不是 CAD 历史里的参数。"
),
**ambiguity_info,
}
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)
same_radius_round_face_ids: set[int] = set()
mixed_radius_round_face_ids: set[int] = set()
direct_round_face_ids: set[int] = set()
adjacent_round_radius_rows: dict[int, float] = {}
radius_tolerance = max(radius * 0.12, _shape_diagonal(self.faces[face_id]) * 1e-5, 1e-4)
chain_scan_truncated = False
visited = {face_id}
queue = [face_id]
max_chain_scan_faces = 24
while queue:
current_id = queue.pop(0)
try:
current_adjacent_ids = self._adjacent_face_ids_for_edges(
self._face_boundary_edge_ids(current_id),
current_id,
)
except Exception:
continue
for adjacent_id in current_adjacent_ids:
if adjacent_id == face_id:
continue
try:
adjacent_info = self.face_info(adjacent_id)
except Exception:
continue
adjacent_radius = _float_or_none(adjacent_info.get("radius"))
if (
adjacent_info.get("surface") != "cylinder"
or adjacent_info.get("feature_guess") != "round/fillet candidate"
or adjacent_radius is None
):
continue
adjacent_round_radius_rows[int(adjacent_id)] = float(adjacent_radius)
if current_id == face_id:
direct_round_face_ids.add(int(adjacent_id))
if abs(adjacent_radius - radius) <= radius_tolerance:
same_radius_round_face_ids.add(int(adjacent_id))
if adjacent_id not in visited:
if len(visited) >= max_chain_scan_faces:
chain_scan_truncated = True
continue
visited.add(int(adjacent_id))
queue.append(int(adjacent_id))
else:
mixed_radius_round_face_ids.add(int(adjacent_id))
adjacent_same_radius_round_face_ids = sorted(same_radius_round_face_ids)
adjacent_mixed_radius_round_face_ids = sorted(mixed_radius_round_face_ids)
adjacent_round_face_ids = sorted(set(adjacent_same_radius_round_face_ids) | set(adjacent_mixed_radius_round_face_ids))
adjacent_round_radius_row_values = tuple(sorted(adjacent_round_radius_rows.items()))
support_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids) - direct_round_face_ids)
chain_face_ids = tuple(sorted({face_id, *adjacent_round_face_ids}))
if adjacent_mixed_radius_round_face_ids:
chain_status = "variable-radius-chain-candidate"
chain_blocker = (
"当前圆角与不同半径的圆角面直接相连,属于变半径圆角链或角部 blend;"
"当前版本暂未实现稳定变半径圆角链重建。"
)
elif chain_scan_truncated or len(chain_face_ids) > 4:
chain_status = "complex-same-radius-chain-candidate"
chain_blocker = (
f"当前圆角链包含至少 {len(chain_face_ids)} 个同半径圆角面;"
"当前版本只开放 2 到 4 个面的简单等半径圆角链,复杂长链暂未实现稳定重建。"
)
elif adjacent_same_radius_round_face_ids:
chain_status = "same-radius-chain-candidate"
chain_blocker = ""
else:
chain_status = "single-face"
chain_blocker = ""
existing_fillet_risk = "blocked" if chain_blocker else "high" if adjacent_same_radius_round_face_ids else "medium"
if adjacent_mixed_radius_round_face_ids:
chain_note = (
"Detected directly connected round/fillet Faces with different radii; "
"this is treated as a variable-radius fillet chain or corner blend, not a single isolated fillet."
)
elif chain_blocker:
chain_note = (
"Detected a same-radius round/fillet chain beyond the simple local edit limit; "
"this is treated as a complex fillet chain until a dedicated chain rebuild is implemented."
)
elif adjacent_same_radius_round_face_ids:
chain_note = (
"Detected connected round/fillet Faces with a matching radius; "
"this can be edited as a simple same-radius fillet chain when every chain Face can be defeatured and refilleted."
)
else:
chain_note = "No directly connected round/fillet Face was detected."
if chain_blocker:
existing_fillet_note = chain_blocker
elif adjacent_same_radius_round_face_ids:
existing_fillet_note = (
"这是由同半径相邻圆柱倒圆面推断出的简单圆角链候选;"
"当前版本会尝试一起移除链上圆角面,再在恢复出的锐边上按目标半径重新倒圆;"
"角部 blend 或复杂链仍可能失败并回滚。"
)
else:
existing_fillet_note = (
"这是由局部小半径圆柱面推断出的已有圆角/倒圆候选;"
"当前版本可尝试使用 defeature + 重新倒圆修改半径;"
"复杂 blend 或支撑面不明确时可能失败并回滚。"
)
return {
"existing_fillet_kind": "cylindrical-round-face",
"existing_fillet_status": "blocked" if chain_blocker else "candidate",
"existing_fillet_risk": existing_fillet_risk,
"existing_fillet_blockers": chain_blocker,
"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),
"feature_existing_fillet_chain_face_ids": chain_face_ids,
"feature_existing_fillet_chain_adjacent_face_ids": tuple(adjacent_round_face_ids),
"feature_existing_fillet_direct_round_face_ids": tuple(sorted(direct_round_face_ids)),
"feature_existing_fillet_same_radius_chain_face_ids": tuple(adjacent_same_radius_round_face_ids),
"feature_existing_fillet_mixed_radius_chain_face_ids": tuple(adjacent_mixed_radius_round_face_ids),
"feature_existing_fillet_adjacent_radius_rows": adjacent_round_radius_row_values,
"existing_fillet_chain_face_count": len(chain_face_ids),
"existing_fillet_chain_status": chain_status,
"existing_fillet_chain_note": chain_note,
"existing_fillet_note": existing_fillet_note,
}
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_plane_normal_tuple(self, face_id: int) -> tuple[float, float, float] | None:
if face_id < 0 or face_id >= len(self.faces):
return None
try:
surface = BRepAdaptor_Surface(self.faces[face_id])
except Exception:
return None
if surface.GetType() != GeomAbs_Plane:
return None
return _tuple_normalized(_dir_tuple(surface.Plane().Axis().Direction()))
def _edge_axis_tuple(self, edge_id: int) -> tuple[float, float, float] | None:
if edge_id < 0 or edge_id >= len(self.edges):
return None
try:
curve = BRepAdaptor_Curve(self.edges[edge_id])
if curve.GetType() == GeomAbs_Line:
return _tuple_normalized(_dir_tuple(curve.Line().Direction()))
except Exception:
pass
points = self._edge_vertex_points(edge_id)
if len(points) < 2:
return None
return _tuple_normalized(_tuple_sub(points[-1], points[0]))
def _planar_relation_name(self, dot: float, tolerance: float) -> str:
if dot >= 1.0 - tolerance:
return "parallel"
if dot <= tolerance:
return "perpendicular"
return "angled"
def _first_level_planar_relation_fields(
self,
*,
subject_face_ids: Iterable[int],
adjacent_face_ids: Iterable[int],
edge_axis: tuple[float, float, float] | None = None,
relation_scope: str = "face",
) -> dict[str, object]:
tolerance = 0.12
rows: list[dict[str, object]] = []
non_planar_face_ids: set[int] = set()
normals: dict[int, tuple[float, float, float]] = {}
subject_ids = tuple(sorted({int(item) for item in subject_face_ids if 0 <= int(item) < len(self.faces)}))
adjacent_ids = tuple(sorted({int(item) for item in adjacent_face_ids if 0 <= int(item) < len(self.faces)}))
involved_ids = tuple(sorted({*subject_ids, *adjacent_ids}))
for face_id in involved_ids:
normal = self._face_plane_normal_tuple(face_id)
if normal is None:
non_planar_face_ids.add(face_id)
continue
normals[face_id] = normal
relation_labels = {
"parallel": "平行",
"perpendicular": "垂直",
"angled": "斜交",
}
subject_pairs: set[tuple[int, int]] = set()
for subject_id in subject_ids:
subject_normal = normals.get(subject_id)
if subject_normal is None:
continue
for adjacent_id in adjacent_ids:
adjacent_normal = normals.get(adjacent_id)
if adjacent_normal is None or adjacent_id == subject_id:
continue
pair_key = tuple(sorted((subject_id, adjacent_id)))
if pair_key in subject_pairs:
continue
subject_pairs.add(pair_key)
dot = abs(_tuple_dot(subject_normal, adjacent_normal))
relation = self._planar_relation_name(dot, tolerance)
rows.append(
{
"relation_kind": "face-face",
"source": "subject-adjacent",
"face_ids": pair_key,
"relation": relation,
"relation_label": relation_labels[relation],
"normal_dot": dot,
}
)
if relation_scope == "edge":
incident_ids = tuple(face_id for face_id in adjacent_ids if face_id in normals)
for left_index, left_id in enumerate(incident_ids):
left_normal = normals.get(left_id)
if left_normal is None:
continue
for right_id in incident_ids[left_index + 1 :]:
right_normal = normals.get(right_id)
if right_normal is None:
continue
dot = abs(_tuple_dot(left_normal, right_normal))
relation = self._planar_relation_name(dot, tolerance)
rows.append(
{
"relation_kind": "face-face",
"source": "incident-face-pair",
"face_ids": (left_id, right_id),
"relation": relation,
"relation_label": relation_labels[relation],
"normal_dot": dot,
}
)
if edge_axis is not None:
for face_id in incident_ids:
normal = normals.get(face_id)
if normal is None:
continue
dot = abs(_tuple_dot(normal, edge_axis))
relation = "perpendicular" if dot >= 1.0 - tolerance else "parallel" if dot <= tolerance else "angled"
rows.append(
{
"relation_kind": "face-edge-axis",
"source": "incident-face-to-edge-axis",
"face_id": face_id,
"relation": relation,
"relation_label": relation_labels[relation],
"normal_edge_dot": dot,
}
)
parallel_count = sum(1 for row in rows if row.get("relation") == "parallel")
perpendicular_count = sum(1 for row in rows if row.get("relation") == "perpendicular")
angled_count = sum(1 for row in rows if row.get("relation") == "angled")
face_face_count = sum(1 for row in rows if row.get("relation_kind") == "face-face")
edge_axis_count = sum(1 for row in rows if row.get("relation_kind") == "face-edge-axis")
observed_modes: list[str] = []
if parallel_count:
observed_modes.append("parallel")
if perpendicular_count:
observed_modes.append("perpendicular")
if angled_count:
observed_modes.append("angled")
if rows:
summary = (
f"一级平面关系:已识别 {len(rows)} 条;"
f"平行 {parallel_count},垂直 {perpendicular_count},斜交 {angled_count}。"
)
status = "ready"
else:
summary = "一级平面关系:当前一级邻域没有足够平面可判断。"
status = "unavailable"
return {
"first_level_planar_relation_status": status,
"first_level_planar_relation_scope": relation_scope,
"first_level_planar_relation_tolerance": tolerance,
"first_level_planar_relation_rows": tuple(rows),
"first_level_planar_relation_count": len(rows),
"first_level_planar_relation_face_face_count": face_face_count,
"first_level_planar_relation_edge_axis_count": edge_axis_count,
"first_level_planar_relation_parallel_count": parallel_count,
"first_level_planar_relation_perpendicular_count": perpendicular_count,
"first_level_planar_relation_angled_count": angled_count,
"first_level_planar_relation_subject_face_ids": subject_ids,
"first_level_planar_relation_adjacent_face_ids": adjacent_ids,
"first_level_planar_relation_non_planar_face_ids": tuple(sorted(non_planar_face_ids)),
"first_level_planar_relation_observed_modes": tuple(observed_modes),
"first_level_planar_relation_summary": summary,
"first_level_planar_relation_note": (
"这些是一级邻域内已识别到的平面方向关系;只有明确支持的建模意图会把它们作为执行约束。"
),
}
def _first_level_same_domain_relation_fields(
self,
*,
face_id: int,
same_domain_face_ids: Iterable[int],
relation_scope: str = "face",
) -> dict[str, object]:
domain_ids = tuple(sorted({int(item) for item in same_domain_face_ids if 0 <= int(item) < len(self.faces)}))
surface = self.face_surface_kind(face_id) if 0 <= int(face_id) < len(self.faces) else "unknown"
fragment_ids = tuple(item for item in domain_ids if item != int(face_id))
if surface == "plane":
relation = "coplanar"
relation_label = "共面"
elif surface == "cylinder":
relation = "cocylindrical"
relation_label = "同圆柱面"
elif len(domain_ids) > 1:
relation = "same-domain"
relation_label = "同域"
else:
relation = "single-face"
relation_label = "单 Face"
if len(domain_ids) > 1:
summary = f"一级同域关系:已识别 {len(domain_ids)}{relation_label} Face,会作为同一局部主体。"
elif surface in {"plane", "cylinder"}:
summary = f"一级同域关系:当前为单个{relation_label}主体,未发现可同步碎片 Face。"
else:
summary = "一级同域关系:当前曲面类型暂不支持同域碎片同步,只按单 Face 处理。"
return {
"first_level_same_domain_status": "ready",
"first_level_same_domain_scope": relation_scope,
"first_level_same_domain_relation": relation,
"first_level_same_domain_relation_label": relation_label,
"first_level_same_domain_surface": surface,
"first_level_same_domain_face_ids": domain_ids,
"first_level_same_domain_face_count": len(domain_ids),
"first_level_same_domain_fragment_face_ids": fragment_ids,
"first_level_same_domain_fragment_face_count": len(fragment_ids),
"first_level_same_domain_summary": summary,
"first_level_same_domain_note": (
"同域/共面/同圆柱面碎片属于当前一级主体;当前只同步明确支持的编辑路径,"
"不会递归扩展到二级、三级邻域。"
),
}
def _cylinders_are_coaxial(
self,
left: BRepAdaptor_Surface,
right: BRepAdaptor_Surface,
tolerance: float,
) -> bool:
if left.GetType() != GeomAbs_Cylinder or right.GetType() != GeomAbs_Cylinder:
return False
left_axis = left.Cylinder().Axis()
right_axis = right.Cylinder().Axis()
axis_dot = abs(_direction_dot(left_axis.Direction(), right_axis.Direction()))
if axis_dot < 1.0 - 1e-6:
return False
distance = _point_axis_distance(left_axis.Location(), left_axis.Direction(), right_axis.Location())
return distance <= max(tolerance, _shape_diagonal(self.shape) * 1e-7, 1e-6)
def _first_level_coaxial_cylinder_relation_fields(
self,
*,
subject_face_ids: Iterable[int],
adjacent_face_ids: Iterable[int],
relation_scope: str = "face",
) -> dict[str, object]:
involved_ids = tuple(
sorted({int(item) for item in (*tuple(subject_face_ids), *tuple(adjacent_face_ids)) if 0 <= int(item) < len(self.faces)})
)
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
cylinder_infos: dict[int, dict[str, object]] = {}
for face_id in involved_ids:
surf = BRepAdaptor_Surface(self.faces[face_id])
if surf.GetType() != GeomAbs_Cylinder:
continue
cylinder = surf.Cylinder()
axis = cylinder.Axis()
cylinder_infos[face_id] = {
"face_id": face_id,
"axis_point": _point_tuple(axis.Location()),
"axis_direction": _dir_tuple(axis.Direction()),
"radius": float(cylinder.Radius()),
}
rows: list[dict[str, object]] = []
cylinder_ids = tuple(sorted(cylinder_infos))
for left_index, left_id in enumerate(cylinder_ids):
left_surf = BRepAdaptor_Surface(self.faces[left_id])
for right_id in cylinder_ids[left_index + 1 :]:
right_surf = BRepAdaptor_Surface(self.faces[right_id])
if not self._cylinders_are_coaxial(left_surf, right_surf, tolerance):
continue
left_radius = float(cylinder_infos[left_id]["radius"])
right_radius = float(cylinder_infos[right_id]["radius"])
rows.append(
{
"relation_kind": "cylinder-cylinder-axis",
"source": "first-level-cylinder-pair",
"face_ids": (left_id, right_id),
"relation": "coaxial",
"relation_label": "同轴",
"radii": (left_radius, right_radius),
"same_radius": abs(left_radius - right_radius) <= max(tolerance, max(left_radius, right_radius) * 1e-6),
}
)
related_face_ids = tuple(sorted({face_id for row in rows for face_id in _int_values(row.get("face_ids"))}))
if rows:
unique_radii = []
for face_id in related_face_ids:
radius = float(cylinder_infos[face_id]["radius"])
if not any(abs(radius - existing) <= max(tolerance, max(radius, existing) * 1e-6) for existing in unique_radii):
unique_radii.append(radius)
summary = (
f"一级同轴圆柱关系:已识别 {len(rows)} 组同轴圆柱 Face"
f"相关 Face {len(related_face_ids)},半径 {tuple(round(item, 6) for item in sorted(unique_radii))}。"
)
status = "ready"
elif cylinder_infos:
summary = "一级同轴圆柱关系:一级范围内只有单个圆柱 Face,暂时没有可配对同轴关系。"
status = "unavailable"
else:
summary = "一级同轴圆柱关系:一级范围内没有圆柱 Face。"
status = "unavailable"
return {
"first_level_coaxial_cylinder_status": status,
"first_level_coaxial_cylinder_scope": relation_scope,
"first_level_coaxial_cylinder_rows": tuple(rows),
"first_level_coaxial_cylinder_count": len(rows),
"first_level_coaxial_cylinder_face_ids": related_face_ids,
"first_level_coaxial_cylinder_face_count": len(related_face_ids),
"first_level_coaxial_cylinder_candidate_face_ids": cylinder_ids,
"first_level_coaxial_cylinder_candidate_face_count": len(cylinder_ids),
"first_level_coaxial_cylinder_summary": summary,
"first_level_coaxial_cylinder_note": (
"这些是一级范围内圆柱轴线重合的事实;当前只作为识别和守门证据,"
"跨特征同轴传播仍不自动执行。"
),
}
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())
)
planar_relation_fields = self._first_level_planar_relation_fields(
subject_face_ids=same_domain_face_ids,
adjacent_face_ids=adjacent_face_ids,
relation_scope="face",
)
same_domain_relation_fields = self._first_level_same_domain_relation_fields(
face_id=face_id,
same_domain_face_ids=same_domain_face_ids,
relation_scope="face",
)
coaxial_cylinder_relation_fields = self._first_level_coaxial_cylinder_relation_fields(
subject_face_ids=same_domain_face_ids,
adjacent_face_ids=adjacent_face_ids,
relation_scope="face",
)
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),
**planar_relation_fields,
**same_domain_relation_fields,
**coaxial_cylinder_relation_fields,
"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())
)
same_domain_relation_fields = self._first_level_same_domain_relation_fields(
face_id=face_id,
same_domain_face_ids=side_face_ids,
relation_scope="cylindrical-feature",
)
coaxial_cylinder_relation_fields = self._first_level_coaxial_cylinder_relation_fields(
subject_face_ids=side_face_ids,
adjacent_face_ids=adjacent_face_ids,
relation_scope="cylindrical-feature",
)
angular_span = (
_float_or_none(feature.get("slot_angular_span"))
or _float_or_none(feature.get("same_domain_angular_span"))
or _float_or_none(feature.get("angular_span"))
or _float_or_none(info.get("same_domain_angular_span"))
or _float_or_none(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),
**same_domain_relation_fields,
**coaxial_cylinder_relation_fields,
"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"))
planar_relation_fields = {
key: value
for key, value in topology.items()
if str(key).startswith("first_level_planar_relation_")
}
same_domain_relation_fields = {
key: value
for key, value in topology.items()
if str(key).startswith("first_level_same_domain_")
}
coaxial_cylinder_relation_fields = {
key: value
for key, value in topology.items()
if str(key).startswith("first_level_coaxial_cylinder_")
}
if not planar_relation_fields:
planar_relation_fields = self._first_level_planar_relation_fields(
subject_face_ids=subject_face_ids,
adjacent_face_ids=adjacent_face_ids,
relation_scope=resolved_scope,
)
planar_summary = str(planar_relation_fields.get("first_level_planar_relation_summary") or "").strip()
same_domain_summary = str(same_domain_relation_fields.get("first_level_same_domain_summary") or "").strip()
coaxial_summary = str(
coaxial_cylinder_relation_fields.get("first_level_coaxial_cylinder_summary") or ""
).strip()
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."
)
if same_domain_summary and int(same_domain_relation_fields.get("first_level_same_domain_face_count", 0) or 0) > 1:
summary = f"{summary} {same_domain_summary}"
if planar_summary and planar_relation_fields.get("first_level_planar_relation_status") == "ready":
summary = f"{summary} {planar_summary}"
if coaxial_summary and coaxial_cylinder_relation_fields.get("first_level_coaxial_cylinder_status") == "ready":
summary = f"{summary} {coaxial_summary}"
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,
**planar_relation_fields,
**same_domain_relation_fields,
**coaxial_cylinder_relation_fields,
}
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_size_parameter_readiness(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
blockers: list[str] = []
if face_id < 0 or face_id >= len(self.faces):
blockers.append("Face ID 不存在。")
if str(info.get("surface") or "") != "plane":
blockers.append("面内长度/宽度只对平面矩形 Face 开放。")
if bool(info.get("has_inner_boundaries")):
blockers.append("当前 Face 有内孔/内边界,请优先使用孔或槽的专门修改入口。")
try:
boundary_wires = int(info.get("boundary_wires", 0) or 0)
except (TypeError, ValueError):
boundary_wires = 0
if boundary_wires and boundary_wires != 1:
blockers.append("当前 Face 不是单一外轮廓。")
width = _float_or_none(info.get("local_face_width"))
height = _float_or_none(info.get("local_face_height"))
width_direction = _tuple_or_none(info.get("local_face_width_direction"))
height_direction = _tuple_or_none(info.get("local_face_height_direction"))
center = _tuple_or_none(info.get("local_face_size_center")) or _tuple_or_none(info.get("area_center"))
if width is None or width <= 1e-9:
blockers.append("当前 Face 缺少稳定的面内长度。")
if height is None or height <= 1e-9:
blockers.append("当前 Face 缺少稳定的面内宽度。")
if width_direction is None or height_direction is None or center is None:
blockers.append("当前 Face 缺少稳定的面内方向或中心。")
edge_ids: list[int] = []
if 0 <= face_id < len(self.faces):
try:
edge_ids = self._face_boundary_edge_ids(face_id)
except Exception:
edge_ids = []
try:
boundary_edges = int(info.get("boundary_edges", len(edge_ids)) or len(edge_ids))
except (TypeError, ValueError):
boundary_edges = len(edge_ids)
rectangular_groups: list[dict[str, object]] = []
if boundary_edges != 4 or len(edge_ids) != 4:
blockers.append("面内长度/宽度当前只对四边矩形平面开放。")
else:
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:
blockers.append("当前 Face 的边界包含非直线边。")
break
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:
blockers.append("当前 Face 的边界直线不能稳定读取。")
break
if direction is None or length <= 1e-9:
blockers.append("当前 Face 的边界存在退化直线边。")
break
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": []}
direction_groups.append(matched_group)
matched_group["lengths"].append(length)
if len(direction_groups) != 2 or any(len(group["lengths"]) != 2 for group in direction_groups):
blockers.append("当前 Face 的四条边没有形成两组稳定平行对边。")
else:
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
):
blockers.append("当前 Face 的两组对边不是稳定垂直关系。")
for group in direction_groups:
lengths = [float(item) for item in group["lengths"]]
average = sum(lengths) / max(len(lengths), 1)
if max(abs(item - average) for item in lengths) > max(average * 1e-4, 1e-7):
blockers.append("当前 Face 的相对边长度不一致。")
break
rectangular_groups = direction_groups
if blockers:
return {
"local_face_size_edit_ready": False,
"local_face_size_edit_blocker": "".join(dict.fromkeys(blockers)),
}
result: dict[str, object] = {
"local_face_size_edit_ready": True,
"local_face_size_edit_blocker": "",
}
if rectangular_groups:
rectangular_groups.sort(
key=lambda group: sum(float(item) for item in group["lengths"]) / max(len(group["lengths"]), 1),
reverse=True,
)
length_group = rectangular_groups[0]
width_group = rectangular_groups[1]
length_values = [float(item) for item in length_group["lengths"]]
width_values = [float(item) for item in width_group["lengths"]]
result.update(
{
"local_face_width": sum(length_values) / len(length_values),
"local_face_height": sum(width_values) / len(width_values),
"local_face_width_direction": length_group["direction"],
"local_face_height_direction": width_group["direction"],
"local_face_size_center": center,
}
)
return result
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._asitus_hole_region_ids(face_id)
or self._internal_hole_region_ids(face_id)
or 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 _asitus_hole_region_ids(self, face_id: int) -> list[int]:
if face_id < 0 or face_id >= len(self.faces):
return []
if not self._can_use_asitus_hole_recognition():
return []
if self._asitus_hole_regions_loading:
return []
if not self._asitus_hole_regions_attempted:
self._load_asitus_hole_regions()
return list(self._asitus_hole_region_cache.get(face_id, ()))
def _internal_hole_region_ids(self, face_id: int) -> list[int]:
if face_id < 0 or face_id >= len(self.faces):
return []
if not self._can_use_internal_recognition_graph():
return []
if not self._internal_hole_regions_attempted:
self._load_internal_hole_regions()
return list(self._internal_hole_region_cache.get(face_id, ()))
def _can_use_internal_recognition_graph(self) -> bool:
return bool(self.faces) and len(self.faces) <= 1000
def _load_internal_hole_regions(self) -> None:
self._internal_hole_regions_attempted = True
self._internal_hole_region_cache.clear()
self._internal_recognition_info.clear()
try:
regions = recognize_through_hole_regions(self)
mapped_groups: set[tuple[int, ...]] = set()
for region in regions:
group = tuple(sorted({int(item) for item in region.face_ids if 0 <= int(item) < len(self.faces)}))
if len(group) < 2:
continue
mapped_groups.add(group)
for face_id in group:
self._internal_hole_region_cache[face_id] = list(group)
self._internal_recognition_info.update(
{
"ok": True,
"source": "internal-recognition-graph",
"mapped_hole_count": len(mapped_groups),
"mapped_hole_groups": tuple(sorted(mapped_groups)),
"through_hole_region_count": len(regions),
}
)
except Exception as exc:
self._internal_hole_region_cache.clear()
self._internal_recognition_info.update(
{
"ok": False,
"source": "internal-recognition-graph",
"reason": "recognition-failed",
"message": str(exc),
}
)
def _can_use_asitus_hole_recognition(self) -> bool:
if int(getattr(self, "_topology_refresh_generation", 0) or 0) > 1:
return False
suffix = self.filename.suffix.lower()
return suffix in {".step", ".stp"} and self.filename.is_file()
def _load_asitus_hole_regions(self) -> None:
self.install_asitus_hole_recognition_result(run_asitus_hole_recognition(self.filename))
def begin_asitus_hole_region_load(self) -> bool:
if not self._can_use_asitus_hole_recognition():
return False
if self._asitus_hole_regions_attempted or self._asitus_hole_regions_loading:
return False
self._asitus_hole_regions_loading = True
self._asitus_hole_recognition_info.update(
{
"ok": False,
"reason": "pending",
"message": "Analysis Situs hole recognition is running in the background.",
}
)
return True
def install_asitus_hole_recognition_result(self, result: object) -> list[tuple[int, ...]]:
self._asitus_hole_regions_loading = False
self._asitus_hole_regions_attempted = True
self._asitus_hole_region_cache.clear()
self._asitus_hole_recognition_info.clear()
self._asitus_face_relation_cache.clear()
self._asitus_adjacency_relation_cache.clear()
self._asitus_geometric_relation_cache.clear()
if not self._can_use_asitus_hole_recognition():
self._asitus_hole_recognition_info.update(
{
"ok": False,
"reason": "stale-topology",
"message": "Analysis Situs result ignored because the model topology has changed.",
}
)
return []
if not isinstance(result, dict):
result = {"ok": False, "reason": "bad-result", "message": "Analysis Situs returned an unexpected result."}
self._asitus_hole_recognition_info.update(
{
"ok": bool(result.get("ok")),
"reason": result.get("reason", ""),
"message": result.get("message", ""),
"cli": result.get("cli", ""),
}
)
if not result.get("ok"):
self._clear_same_domain_dependent_caches()
return []
self._install_asitus_relation_summary(result)
raw_groups = result.get("groups", ())
if not isinstance(raw_groups, (tuple, list)):
self._clear_same_domain_dependent_caches()
return []
mapped_groups = self._map_asitus_hole_groups(raw_groups)
if not mapped_groups:
self._clear_same_domain_dependent_caches()
return []
for group in mapped_groups:
for item in group:
self._asitus_hole_region_cache[int(item)] = list(group)
self._asitus_hole_recognition_info.update(
{
"mapped_hole_count": len(mapped_groups),
"mapped_hole_groups": tuple(tuple(group) for group in mapped_groups),
"source_face_id_base": "analysis-situs-aag-1-based",
}
)
self._clear_same_domain_dependent_caches()
return mapped_groups
def fail_asitus_hole_region_load(self, message: str) -> None:
self._asitus_hole_regions_loading = False
self._asitus_hole_regions_attempted = True
self._asitus_hole_region_cache.clear()
self._asitus_hole_recognition_info.clear()
self._asitus_face_relation_cache.clear()
self._asitus_adjacency_relation_cache.clear()
self._asitus_geometric_relation_cache.clear()
self._asitus_hole_recognition_info.update(
{
"ok": False,
"reason": "worker-failed",
"message": str(message or "Analysis Situs hole recognition failed."),
}
)
self._clear_same_domain_dependent_caches()
def _clear_same_domain_dependent_caches(self) -> None:
self._same_domain_face_ids_cache.clear()
self._internal_hole_regions_attempted = False
self._internal_hole_region_cache.clear()
self._internal_recognition_info.clear()
self._recognition_graph_cache.clear()
self._through_hole_regions_cache.clear()
self._quick_face_info_cache.clear()
self._face_info_cache.clear()
self._feature_info_cache.clear()
self._face_first_level_topology_cache.clear()
self._cylindrical_first_level_topology_cache.clear()
self._face_first_level_fact_cache.clear()
self._editable_feature_candidates_cache.clear()
self._cylindrical_feature_candidates_cache.clear()
def mark_external_recognition_stale(self, reason: str = "modified-topology") -> None:
self._topology_refresh_generation = max(int(getattr(self, "_topology_refresh_generation", 0) or 0), 2)
self._asitus_hole_regions_attempted = False
self._asitus_hole_regions_loading = False
self._asitus_hole_region_cache.clear()
self._asitus_face_relation_cache.clear()
self._asitus_adjacency_relation_cache.clear()
self._asitus_geometric_relation_cache.clear()
self._asitus_hole_recognition_info.clear()
self._asitus_hole_recognition_info.update(
{
"ok": False,
"reason": str(reason or "modified-topology"),
"message": "Analysis Situs result ignored because the model topology has changed.",
}
)
self._clear_same_domain_dependent_caches()
def _install_asitus_relation_summary(self, result: dict[str, object]) -> None:
faces = result.get("faces", ())
if isinstance(faces, (tuple, list)):
for item in faces:
if not isinstance(item, dict):
continue
raw_face_id = self._coerce_int(item.get("id"))
face_id = self._map_asitus_single_face_id(raw_face_id)
if face_id is None:
continue
neighbor_ids = tuple(
sorted(
{
mapped
for raw_neighbor in self._iter_int_values(item.get("neighbor_ids"))
for mapped in (self._map_asitus_single_face_id(raw_neighbor),)
if mapped is not None and mapped != face_id
}
)
)
self._asitus_face_relation_cache[face_id] = {
"source": "analysis-situs-aag",
"asitus_face_id": raw_face_id,
"surface": str(item.get("surface") or ""),
"neighbor_face_ids": neighbor_ids,
"neighbor_face_count": len(neighbor_ids),
}
adjacency = result.get("adjacency", ())
if isinstance(adjacency, (tuple, list)):
for item in adjacency:
if not isinstance(item, dict):
continue
mapped_pair = tuple(
sorted(
{
mapped
for raw_face_id in self._iter_int_values(item.get("face_ids"))
for mapped in (self._map_asitus_single_face_id(raw_face_id),)
if mapped is not None
}
)
)
if len(mapped_pair) != 2:
continue
self._asitus_adjacency_relation_cache[mapped_pair] = {
"source": "analysis-situs-aag",
"face_ids": mapped_pair,
"angle_type": str(item.get("angle_type") or ""),
"angle_rad": item.get("angle_rad"),
"edge_ids": tuple(self._iter_int_values(item.get("edge_ids"))),
}
geometric_relations = result.get("geometric_relations", ())
if isinstance(geometric_relations, (tuple, list)):
for item in geometric_relations:
if not isinstance(item, dict):
continue
mapped_pair = tuple(
sorted(
{
mapped
for raw_face_id in self._iter_int_values(item.get("face_ids"))
for mapped in (self._map_asitus_single_face_id(raw_face_id),)
if mapped is not None
}
)
)
if len(mapped_pair) != 2:
continue
relation = {
"source": str(item.get("source") or "analysis-situs-probe"),
"face_ids": mapped_pair,
"relation_type": str(item.get("relation_type") or ""),
"residual": item.get("residual"),
}
self._asitus_geometric_relation_cache.setdefault(mapped_pair, []).append(relation)
if (
self._asitus_face_relation_cache
or self._asitus_adjacency_relation_cache
or self._asitus_geometric_relation_cache
):
self._asitus_hole_recognition_info.update(
{
"aag_face_relation_count": len(self._asitus_face_relation_cache),
"aag_adjacency_relation_count": len(self._asitus_adjacency_relation_cache),
"aag_geometric_relation_pair_count": len(self._asitus_geometric_relation_cache),
"aag_geometric_relation_count": sum(
len(items) for items in self._asitus_geometric_relation_cache.values()
),
"aag_surface_summary": dict(result.get("surface_summary", {}) or {}),
"aag_angle_summary": dict(result.get("angle_summary", {}) or {}),
"aag_geometric_relation_summary": dict(result.get("geometric_relation_summary", {}) or {}),
"aag_geometric_relation_mode": str(result.get("geometric_relation_mode") or ""),
"aag_relation_source": "analysis-situs-aag",
}
)
def _asitus_face_summary_fields(self, face_id: int) -> dict[str, object]:
entry = self._asitus_face_relation_cache.get(int(face_id))
if not entry:
return {}
neighbor_ids = tuple(int(item) for item in entry.get("neighbor_face_ids", ()) or ())
relation_types: Counter[str] = Counter()
for neighbor_id in neighbor_ids:
pair = tuple(sorted((int(face_id), int(neighbor_id))))
relation = self._asitus_adjacency_relation_cache.get(pair)
if not relation:
continue
relation_type = str(relation.get("angle_type") or "adjacent")
relation_types[relation_type] += 1
relation_summary = ", ".join(f"{key}:{value}" for key, value in sorted(relation_types.items()))
if not relation_summary:
relation_summary = f"adjacent:{len(neighbor_ids)}"
geometric_relation_types: Counter[str] = Counter()
for pair, relations in self._asitus_geometric_relation_cache.items():
if int(face_id) not in pair:
continue
for relation in relations:
relation_type = str(relation.get("relation_type") or "")
if relation_type:
geometric_relation_types[relation_type] += 1
geometric_relation_summary = ", ".join(
f"{key}:{value}" for key, value in sorted(geometric_relation_types.items())
)
return {
"asitus_relation_status": "ready",
"asitus_relation_source": entry.get("source", "analysis-situs-aag"),
"asitus_face_id": entry.get("asitus_face_id"),
"asitus_surface": entry.get("surface", ""),
"asitus_adjacent_face_ids": neighbor_ids,
"asitus_adjacent_face_count": len(neighbor_ids),
"asitus_adjacent_relation_summary": relation_summary,
"asitus_geometric_relation_count": sum(geometric_relation_types.values()),
"asitus_geometric_relation_summary": geometric_relation_summary,
"asitus_geometric_relation_types": tuple(sorted(geometric_relation_types)),
}
def _map_asitus_single_face_id(self, raw_face_id: object) -> int | None:
value = self._coerce_int(raw_face_id)
if value is None:
return None
if 1 <= value <= len(self.faces):
return value - 1
if 0 <= value < len(self.faces):
return value
return None
@staticmethod
def _coerce_int(value: object) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
@staticmethod
def _iter_int_values(values: object) -> tuple[int, ...]:
if values is None or isinstance(values, (str, bytes)):
return ()
try:
iterator = iter(values) # type: ignore[arg-type]
except TypeError:
coerced = StepModel._coerce_int(values)
return () if coerced is None else (coerced,)
result: list[int] = []
for item in iterator:
coerced = StepModel._coerce_int(item)
if coerced is not None:
result.append(coerced)
return tuple(result)
def _map_asitus_hole_groups(self, groups: Iterable[object]) -> list[tuple[int, ...]]:
mapped: list[tuple[int, ...]] = []
seen: set[tuple[int, ...]] = set()
for raw_group in groups:
try:
values = tuple(sorted({int(item) for item in raw_group})) # type: ignore[arg-type]
except (TypeError, ValueError):
continue
group = self._map_asitus_hole_group(values)
if not group or group in seen:
continue
seen.add(group)
mapped.append(group)
return mapped
def _map_asitus_hole_group(self, raw_group: tuple[int, ...]) -> tuple[int, ...]:
best_group: tuple[int, ...] = ()
best_score = -1.0
for offset in (-1, 0):
candidate = tuple(sorted({item + offset for item in raw_group}))
score, cylinder_group = self._score_asitus_hole_group(candidate)
if score > best_score:
best_score = score
best_group = cylinder_group
return best_group if best_score >= 35.0 else ()
def _score_asitus_hole_group(self, candidate: tuple[int, ...]) -> tuple[float, tuple[int, ...]]:
if not candidate:
return -1.0, ()
if any(face_id < 0 or face_id >= len(self.faces) for face_id in candidate):
return -1.0, ()
cylinder_face_ids: list[int] = []
cylinder_surfaces: list[BRepAdaptor_Surface] = []
for face_id in candidate:
try:
surf = BRepAdaptor_Surface(self.faces[face_id])
except Exception:
continue
if surf.GetType() != GeomAbs_Cylinder:
continue
cylinder_face_ids.append(face_id)
cylinder_surfaces.append(surf)
if not cylinder_face_ids:
return -1.0, ()
score = 20.0 * len(cylinder_face_ids)
if len(cylinder_face_ids) < len(candidate):
score -= 5.0 * (len(candidate) - len(cylinder_face_ids))
part_ids = {self.face_part_ids[item] for item in cylinder_face_ids}
solid_ids = {self.face_solid_ids[item] for item in cylinder_face_ids}
if len(part_ids) == 1:
score += 10.0
if len(solid_ids) == 1:
score += 10.0
if len(cylinder_surfaces) > 1:
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
source = cylinder_surfaces[0]
if all(_surfaces_are_cocylindrical(source, other, tolerance) for other in cylinder_surfaces[1:]):
score += 40.0
else:
score -= 45.0
angular_span = 0.0
for surf in cylinder_surfaces:
try:
angular_span += abs(float(surf.LastUParameter()) - float(surf.FirstUParameter()))
except Exception:
pass
if angular_span >= math.tau * 0.85:
score += 25.0
elif angular_span >= math.pi * 0.9:
score += 8.0
return score, tuple(sorted(set(cylinder_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}))
planar_relation_fields = self._first_level_planar_relation_fields(
subject_face_ids=(),
adjacent_face_ids=adjacent_face_ids,
edge_axis=self._edge_axis_tuple(edge_id),
relation_scope="edge",
)
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),
**planar_relation_fields,
"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)},
)
planar_relation_fields = {
key: value
for key, value in topology.items()
if str(key).startswith("first_level_planar_relation_")
}
planar_summary = str(planar_relation_fields.get("first_level_planar_relation_summary") or "").strip()
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."
)
if planar_summary and planar_relation_fields.get("first_level_planar_relation_status") == "ready":
summary = f"{summary} {planar_summary}"
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": summary,
**planar_relation_fields,
}
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